diff --git a/.github/scripts/osv-scan.sh b/.github/scripts/osv-scan.sh new file mode 100755 index 00000000..9602bbe8 --- /dev/null +++ b/.github/scripts/osv-scan.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Scans the dependencies the SDK modules ship with against the OSV database (osv.dev). +# +# Maven resolves the tree, so the scan sees exactly what a consumer gets: every reactor module and +# every transitive compile, runtime and provided dependency. Test scope is left out, and so is the +# integration-testing/ harness, which is not a reactor module. +# +# Fails when a finding is HIGH or CRITICAL (CVSS 7.0 or more) or has no severity score yet, unless +# osv-scanner.toml accepts it. Lower findings are reported and do not fail. +# +# Usage: .github/scripts/osv-scan.sh (OSV_SCANNER overrides the scanner binary) +set -euo pipefail + +OSV_SCANNER="${OSV_SCANNER:-osv-scanner}" +CYCLONEDX_VERSION=2.9.3 +OUT=target/osv + +cd "$(dirname "$0")/../.." + +entries=$(grep -c '^\[\[IgnoredVulns\]\]' osv-scanner.toml || true) +for key in id ignoreUntil reason; do + n=$(grep -c "^${key} *=" osv-scanner.toml || true) + if [ "$n" != "$entries" ]; then + echo "osv-scanner.toml: each [[IgnoredVulns]] entry needs id, ignoreUntil and reason" >&2 + exit 2 + fi +done + +rm -f target/*-cyclonedx.json +./mvnw -B -q "org.cyclonedx:cyclonedx-maven-plugin:${CYCLONEDX_VERSION}:makeAggregateBom" \ + -DoutputFormat=json -DincludeTestScope=false +mkdir -p "$OUT" +cp target/*-cyclonedx.json "$OUT/bom.cdx.json" + +scan() { + "$OSV_SCANNER" scan source --config osv-scanner.toml --lockfile "$OUT/bom.cdx.json" "$@" +} + +# Exit status 1 means vulnerabilities were found; anything above 1 is a scanner failure. +status=0 +scan --format json --output-file "$OUT/results.json" || status=$? +if [ "$status" -gt 1 ]; then + echo "osv-scanner failed with exit status $status" >&2 + exit "$status" +fi +scan --format sarif --output-file "$OUT/results.sarif" || [ $? -eq 1 ] +scan --format markdown --output-file "$OUT/results.md" || [ $? -eq 1 ] + +packages=$(jq '[.components[] | select(.type == "library")] | length' "$OUT/bom.cdx.json") +blocking=$(jq -r ' + .results[]?.packages[] | .package as $p | .groups[] + | select(.max_severity == "" or (.max_severity | tonumber) >= 7.0) + | "| \($p.name) | \($p.version) | \(.ids | join(", ")) | \(.aliases | map(select(startswith("CVE-"))) | join(", ")) | \(if .max_severity == "" then "unscored" else .max_severity end) |" +' "$OUT/results.json") + +{ + echo "## Dependency vulnerability scan" + echo + echo "Scanned $packages packages (the SDK modules and their runtime dependencies, test scope excluded) against OSV." + if [ "$entries" -gt 0 ]; then + echo "$entries accepted in osv-scanner.toml until their ignoreUntil date." + fi + echo + if [ -n "$blocking" ]; then + echo "**FAILED: HIGH, CRITICAL or unscored vulnerabilities found.**" + echo + echo "| Package | Version | Advisory | CVE | CVSS |" + echo "|---|---|---|---|---|" + echo "$blocking" + echo + elif [ "$status" -eq 1 ]; then + echo "Passed: only findings below HIGH, listed below." + echo + else + echo "Passed: no known vulnerabilities." + echo + fi + if [ "$status" -eq 1 ]; then + cat "$OUT/results.md" + fi +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + +[ -z "$blocking" ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383f82df..7c886921 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: java-version: '17' distribution: 'temurin' cache: maven + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} - name: Build with Maven run: ./mvnw clean verify -B @@ -39,6 +44,31 @@ jobs: path: '**/target/surefire-reports/' retention-days: 7 + # The SDK targets and releases on Java 17, but Error Prone (which runs NullAway) needs + # JDK 21. The parent pom's errorprone profile activates on JDK 21+, so this job is the one + # that enforces Error Prone's bug checks and the @NullMarked packages at ERROR. + errorprone: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 21 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + - name: Build with Maven (Error Prone and NullAway enforced) + run: ./mvnw -B clean verify + # Issue #14 only reproduced when the JVM was starved of cores: the agent's lock release # and the client's next prompt race on one CPU. Pin the contention tests to one core so # the window stays open in CI. @@ -55,6 +85,48 @@ jobs: java-version: '17' distribution: 'temurin' cache: maven + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} - name: Contention tests pinned to one CPU run: taskset -c 0 ./mvnw -B -pl acp-core -am test -Dtest='MultiPromptSyncSessionTest,AcpAgentSessionPromptLockTest' -Dsurefire.failIfNoSpecifiedTests=false + + # Lincheck model checks the SDK's concurrent state (the *LincheckTest classes): every + # interleaving of seeded, bounded scenarios against a sequential specification. The build + # runs them with a small budget; this job explores ten times as many scenarios. Scenarios and + # interleavings come from a fixed seed, so a failure reproduces locally with the same + # -Dlincheck.scale. + lincheck: + runs-on: ubuntu-latest + # One job per module: at scale 10 the acp-core models alone take about 17 minutes. + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + module: [acp-core, acp-http-core] + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 17 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + # Install first rather than -am: a -Dtest filter would also reach the JSON modules, + # which scan acp-core's test-jar for their contract tests. + - name: Install the modules under test + run: ./mvnw -B -q install -DskipTests -Dpmd.skip -Dcpd.skip + + - name: Lincheck model checking, extended budget + run: ./mvnw -B -pl ${{ matrix.module }} test -Dtest='*LincheckTest' -Dlincheck.scale=10 diff --git a/.github/workflows/cross-sdk-smoke.yml b/.github/workflows/cross-sdk-smoke.yml new file mode 100644 index 00000000..ca417aad --- /dev/null +++ b/.github/workflows/cross-sdk-smoke.yml @@ -0,0 +1,121 @@ +name: Real-agent smoke + +# The Java stdio client from this checkout against published ACP agent CLIs (Gemini CLI, the +# Claude Agent ACP adapter, the Codex ACP adapter); see integration-testing/programs/smoke/. +# Manual dispatch only. It never gates push, pull request or release. +# +# Each agent job needs that agent's API key as a repository secret (GEMINI_API_KEY, +# ANTHROPIC_API_KEY, OPENAI_API_KEY). Adding secrets is an org-admin act. Until a secret exists +# its job prints "SKIPPED: secret X not configured" and succeeds, so nothing here needs editing +# once the keys are added. +on: + workflow_dispatch: + inputs: + agent: + description: 'Agent to run' + type: choice + options: [all, gemini, claude, codex] + default: all + keyless: + description: 'With no secret, still run the keyless steps (initialize, session/new) instead of skipping' + type: boolean + default: false + # Enable once the secrets exist (see integration-testing/programs/smoke/README.md): + # schedule: + # - cron: '41 5 * * *' + +permissions: + contents: read + +concurrency: + group: cross-sdk-smoke-${{ github.ref }} + cancel-in-progress: false + +jobs: + smoke: + name: smoke (${{ matrix.agent }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + # Optional hardening: an environment named "smoke" with required reviewers would gate every + # run on an approval and scope the secrets to it. Creating environments needs admin rights, + # so it is not required here; to use it, create it, move the secrets into it, and uncomment. + # environment: smoke + strategy: + fail-fast: false + matrix: + include: + - agent: gemini + secret: GEMINI_API_KEY + - agent: claude + secret: ANTHROPIC_API_KEY + - agent: codex + secret: OPENAI_API_KEY + + steps: + - name: Decide whether to run + id: gate + env: + HAS_SECRET: ${{ secrets[matrix.secret] != '' }} + SELECTED: ${{ inputs.agent == '' || inputs.agent == 'all' || inputs.agent == matrix.agent }} + KEYLESS: ${{ inputs.keyless == true }} + AGENT: ${{ matrix.agent }} + SECRET_NAME: ${{ matrix.secret }} + run: | + if [ "$SELECTED" != "true" ]; then + echo "run=false" >> "$GITHUB_OUTPUT" + echo "SKIPPED: $AGENT not selected" | tee -a "$GITHUB_STEP_SUMMARY" + elif [ "$HAS_SECRET" = "true" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + echo "auth=key" >> "$GITHUB_OUTPUT" + elif [ "$KEYLESS" = "true" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + echo "auth=none" >> "$GITHUB_OUTPUT" + echo "Secret $SECRET_NAME not configured: keyless steps only, the prompt step SKIPs" | tee -a "$GITHUB_STEP_SUMMARY" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "SKIPPED: secret $SECRET_NAME not configured (an org admin adds it; see integration-testing/programs/smoke/README.md)" | tee -a "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + if: steps.gate.outputs.run == 'true' + + - name: Set up JDK 17 + if: steps.gate.outputs.run == 'true' + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Cache the Maven distribution the wrapper uses + if: steps.gate.outputs.run == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + # Node 22: the Claude Agent ACP adapter declares node >=22. + - name: Set up Node 22 + if: steps.gate.outputs.run == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + + - name: Smoke ${{ matrix.agent }} + if: steps.gate.outputs.run == 'true' + env: + # Each job sees only its own agent's key. + GEMINI_API_KEY: ${{ matrix.agent == 'gemini' && secrets.GEMINI_API_KEY || '' }} + ANTHROPIC_API_KEY: ${{ matrix.agent == 'claude' && secrets.ANTHROPIC_API_KEY || '' }} + OPENAI_API_KEY: ${{ matrix.agent == 'codex' && secrets.OPENAI_API_KEY || '' }} + AGENT: ${{ matrix.agent }} + AUTH: ${{ steps.gate.outputs.auth }} + run: integration-testing/programs/smoke/run-smoke.sh "$AGENT" --auth "$AUTH" + + - name: Upload smoke logs + if: always() && steps.gate.outputs.run == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: smoke-${{ matrix.agent }}-logs + path: integration-testing/logs/smoke/ + retention-days: 14 diff --git a/.github/workflows/cross-sdk.yml b/.github/workflows/cross-sdk.yml index baf5e72a..29b1d619 100644 --- a/.github/workflows/cross-sdk.yml +++ b/.github/workflows/cross-sdk.yml @@ -1,19 +1,28 @@ name: Cross-SDK and load scenarios -# Runs integration-testing/ (interop with the TypeScript, Rust and Python SDKs, plus load runs) -# against this checkout. Deliberately not on push or pull_request: it needs Node, Rust and Python -# toolchains, clones the peer SDKs and takes minutes. It never gates the release workflow. +# Runs integration-testing/ (interop with the TypeScript, Rust, Python and Kotlin SDKs, plus load +# runs) against this checkout. One job per peer and transport, each installing only its own +# toolchain; a prepare job per peer builds the peer once and caches it under the peer's commit, so +# that peer's transport jobs reuse the build. Deliberately not on push or pull_request (the +# Java <-> Java cells run there, from interop-java.yml): it needs several toolchains, clones the +# peer SDKs and takes minutes. It never gates the release workflow. on: workflow_dispatch: inputs: peers-ref: - description: 'Branch, tag or SHA to test every peer SDK at' + description: 'Branch, tag or SHA to test every peer SDK at (empty: each peer''s ref in peers.json)' required: false - default: 'main' + default: '' peer-overrides: description: 'Per-peer refs, space separated, e.g. "typescript-sdk=v0.5.0 rust-sdk=v1.2.0"' required: false default: '' + peer: + description: 'Run one peer only (java: the Java <-> Java cells, conformance and load)' + type: choice + required: false + default: 'all' + options: [all, java, typescript, rust, python, kotlin] schedule: - cron: '17 4 * * *' @@ -25,47 +34,257 @@ concurrency: cancel-in-progress: false jobs: + # The job matrices, computed so that a manual run can pick one peer (a static include cannot be + # filtered by an input). One "prepare" leg per peer SDK, one "cells" leg per peer and transport. + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + prepare: ${{ steps.pick.outputs.prepare }} + cells: ${{ steps.pick.outputs.cells }} + has-prepare: ${{ steps.pick.outputs.has-prepare }} + steps: + - id: pick + env: + PEER: ${{ github.event.inputs.peer || 'all' }} + run: | + # Per peer: its toolchain, its transports (cells x-java-- and x--java-), + # and the cache paths of its build. A peer's legs also run its quarkus-- scenarios + # (the Quarkus-hosted agent against that peer's client, with 1 MB and 8 MB payloads), where + # they exist. The framework smoke cells (fw-*, load-10-fw-*) run in framework-smoke.yml. + # java runs as one leg: the Java <-> Java cells, conformance, load (not load-10-fw-*). + # kotlin has no Streamable HTTP; its Gradle toolchain is JDK 21 while the SDK and the + # Java programs stay on 17 (the last version listed is the default). + peers="$(jq -c . <<'JSON' + [ + { "peer": "java", "java": "17", "only": "x-java-java-*,conf-*,load-50,load-300,load-1000,load-shared-300" }, + { "peer": "typescript", "sdk": "typescript-sdk", "java": "17", "node": true, + "transports": ["stdio", "http", "ws"], + "cache-paths": "integration-testing/.cache/peers/typescript-sdk@*\n~/.npm" }, + { "peer": "rust", "sdk": "rust-sdk", "java": "17", "rust": true, + "transports": ["stdio", "http", "ws"], + "cache-paths": "integration-testing/.cache/peers/rust-sdk@*\nintegration-testing/.cache/build\n~/.cargo/registry/index\n~/.cargo/registry/cache\n~/.cargo/git/db" }, + { "peer": "python", "sdk": "python-sdk", "java": "17", "python": true, + "transports": ["stdio", "http", "ws"], + "cache-paths": "integration-testing/.cache/peers/python-sdk@*" }, + { "peer": "kotlin", "sdk": "kotlin-sdk", "java": "21\n17", "gradle": true, + "transports": ["stdio", "ws"], + "cache-paths": "integration-testing/.cache/peers/kotlin-sdk@*\n~/.gradle/caches\n~/.gradle/wrapper" } + ] + JSON + )" + picked="$(jq -c --arg p "$PEER" '[.[] | select($p == "all" or .peer == $p)]' <<< "$peers")" + prepare="$(jq -c '{include: [.[] | select(.sdk) + | . + {only: (["x-*-\(.peer)-*", "x-\(.peer)-*", "quarkus-\(.peer)-*"] | join(","))} + | del(.transports)]}' <<< "$picked")" + cells="$(jq -c '{include: [.[] as $x | ($x.transports // ["all"])[] as $t + | $x + (if $t == "all" then {leg: $x.peer} else {leg: "\($x.peer)-\($t)", transport: $t, + only: (["x-java-\($x.peer)-\($t)*", "x-\($x.peer)-java-\($t)*", + "quarkus-\($x.peer)-\($t)*"] + | join(","))} end) + | del(.transports)]}' <<< "$picked")" + jq . <<< "$cells" + { + echo "prepare=$prepare" + echo "cells=$cells" + echo "has-prepare=$(jq '.include | length > 0' <<< "$prepare")" + } >> "$GITHUB_OUTPUT" + + # Clone and build each peer once, and cache it under the peer's resolved commit for its legs. + prepare: + name: prepare ${{ matrix.peer }} + needs: plan + if: needs.plan.outputs.has-prepare == 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.prepare) }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: maven + + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + - name: Set up Node 20 + if: matrix.node + id: node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + + - name: Set up Rust stable + if: matrix.rust + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 + with: + toolchain: stable + + - name: Set up Python 3.12 + if: matrix.python + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Point Gradle toolchains at JDK 21 + if: matrix.gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.java.installations.fromEnv=JAVA_HOME_21_X64" >> ~/.gradle/gradle.properties + # The Kotlin SDK's buildSrc needs Gradle itself to run on JDK 21 (JAVA_HOME is 17 here). + echo "org.gradle.java.home=$JAVA_HOME_21_X64" >> ~/.gradle/gradle.properties + + - name: Set up JBang + uses: jbangdev/setup-jbang@2b1b465a7b75f4222b81426f23a01e013aa7b95c # v0.1.1 + with: + version: '0.135.1' + setup-java: 'false' + + - name: Resolve the peer SDK commit + id: peer + env: + SDK: ${{ matrix.sdk }} + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} + PEER_OVERRIDES: ${{ github.event.inputs.peer-overrides || '' }} + run: integration-testing/scripts/peer-sha.sh "$SDK" "$PEERS_REF" "$PEER_OVERRIDES" >> "$GITHUB_OUTPUT" + + # The key carries the toolchain version the build depends on (the Python venv, Node's + # node_modules) and the commit; a newer commit restores the last build and updates it. + - name: Cache the peer SDK build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ matrix.cache-paths }} + key: cross-sdk-${{ matrix.sdk }}-tc${{ steps.python.outputs.python-version }}${{ steps.node.outputs.node-version }}-${{ steps.peer.outputs.sha }}-${{ hashFiles('integration-testing/programs/**/Cargo.toml', 'integration-testing/programs/**/*.gradle.kts') }} + restore-keys: | + cross-sdk-${{ matrix.sdk }}-tc${{ steps.python.outputs.python-version }}${{ steps.node.outputs.node-version }}- + + - name: Fetch and build the ${{ matrix.peer }} peer and programs + env: + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} + PEER_OVERRIDES: ${{ github.event.inputs.peer-overrides || '' }} + ONLY: ${{ matrix.only }} + run: | + args=(--only "$ONLY" --prepare-only) + if [ -n "$PEERS_REF" ]; then args+=(--peers-ref "$PEERS_REF"); fi + for o in $PEER_OVERRIDES; do args+=(--peer "$o"); done + integration-testing/scripts/run-all.sh "${args[@]}" + + - name: Upload preparation logs + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: cross-sdk-prepare-logs-${{ matrix.peer }} + path: integration-testing/logs/ + if-no-files-found: ignore + retention-days: 14 + scenarios: + name: ${{ matrix.leg }} + needs: [plan, prepare] + # Also runs when a prepare leg failed (that peer's legs then build for themselves) or was + # skipped (java only). + if: ${{ !cancelled() && needs.plan.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 45 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.cells) }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - name: Set up JDK 17 + - name: Set up JDK uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: - java-version: '17' + java-version: ${{ matrix.java }} distribution: 'temurin' cache: maven + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + - name: Set up Node 20 + if: matrix.node + id: node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - name: Set up Rust stable + if: matrix.rust uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # v1 with: toolchain: stable - name: Set up Python 3.12 + if: matrix.python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' + - name: Point Gradle toolchains at JDK 21 + if: matrix.gradle + run: | + mkdir -p ~/.gradle + echo "org.gradle.java.installations.fromEnv=JAVA_HOME_21_X64" >> ~/.gradle/gradle.properties + # The Kotlin SDK's buildSrc needs Gradle itself to run on JDK 21 (JAVA_HOME is 17 here). + echo "org.gradle.java.home=$JAVA_HOME_21_X64" >> ~/.gradle/gradle.properties + - name: Set up JBang uses: jbangdev/setup-jbang@2b1b465a7b75f4222b81426f23a01e013aa7b95c # v0.1.1 with: version: '0.135.1' setup-java: 'false' - - name: Run every scenario + - name: Check the generated configs are up to date + if: matrix.peer == 'java' + working-directory: integration-testing + run: | + jbang GenConfigs.java --check + python3 programs/raw/gen_conf.py --check + + - name: Resolve the peer SDK commit + if: matrix.sdk + id: peer + env: + SDK: ${{ matrix.sdk }} + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} + PEER_OVERRIDES: ${{ github.event.inputs.peer-overrides || '' }} + run: integration-testing/scripts/peer-sha.sh "$SDK" "$PEERS_REF" "$PEER_OVERRIDES" >> "$GITHUB_OUTPUT" + + # Restore only: the prepare job saved this key. On a miss the leg builds for itself. + - name: Restore the peer SDK build + if: matrix.sdk + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ matrix.cache-paths }} + key: cross-sdk-${{ matrix.sdk }}-tc${{ steps.python.outputs.python-version }}${{ steps.node.outputs.node-version }}-${{ steps.peer.outputs.sha }}-${{ hashFiles('integration-testing/programs/**/Cargo.toml', 'integration-testing/programs/**/*.gradle.kts') }} + restore-keys: | + cross-sdk-${{ matrix.sdk }}-tc${{ steps.python.outputs.python-version }}${{ steps.node.outputs.node-version }}- + + - name: Run the ${{ matrix.leg }} scenarios env: - PEERS_REF: ${{ github.event.inputs.peers-ref || 'main' }} + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} PEER_OVERRIDES: ${{ github.event.inputs.peer-overrides || '' }} + ONLY: ${{ matrix.only }} + RUN_LABEL: ${{ matrix.leg }} run: | - args=(--peers-ref "$PEERS_REF") + args=(--only "$ONLY") + if [ -n "$PEERS_REF" ]; then args+=(--peers-ref "$PEERS_REF"); fi for o in $PEER_OVERRIDES; do args+=(--peer "$o"); done integration-testing/scripts/run-all.sh "${args[@]}" @@ -73,6 +292,7 @@ jobs: if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: cross-sdk-logs + name: cross-sdk-logs-${{ matrix.leg }} path: integration-testing/logs/ + if-no-files-found: ignore retention-days: 14 diff --git a/.github/workflows/dependency-scan.yml b/.github/workflows/dependency-scan.yml new file mode 100644 index 00000000..0432e014 --- /dev/null +++ b/.github/workflows/dependency-scan.yml @@ -0,0 +1,74 @@ +name: Dependency vulnerability scan + +# Checks the resolved runtime dependency tree of every SDK module against OSV (osv.dev), so a CVE +# published after a release is caught without waiting for the next build. OSV needs no API key. +# Fails on HIGH or CRITICAL; see .github/scripts/osv-scan.sh for the rule and osv-scanner.toml for +# accepted findings. +on: + schedule: + - cron: '23 5 * * 1' + workflow_dispatch: + push: + branches: [main] + paths: + - '**/pom.xml' + - '!integration-testing/**' + - 'osv-scanner.toml' + - '.github/scripts/osv-scan.sh' + - '.github/workflows/dependency-scan.yml' + +permissions: + contents: read + +concurrency: + group: dependency-scan-${{ github.ref }} + cancel-in-progress: true + +env: + OSV_SCANNER_VERSION: '2.6.0' + OSV_SCANNER_SHA256: 'ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108' + +jobs: + osv-scan: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + security-events: write + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 17 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Install osv-scanner + run: | + curl -sSfL -o "$RUNNER_TEMP/osv-scanner" \ + "https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64" + echo "${OSV_SCANNER_SHA256} $RUNNER_TEMP/osv-scanner" | sha256sum --check --strict + chmod +x "$RUNNER_TEMP/osv-scanner" + echo "OSV_SCANNER=$RUNNER_TEMP/osv-scanner" >> "$GITHUB_ENV" + + - name: Scan dependencies + run: .github/scripts/osv-scan.sh + + - name: Upload SARIF to code scanning + if: always() && hashFiles('target/osv/results.sarif') != '' + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + sarif_file: target/osv/results.sarif + category: osv-scanner + + - name: Upload scan results + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dependency-scan + path: target/osv/ + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/framework-smoke.yml b/.github/workflows/framework-smoke.yml new file mode 100644 index 00000000..45a0a719 --- /dev/null +++ b/.github/workflows/framework-smoke.yml @@ -0,0 +1,104 @@ +name: Framework smoke matrix + +# The release gate of integration-testing/README.md, "Framework smoke matrix": the SDK hosted by +# Spring Boot, Micronaut and Quarkus against the TypeScript SDK (every fw-* and load-10-fw-* +# scenario, generated from integration-testing/smoke.json). Runs on every push to main, nightly, +# and on manual dispatch for any branch, which is how a candidate branch is gated: dispatch it on +# that branch ("Run workflow" -> "Use workflow from"), or +# gh workflow run framework-smoke.yml --ref +# It needs only JDK 17, Node 20 and the TypeScript SDK, and takes about 10 minutes. The full +# cross-SDK matrix stays in cross-sdk.yml. +on: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + schedule: + - cron: '43 3 * * *' + workflow_dispatch: + inputs: + peers-ref: + description: 'Branch, tag or SHA of the TypeScript SDK (empty: its ref in peers.json)' + required: false + default: '' + +permissions: + contents: read + +concurrency: + group: framework-smoke-${{ github.ref }} + cancel-in-progress: false + +jobs: + smoke: + name: framework smoke + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 17 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + - name: Set up Node 20 + id: node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + + - name: Set up JBang + uses: jbangdev/setup-jbang@2b1b465a7b75f4222b81426f23a01e013aa7b95c # v0.1.1 + with: + version: '0.135.1' + setup-java: 'false' + + - name: Check the generated configs are up to date + working-directory: integration-testing + run: jbang GenConfigs.java --check + + - name: Resolve the TypeScript SDK commit + id: peer + env: + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} + run: integration-testing/scripts/peer-sha.sh typescript-sdk "$PEERS_REF" "" >> "$GITHUB_OUTPUT" + + # The same key as cross-sdk.yml's typescript legs, so either workflow's build is reused. + - name: Cache the TypeScript SDK build + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + integration-testing/.cache/peers/typescript-sdk@* + ~/.npm + key: cross-sdk-typescript-sdk-tc${{ steps.node.outputs.node-version }}-${{ steps.peer.outputs.sha }}-${{ hashFiles('integration-testing/programs/**/Cargo.toml', 'integration-testing/programs/**/*.gradle.kts') }} + restore-keys: | + cross-sdk-typescript-sdk-tc${{ steps.node.outputs.node-version }}- + + - name: Run the framework smoke matrix + env: + PEERS_REF: ${{ github.event.inputs.peers-ref || '' }} + RUN_LABEL: framework-smoke + run: | + args=(--tag fw) + if [ -n "$PEERS_REF" ]; then args+=(--peers-ref "$PEERS_REF"); fi + integration-testing/scripts/run-all.sh "${args[@]}" + + - name: Upload scenario logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: framework-smoke-logs + path: integration-testing/logs/ + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/interop-java.yml b/.github/workflows/interop-java.yml new file mode 100644 index 00000000..e7fb4051 --- /dev/null +++ b/.github/workflows/interop-java.yml @@ -0,0 +1,69 @@ +name: Java interop cells + +# The generated Java client <-> Java agent cells of integration-testing/ over stdio, Streamable +# HTTP and WebSocket, on every push to main and every pull request. They need only the JDK, so +# they are cheap; the per-peer cells run nightly in cross-sdk.yml. Also checks that the generated +# configs (GenConfigs.java and programs/raw/gen_conf.py) match steps.json, matrix.json, raw.py and +# expectations/. +on: + push: + branches: [main] + paths-ignore: + - 'docs/**' + - '*.md' + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: interop-java-${{ github.ref }} + cancel-in-progress: true + +jobs: + java-java: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 17 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Cache the Maven distribution the wrapper uses + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.m2/wrapper + key: maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }} + + - name: Set up JBang + uses: jbangdev/setup-jbang@2b1b465a7b75f4222b81426f23a01e013aa7b95c # v0.1.1 + with: + version: '0.135.1' + setup-java: 'false' + + - name: Check the generated configs are up to date + working-directory: integration-testing + run: | + jbang GenConfigs.java --check + python3 programs/raw/gen_conf.py --check + + - name: Run the Java <-> Java cells + env: + RUN_LABEL: java-java + run: integration-testing/scripts/run-all.sh --only 'x-java-java-*' + + - name: Upload scenario logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: interop-java-logs + path: integration-testing/logs/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/pit.yml b/.github/workflows/pit.yml new file mode 100644 index 00000000..745b31fc --- /dev/null +++ b/.github/workflows/pit.yml @@ -0,0 +1,45 @@ +name: Mutation testing (PIT) + +# Runs the pit profile (acp-core's protocol core: the session classes, their shared +# request/response plumbing and the json package) and uploads the HTML report. Manual only: +# mutation testing is a measurement, not a gate on every change. The run fails if the +# mutation score drops below the profile's mutationThreshold. +on: + workflow_dispatch: + inputs: + mutators: + description: 'PIT mutator group' + type: choice + options: + - DEFAULTS + - STRONGER + default: DEFAULTS + +permissions: + contents: read + +jobs: + pit: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK 21 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + + - name: Run PIT + run: ./mvnw -B -Ppit -pl acp-core -am test -Dpit.mutators=${{ inputs.mutators }} + + - name: Upload PIT report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pit-report-${{ inputs.mutators }} + path: acp-core/target/pit-reports/ + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b710f6a..3e01d7af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ permissions: jobs: release: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 diff --git a/.gitignore b/.gitignore index a6c434ca..ead89b73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ ### Maven/Gradle Builds ### target/ .m2repo/ -!.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ build/ @@ -58,6 +57,8 @@ derby.log ### Package Files ### *.jar +# The Maven wrapper jar is committed so mvnw needs no download; this must follow *.jar to win. +!.mvn/wrapper/maven-wrapper.jar *.war *.nar *.ear diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 00000000..32599cef --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,10 @@ +--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 00000000..bf82ff01 Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/AGENTS.md b/AGENTS.md index 38d34d9c..c115688e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ key passes Jackson-based peers and breaks strict ones. Unstable protocol methods Client-session notifications are delivered in order through a single sink drained by `concatMap`, and `closeGracefully()` waits for that drain, bounded by the request timeout, while `close()` -interrupts. Sync clients always have async handlers, because every sync consumer is wrapped in +interrupts. Sync clients always have async handlers, because every sync handler is wrapped in `subscribeOn`. Keep both properties when touching `AcpClientSession` or a transport, and add the test that would have caught their loss. diff --git a/CHANGELOG.md b/CHANGELOG.md index 827934a5..256f2d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,2218 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.80.0] - 2026-10-08 + +### Known issues + +- **Spring WebFlux on Jetty:** Jetty's connection idle timeout, 30 seconds by default, closes an ACP + WebSocket after 30 seconds without traffic. Set `server.jetty.connection-idle-timeout` (for + example `30m`), or use Reactor Netty, the default for WebFlux. A fix is planned for 0.80.1. +- **Micronaut** serves ACP on the SDK's listener, on its own port, so Micronaut's own security does + not apply to it. This is an interim arrangement until the Netty host planned for 0.81.0. +- An oversized request body may surface as a connection error rather than HTTP 413 on Tomcat + (reactive) and Jetty hosts. The request is refused either way; only the error the client sees + differs. Bodies within the limit (16 MiB by default) are not affected. +- The outbound queues of the stdio transports and the WebSocket client are unbounded: a peer that + keeps sending requests without reading the responses grows the sender's memory, about one + response per request. The server-side HTTP and WebSocket hosts are bounded. Bounded outbound + queues are planned for 0.81.0. + +### Security + +- Every WebSocket host now closes idle connections (after `webSocketIdleTimeout`, 30 minutes by + default, close code 1001) and connections that do not send `initialize` within + `initializeTimeout` (30 s by default, close code 1008). The idle timeout previously applied only + on the servlet and Jetty hosts; WebFlux and Quarkus had none, and no host closed a WebSocket that + never initialized. + +- **Quarkus: HTTP security policies now apply to the WebSocket handshake.** The extension's + WebSocket route ran ahead of Quarkus's authentication and permission checks (order + `Integer.MIN_VALUE`), so an unauthenticated client was upgraded on a path that + `quarkus.http.auth.permission` protected (101 instead of 401). The endpoint is now one Vert.x + route in the router's ordinary order, behind those checks, for HTTP and WebSocket alike. + +- **The SDK's listener binds the loopback interface by default.** `StreamableHttpAcpAgentTransport` + set a port and no host on its Jetty connector, so it listened on every network interface, and + anyone who could reach the machine could start an agent: the endpoint has no authentication of + its own. It now binds `127.0.0.1`, and `::1` too where the machine has IPv6, unless told + otherwise. Remote exposure is an explicit opt-in: `StreamableHttpAcpAgentTransportOptions.builder() + .host("0.0.0.0")` (or one address), `spring.acp.agent.transport.http.listener.host` (Spring Boot, + non-web applications), `acp.agent.transport.http.listener.host` (Micronaut) and + `transport.http.listener.host` in `AcpAgentSettings`. Servers a framework runs (a Spring MVC + servlet container, Quarkus) keep the framework's own bind settings (`server.address`, + `quarkus.http.host`). Migration: a deployment that reached the listener from another machine or + a container sets the host to `0.0.0.0`, ideally behind a proxy that authenticates. + +- **Browser requests from a foreign origin are refused (403).** Any web page the user visited could + POST to, or open a WebSocket to, an agent on `localhost` (cross-site requests, DNS rebinding). + Every host now checks the `Origin` header, on HTTP requests and on the WebSocket handshake: a + request without one (any non-browser client) is served, and so is one from + `http(s)://localhost`, `127.0.0.1` or `[::1]` on any port; any other origin gets 403 unless it is + listed. The rule lives in the shared endpoint code, so the servlet, the SDK listener (HTTP and + WebSocket) and Quarkus (servlet and WebSocket route) all apply it. New: + `StreamableHttpAcpAgentTransportOptions.Builder.allowedOrigins(Collection)` (`*` for any) and + `isOriginAllowed(String)`; `spring.acp.agent.transport.http.allowed-origins`, + `acp.agent.transport.http.allowed-origins` (Micronaut), + `quarkus.acp.agent.transport.http.allowed-origins`, and `transport.http.allowed-origins` in + `AcpAgentSettings`. Migration: a browser application served from another origin that talks to + the agent directly lists that origin. + + +- **An invalid JSON-RPC request no longer reaches the logs.** `AcpSchema.deserializeJsonRpcMessage` + refused a request or notification JSON-RPC 2.0 does not allow (no `jsonrpc` or one other than + `"2.0"`, a method that is not a string, an id that is not a string, number or null) with an + exception whose message quoted the whole message text, and every transport logs that exception + at WARN or ERROR: the stdio agent and client, the WebSocket and Streamable HTTP clients, and the + server's WebSocket on every host (the SDK listener, the servlet, WebFlux, Quarkus). A peer's + malformed `session/prompt` put the user's prompt in the log. The message now names the member at + fault only. + +- **The WebSocket client logs its endpoint without the query or user information.** + `WebSocketAcpClientTransport` logged the full endpoint URI at INFO when connecting and at ERROR + when the connect failed; a token in the query string (`?token=...`) or a password in the URI + reached the log. It now logs scheme, host, port and path. + +- **A peer's error message and data are no longer logged above DEBUG.** An error answer is the + peer's own text. Three log lines at WARN carried it: a peer's error report for a message it could + not read (`PendingResponses`), a notification handler that failed with the peer's `AcpError`, and + a failed `terminal/release` of a cancelled `execute`. Each now logs the error code and the SDK's + name for it (`-32603 Internal error`); the peer's message and data are logged at DEBUG. + +- **Documented: what the SDK's logs can carry.** A stdio agent's standard error is logged at INFO, + unfiltered, on `com.agentclientprotocol.sdk.client.transport.agent-stderr`; silence it with that + logger's level or `setStdErrorHandler`. DEBUG and TRACE on `com.agentclientprotocol.sdk` log + message payloads, session updates included, and should not be enabled where logs are shipped. + +- **A handler's unexpected exception no longer sends its message to the peer.** A request handler + that failed with an exception other than `AcpProtocolException` was answered `-32603` with the + exception's message, which can carry paths, SQL, URLs with credentials or tokens. It is now + answered `-32603` with the message "Internal error", on both sides, and the exception is logged + at WARN, with its stack trace, by the side that handled the request. An `AcpProtocolException` + is unchanged: its code, message and data are the handler's intended answer. Migration: a handler + whose exception message the peer should see throws `AcpProtocolException` with that message. + +- Jackson 2.22.2 → **2.22.3** and Jackson 3.1.5 → **3.1.7**, clearing CVE-2026-89407, CVE-2026-89425, + CVE-2026-91776, CVE-2026-91777 (both lines) and CVE-2026-19032, CVE-2026-68497, CVE-2026-83557 (Jackson 3). + 0.18.0 shipped with the affected versions; applications can override the versions now. + +### Added + +- `StreamableHttpAcpAgentTransportOptions.Builder.webSocketIdleTimeout(Duration)` (default 30 + minutes) and `initializeTimeout(Duration)` (default 30 seconds), with the record components of + the same names. The endpoint closes a WebSocket connection idle for `webSocketIdleTimeout` with + 1001 and one that has not sent `initialize` within `initializeTimeout` with 1008, on every host; + `initializeTimeout` also bounds the agent's answer to a POST `initialize` (500 after it), which + was a fixed 30 seconds. `AcpWsHandshake.Accepted.idleTimeout()` returns the configured value plus + five seconds, so the servlet's Jakarta WebSocket session idle timeout follows it as a backstop + that never races the endpoint's own close. Framework properties: + `spring.acp.agent.transport.http.web-socket-idle-timeout` and `…initialize-timeout` (Spring + Boot), `acp.agent.transport.http.web-socket-idle-timeout` and `…initialize-timeout` + (Micronaut), `quarkus.acp.agent.transport.http.web-socket-idle-timeout` and + `…initialize-timeout` (Quarkus), and `transport.http.web-socket-idle-timeout` and + `transport.http.initialize-timeout` in `AcpAgentSettings` (with `Limits.webSocketIdleTimeout`, + `Limits.initializeTimeout` and the builder methods of the same names). + +- **One framework-neutral ACP endpoint and a host contract: `acp-http-core`.** Every protocol rule + of the Streamable HTTP and WebSocket transports now lives in one place, + `com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint`: routing, sessions, the bounded SSE + mailboxes and WebSocket send queue, the status and close-code table, the `Origin` check, + keep-alive and drain. A server adapter (a host) only adapts I/O through the contract types + `AcpHttpExchange`, `AcpHttpReply` (`Body`, `Empty`, `EventStream`), `SseFrame`, `AcpWsHandshake`, + `AcpWsOutbound` and `AcpWsHandler`, all `@UnstableAcpApi`: applications use the hosts the SDK + ships. The one neutral WebSocket connection replaces the Jetty-native one. +- **WebSocket on the servlet, on the application's own server.** `StreamableHttpAcpServlet` (new + module `acp-http-servlet`, no Jetty dependency) upgrades `Upgrade: websocket` requests on its own + path through Jakarta WebSocket 2.1 (`ServerContainer.upgradeHttpToWebSocket`), on Tomcat, Jetty + and Undertow. A container without a Jakarta WebSocket implementation answers the upgrade 501. + Its outbound frames are serialized, one write at a time, as Tomcat requires. +- **Spring Boot: ACP on `server.port`, under the application's security.** A servlet web + application now serves Streamable HTTP, SSE and WebSocket on its own server, through its filter + chain: Spring Security rules on `/acp` apply to HTTP and to the WebSocket handshake, and the + principal reaches the endpoint (`AcpHttpExchange.principal()`). The endpoint is an + `AcpHttpEndpoint` bean. With `server.compression.enabled`, `text/event-stream` is no longer + compressed. +- **Shutdown drains open streams.** Closing the endpoint (servlet, listener, Spring's + `SmartLifecycle` before graceful shutdown) gives each open SSE stream a closing comment + (`: shutting down`) and completes it, and closes each WebSocket with 1001 (going away), so a + framework's graceful shutdown never waits for them. +- **SSE responses carry `X-Accel-Buffering: no`**, so nginx and similar proxies do not buffer + them, besides `Cache-Control: no-cache`. +- **Spring WebFlux host: `acp-http-webflux`.** `AcpWebFluxHost` mounts the endpoint as a + `RouterFunction` (`routerFunction("/acp")`, or `handle(ServerWebExchange)` as a `WebHandler`): + Streamable HTTP, SSE and WebSocket on one path of a WebFlux application's own server, Reactor + Netty, Tomcat or Jetty, behind the application's `WebFilter`s. It resolves the reactive principal + (`ServerWebExchange.getPrincipal()`, Spring Security's authentication) before the endpoint reads + it; flushes each SSE frame; aggregates a request body no further than one byte past the limit; + on Reactor Netty raises the WebSocket frame limit above the endpoint's, so an oversized message + closes with 1009 rather than 1011; and closes a WebSocket with the endpoint's close code before + it ends the outbound stream, so clients never see 1005. WebFlux reports no completion per + WebSocket frame, so the host hands WebFlux a frame only on its demand: a client that stops + reading fills the endpoint's bounded send queue (`maxWebSocketPendingFrames`) and the socket + closes with 1011, instead of frames piling up in the host. The transport TCK runs it on Reactor + Netty and on Tomcat. +- **Spring Boot: ACP in a reactive (WebFlux) web application, on `server.port`.** With + `spring.acp.agent.transport.type=http` (or `websocket`) and `acp-http-webflux` on the classpath, a + reactive application routes `spring.acp.agent.transport.http.path` to the endpoint with a + `RouterFunction` bean named `acpRouterFunction` (an application's own bean of that name replaces + it), on its own server, behind its `SecurityWebFilterChain`: security rules on `/acp` apply to HTTP + and to the WebSocket handshake, and the principal reaches the endpoint. The same + `spring.acp.agent.transport.*` properties apply; no new keys. The endpoint is an `AcpHttpEndpoint` + bean, drained by a `SmartLifecycle` before the server's graceful shutdown, and with + `server.compression.enabled`, `text/event-stream` is taken out of the compressed types, as in a + servlet application. The application's `http.server.requests` observations cover `/acp`. +- **A shared transport TCK in `acp-test`:** `com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck`, + the suite every host runs (the servlet on Tomcat 11 and Jetty 12.1, the embedded listener, Spring + MVC), with `HttpProbes` for raw HTTP and WebSocket checks. `acp-test` now depends on + `junit-jupiter-api`, `assertj-core` and `acp-http-core`. + +- **`AcpTransportThreads` in `acp-integration`: the threads a framework's network transports run + on.** `AcpClientTransports.create(settings, prefix, threads)` and + `AcpListeners.listener(settings, factory, threads)` take one: `executor(Executor)` for the + framework's executor, `sdkDefault()` for the SDK's own threads (virtual on JDK 21 and later), or + `platform()` for platform threads on every JDK. The existing overloads use `sdkDefault()`. The + stdio transports keep their own threads, which block on the process's pipes. + +- **The network transports can run on the application's executor.** + `StreamableHttpAcpClientTransportOptions.builder().executor(Executor)` runs the Streamable HTTP + client's work (the default `HttpClient`, handing over HTTP results, reading each SSE stream) on + that executor; `new WebSocketAcpClientTransport(URI, AcpJsonMapper, Executor)` runs the WebSocket + client's `HttpClient` and its frame writer on it; and + `StreamableHttpAcpAgentTransportOptions.builder().executor(Executor)` (JDK 21 and later) serves + the listener on Jetty's `VirtualThreadPool` with every task on it. None of them creates a pool of + its own then, and none shuts the executor down: the application owns it. A JDK `HttpClient` still + keeps its one selector thread, and Jetty's `VirtualThreadPool` parks one platform thread while + the listener runs. +- **`virtualThreads(boolean)` on `StreamableHttpAcpClientTransportOptions.Builder` and + `StreamableHttpAcpAgentTransportOptions.Builder`** (default `true`): `false` keeps the + transport's platform-thread pools on every JDK, for an application that has not opted into + virtual threads. + +- **Client prompt cancellation in the completion menu: `prompt(request, CancellationSignal)`.** + `AcpAsyncClient.prompt(request, stop)` and `AcpSyncClient.prompt(request, stop)` take a new + `com.agentclientprotocol.sdk.client.CancellationSignal`; calling `stop.cancel()`, from any thread, + sends `session/cancel` for the prompt's session, once, and the prompt still returns the agent's + answer (stop reason `cancelled`) with the turn's updates. Cancelling after the answer sends + nothing. Before, stopping a turn and keeping its answer took + `contextWrite(RequestCancellation.cancelWhen(trigger))`, which no completion menu offers and the + sync client could not use; `RequestCancellation` stays, for `$/cancel_request` on any request. + Migration: none; `prompt(request)` is unchanged. To stop a turn and keep its answer, replace + `prompt(request).contextWrite(RequestCancellation.cancelWhen(trigger))` with + `prompt(request, stop)` and call `stop.cancel()`. + +- **Builder handlers receive their agent.** Every typed request setter of `AcpAgent.async(..)` + and `AcpAgent.sync(..)` except `promptHandler` has an overload taking an + `AgentAwareHandler` (async, `(request, AcpAsyncAgent agent)`) or + `SyncAgentAwareHandler` (sync, `(request, AcpSyncAgent agent)`); a + two-argument lambda picks it. The agent is the one `build()` returned, so a `session/load` + replay or a `ConfigOptionUpdate` after `session/set_config_option` no longer needs an + `AtomicReference` holding the built agent: + `.setSessionConfigOptionHandler((request, self) -> { self.sendSessionUpdate(request.sessionId(), update); return response; })`. + One-argument handlers are unchanged. The two interfaces are top-level types in + `com.agentclientprotocol.sdk.agent`. Migration: none; a handler that read the built agent from + an `AtomicReference` can take it as its second parameter instead: + `.loadSessionHandler(request -> agentRef.get()...)` → `.loadSessionHandler((request, self) -> self...)`. + Name the agent parameter `self` (or anything but the variable the built agent is assigned to): + `AcpSyncAgent agent = AcpAgent.sync(t).newSessionHandler((request, agent) -> ...)` does not + compile, since a lambda parameter cannot shadow a local variable. + +- **Extension methods from inside handlers.** `SessionClient` (`PromptContext.client()`) gains + `sendExtRequest(String, Object, TypeRef)`, `sendExtRequest(String, Object)` and + `sendExtNotification(String, Object)`, and `SyncSessionClient` (`SyncPromptContext.client()`) + their blocking forms, the same calls as `AcpAsyncAgent`/`AcpSyncAgent`, so a prompt handler can + send `_`-prefixed requests and notifications to the client: + `context.client().sendExtRequest("_example.com/ping", params, PONG)`. `extRequestHandler` and + `extNotificationHandler` on both agent builders gain agent-aware overloads, typed and raw: a + two-argument lambda receives the params and the agent `build()` returned + (`AgentAwareHandler` and `AgentAwareHandler` on the async builder, + `SyncAgentAwareHandler` and `BiConsumer` on the sync one), for example + `.extRequestHandler("_example.com/ask", ASK, (ask, self) -> self.sendExtRequest("_example.com/confirm", ask, ANSWER))`. + Migration: none; a test double that implements `SessionClient` or `SyncSessionClient` adds the + three methods. + +- **Capability builders that lead to each choice.** `ClientCapabilities.builder()` gains + `readTextFile()`, `writeTextFile()`, `terminal()`, `elicitationForm()` and `elicitationUrl()`; + `AgentCapabilities.builder()` gains `loadSession()`, `promptImage()`, `promptAudio()`, + `promptEmbeddedContext()`, `mcpHttp()` and `mcpSse()`. Each sets one flag and keeps the others + (and the nested record's `_meta`), so `ClientCapabilities.builder().readTextFile().terminal().build()` + replaces `fs(new FileSystemCapability(true, false)).terminal(true)` and its two adjacent booleans. + The setters that take the nested records stay. Migration: none; optionally + `.fs(new FileSystemCapability(true, true))` → `.readTextFile().writeTextFile()` and + `.loadSession(true)` → `.loadSession()`. + +- **Micronaut: `acp.client.capabilities.elicitation-form`, `elicitation-url` and + `boolean-config-options`** (default `false`), as Spring Boot and Quarkus already offer. The + Micronaut client configuration hard-coded the three as not advertised, so a Micronaut client + could not advertise elicitation or boolean config options from its settings. + +- **Transport constructors without a JSON mapper:** `StreamableHttpAcpClientTransport(URI)`, + `WebSocketAcpClientTransport(URI)`, `StreamableHttpAcpAgentTransport(int port, AcpAgentFactory)` + and `StreamableHttpAcpServlet(AcpAgentFactory)` use `AcpJsonMapper.createDefault()`, as the stdio + transports already did. + +- **`AgentParameters.Builder.inheritEnvironment(boolean)`: start a stdio agent without the + client's environment.** The "safe" default variables of `AgentParameters` (HOME, PATH, USER and + the like) had no effect: `StdioAcpClientTransport` added them to the whole environment the + process inherits, so every secret in the client's environment reached the agent. Inheriting stays + the default and is now documented as such. With `inheritEnvironment(false)` the process starts + from an empty environment and gets only `getEnv()`: the safe defaults and the variables added on + the builder (`addEnvVar("GEMINI_API_KEY", ...)` for a key the agent needs). + `AgentParameters.isInheritEnvironment()` reports the choice. + +- **Annotated handlers may return a `CompletionStage` or a single-value `Publisher`**, besides + the value itself and a `Mono`, with the same meaning: the runtime waits for the value on the + handler's thread. A `@Prompt` method's `Mono` (or `CompletionStage`) now sends + the text as an agent message chunk and ends the turn, as a `String` does; it used to fail every + prompt with `-32603`. **Breaking:** `MonoHandler` is renamed `AsyncValueHandler`, and + `getMonoGenericType` is now `getValueType`. + +- **`AcpAgentSupport.Builder.run()`** builds the agent on its transport, starts it and blocks until + the transport ends: `AcpAgentSupport.create(new MyAgent()).transport(new StdioAcpAgentTransport()).run()`, + the bootstrap the README and Javadoc showed, now compiles. + +- **Typed values in `@SetSessionConfigOption` methods.** New parameter annotations `@ConfigId` + (the option id, a `String`) and `@ConfigValue` (the new value, typed by the parameter: `String` + for a select option, `boolean`/`Boolean` for a boolean option, `Object` for either), resolved by + the new `ConfigOptionResolver`. A value of the other kind than the parameter's is answered + `-32602` (invalid params) without calling the method. The SDK still does not reject an id or a + value the session never offered; that needs per-session option tracking and is the method's to + check. +- **Spring Boot autoconfiguration and starter, now part of the SDK.** The former + `spring-ai-community/acp-autoconfig` project moves into this repository, with its history, as two modules + released with the SDK: + - `com.agentclientprotocol:acp-spring-boot-starter` and `com.agentclientprotocol:acp-spring-boot-autoconfigure`, + for Spring Boot 4.1. Boot's BOM is imported by these two modules only; the SDK's other modules do not depend on Spring. + - Packages `com.agentclientprotocol.sdk.spring.boot.autoconfigure` (`.client`, `.agent`). + - **Client:** an `AcpSyncClient` and an `AcpAsyncClient` over one transport from `spring.acp.client.transport.*`: + stdio (`stdio.command`), WebSocket (`websocket.uri`) or Streamable HTTP (`http.uri`). An explicit `type` + without its property fails at startup, naming the property. `AcpClientCustomizer` beans register session-update, + permission, file system and terminal handlers. File system and terminal capabilities are off by default: + advertise one only together with its handler. + - **Agent:** the application's single `@AcpAgent` bean is served over stdio (the default) or, with + `spring.acp.agent.transport.type=http` and one HTTP module on the classpath, over Streamable HTTP and + WebSocket: `acp-http-servlet` in a servlet web application and `acp-http-webflux` in a reactive one, both + on the application's own server, or `acp-streamable-http-jetty`, the SDK's listener, in an application + without a web server. A stdio agent closes the application context when its client + closes stdin, so a `spring.main.keep-alive` application exits 0 (`spring.acp.agent.shutdown-on-transport-end`). + + **Migrating from `org.springaicommunity:acp-spring-boot-starter` 0.12.0** (the old repository is redirected and + publishes no further releases): + - Coordinates: `org.springaicommunity:acp-spring-boot-starter` becomes `com.agentclientprotocol:acp-spring-boot-starter`, + with the SDK's version. Drop any separate ACP SDK version pin; the starter brings the matching SDK modules. + - Imports: `com.agentclientprotocol.autoconfigure.*` becomes `com.agentclientprotocol.sdk.spring.boot.autoconfigure.*`. + Most applications import nothing from these packages; they configure `spring.acp.*`, whose keys are unchanged + except as listed here. + - Behaviour changes since 0.12.0: + - the `spring.acp.agent.transport.websocket.*` properties are removed; they never configured anything; + - `spring.acp.client.capabilities.read-text-file` and `write-text-file` now default to `false`; + - a client-only application no longer gets an agent transport bean; + - session updates with no consumer of your own are logged at DEBUG instead of a WARN; + - `spring.acp.agent.request-timeout` and `spring.acp.client.request-timeout` have no default of their own: + unset, they keep the SDK's one default request timeout (60 s), so the property follows the SDK; + - several client transports set with no `spring.acp.client.transport.type` fail at startup, naming them; the + websocket, then http, then stdio one used to win silently. Set the type, or leave only one set; + - the listener-only agent properties move: `spring.acp.agent.transport.http.port` becomes + `spring.acp.agent.transport.http.listener.port`, and `...http.max-concurrent-streams-per-connection` becomes + `...http.listener.max-concurrent-streams-per-connection`. In a servlet web application they meant nothing; + - `spring.acp.agent.transport.type=websocket` is accepted and means the same as `http`: the endpoint takes + WebSocket upgrades on its path; + - new properties: `spring.acp.client.prompt-timeout`, `spring.acp.client.capabilities.elicitation-form`, + `elicitation-url` and `boolean-config-options`, `spring.acp.agent.cancel-grace-period` and + `spring.acp.agent.max-prompt-duration`; + - `ArgumentResolver` and `ReturnValueHandler` beans are added to the agent, as `AcpInterceptor` beans were; + - `AcpClientCustomizer` moves to `com.agentclientprotocol.sdk.integration`, and `TransportType` is replaced by + `com.agentclientprotocol.sdk.integration.AcpTransportType`; + - closing the client waits at most its request timeout plus 10 s, then closes it at once; + - a second `@AcpAgent` bean fails with "Found 2 @AcpAgent beans [...]", naming + `spring.acp.agent.enabled=false` as the way to serve none; + - the agent's handler methods can run on a Spring executor: `spring.acp.agent.handler-executor` names an + `Executor` bean (a plain `TaskExecutor` is adapted), or `none` for the SDK's own pool. Unset, they run on + the context's `applicationTaskExecutor` when `spring.threads.virtual.enabled=true` (a virtual thread per + handler), and on the SDK's pool otherwise: without virtual threads Boot's executor is a pool of 8 threads, + which would cap the prompts served at once. + - Everything else in this release's breaking changes applies too: Spring applications compile against the + SDK's API. + +- **`acp-integration`: the framework-neutral half of the framework integrations**, package + `com.agentclientprotocol.sdk.integration`. The Spring Boot, Micronaut and Quarkus integrations are thin layers + over it, so each rule below holds the same in all three. Depends on `acp-core` and `acp-agent-support`, with + `acp-streamable-http-jetty` optional, and on no framework (ArchUnit). See `acp-integration/README.md`. + - Settings: `AcpAgentSettings` and `AcpClientSettings`, records with builders, and `from(SettingsSource, prefix)` + for kebab-case key/value configuration. An unset value keeps the SDK default. `AcpTransportType`. + - Transports: `AcpClientTransports` (an explicit type wins; otherwise the one transport configured; several with + no type fail, naming them), `AcpAgentTransports.stdio()`, and `AcpListeners` for the SDK's listener and + servlet, with `isListenerAvailable()`. + - Agents: `AcpAgentDiscovery.requireSingle` over `AgentCandidate`s (user class plus instance supplier, so a + proxied bean keeps its advice) or over class names for build-time discovery; `AcpAgents.builder`. + - Clients: `AcpClients` (one async client and its sync facade, a DEBUG session-update consumer, the + customizers in order) and `AcpClientCustomizer`, now one type for every framework. + - Lifecycles: the `AcpHost` contract (`start`, `stopGracefully`, `stop(Duration)`, `termination`, `port`, + `holdJvmUntilTermination`) with `AcpAgentHost` (one transport; a latched `onTransportEnd` action on a host + thread, never run when the host stopped the agent) and `AcpListenerHost`; `AcpServletHost` and + `AcpClientHost`. + - Handler executors: `AcpAgents.builder(..., handlerExecutor)` passes a framework's executor to + `AcpAgentSupport.Builder.handlerExecutor`, so annotated handlers run on the framework's threads instead of a + second pool; unset keeps the SDK's own pool. + +- **`acp-micronaut`: Micronaut 4 integration** (Java 17+), with `acp-micronaut-sample` (not published). + - **Agent.** Annotate an `@AcpAgent` class `@Singleton` and it is served with the application + context. The bean is found from its compile-time bean definition; exactly one is allowed. + `AcpInterceptor`, `ArgumentResolver` and `ReturnValueHandler` beans are added to it, and handlers + may also return a Reactive Streams `Publisher`. Configured under `acp.agent.*`: stdio by default + (the context closes and the process exits 0 when the client closes the agent's input; SIGTERM + closes the agent gracefully), or `http`/`websocket` through the SDK's Jetty listener on its own + port, with every `StreamableHttpAcpAgentTransportOptions` limit as a property, plus + `cancel-grace-period` and `max-prompt-duration`. As in Spring Boot and `acp-integration`, the + listener's own keys sit under `acp.agent.transport.http.listener` (`host`, `port`, + `max-concurrent-streams-per-connection`); the keys any host of the endpoint reads (`path`, + `allowed-origins`, the buffer limits, `shutdown-timeout`) stay under `acp.agent.transport.http`. + - **Client.** `acp.client.*` builds the stdio, WebSocket or Streamable HTTP transport, an + `AcpAsyncClient` customized by `AcpClientCustomizer` beans in order, and an `AcpSyncClient` over + it; it closes gracefully, once, with the context. + - The Micronaut platform BOM (4.10) is imported only by these modules, after the SDK's Reactor, + Jackson, Jetty and JUnit BOMs, so they run on the SDK's versions. The wire format stays the SDK's + Jackson mapper. + - Built on `acp-integration`: `AcpClientCustomizer` and the transport type are its + `com.agentclientprotocol.sdk.integration` types, and an explicit client type without its property + fails with "...transport.type=http requires ...transport.http.uri". + - On JDK 21 and later the agent's handler methods run on Micronaut's virtual-thread executor + (`TaskExecutors.VIRTUAL`) instead of the SDK's own pool; on JDK 17 they stay on the SDK's pool. + +- **Quarkus extension: `acp-quarkus` (with `acp-quarkus-deployment`).** One `@AcpAgent` class + becomes a singleton bean (found at build time; a second one fails the build) and is served over + stdio, or over Streamable HTTP and WebSocket on the Quarkus HTTP server + (`quarkus.acp.agent.transport.type=http`): one Vert.x route on the Quarkus router for HTTP, SSE + and WebSocket, no servlet container and no second server. Stdio builds keep standard output for the + protocol (console log on stderr, no banner, no HTTP listener) and exit when input ends. Handlers + may return Mutiny `Uni`, and `@Prompt` may stream a `Multi`. `AcpSyncClient`/`AcpAsyncClient` + beans come from `quarkus.acp.client.*` with `AcpClientCustomizer` beans. Configuration mirrors the + Spring Boot starter's (`quarkus.acp.*`; the HTTP port is `quarkus.http.port`). JVM mode only. + Built on Quarkus 3.40.1, whose BOM is imported only by these modules. See `acp-quarkus/README.md`. + Cross-SDK: `quarkus-typescript-http` and `quarkus-typescript-ws` run the TypeScript client + against a Quarkus-hosted agent. Built on `acp-integration`: `AcpClientCustomizer` and the transport + type are its `com.agentclientprotocol.sdk.integration` types, `quarkus.acp.agent.transport.type` + also accepts `websocket` (the same as `http`), and several client transports set with no + `quarkus.acp.client.transport.type` fail at startup, naming them, as in the other frameworks. + The agent's handler methods run on the `ManagedExecutor` (Quarkus' worker pool, with context + propagation) instead of the SDK's own pool. +- **Prompt handlers see their prompt's cancellation.** `SyncPromptContext.isCancelled()` and + `onCancel(Runnable)`, and `PromptContext.isCancelled()` and `whenCancelled()` (a `Mono` + that completes on cancel), signal a cancel by `session/cancel` for the prompt's session or by + `$/cancel_request` for its request, and also when the agent cancels the handler itself (the + cancel grace period or `maxPromptDuration` passed, or the connection closed). An annotated + `@Prompt` method reads it from the context it takes, with no `@Cancel` handler or shared state; + a builder prompt handler likewise. After `session/cancel` the handler still answers `cancelled` + within the cancel grace period; after `$/cancel_request` the agent has already answered, so the + handler just stops. New `PromptResponse.cancelled()`. + **Breaking** for code implementing `PromptContext` or `SyncPromptContext` itself (test doubles): + implement the new methods. + +- **`$/cancel_request` (ACP v1, Cancellation), both directions.** Client and agent sessions alike: + - **Cancelling a request you sent:** dispose (cancel) the subscription to its `Mono` before the + response arrives, directly or through `timeout(...)`, `take...`, or the SDK's own request + timeout, and the session sends `$/cancel_request {"requestId": ...}` to the peer, once, after + the request itself was written (never for a request that failed to send, was already answered, + or was dismissed by closing the session). The caller's `Mono` is cancelled at once (Reactor + semantics); the peer's late answer is expected and discarded at DEBUG. An agent prompt handler + whose subscription is cancelled cancels the `fs/*`, `terminal/*` and + `session/request_permission` requests it is waiting on the same way. Interrupting a thread + blocked in a sync client or agent call cancels its request too. + - **Graceful cancel:** `request.contextWrite(RequestCancellation.cancelWhen(trigger))` sends + `$/cancel_request` when `trigger` emits or completes but keeps waiting, so the request ends with + the peer's answer: its result, or an `AcpError` with code `-32800`. Works for every typed request + method on both sides; at most one `$/cancel_request` per request either way. + - **Answering a cancelled request:** on `$/cancel_request` the session cancels the handler's + subscription and answers the request with error `-32800` (`Request cancelled`), unless the + handler answered first; exactly one response either way (model checked with Lincheck, + `InboundRequestsLincheckTest`). An unknown or already answered id is ignored. A cancelled + `session/prompt` ends its turn before the answer is published; one already cancelled with + `session/cancel` is answered with stop reason `cancelled` instead, as ACP v1 requires of a + cancelled prompt. `$/cancel_request` is handled by the session and never reaches notification + handlers; on the client it is not queued behind slow `session/update` consumers. + - **Internal cancellation:** a handler that fails with `CancellationException`, an interrupt, or + an `AcpProtocolException` with code `-32800` is answered `-32800` (a prompt being cancelled: + stop reason `cancelled`). Closing a session cancels the requests it is still handling and + answers them `-32800` where the transport still delivers. + - Over Streamable HTTP, `$/cancel_request` is connection-scoped both ways, like every `$/` + method and as in the Rust SDK: the client posts it without `Acp-Session-Id`, the agent sends + it on the connection stream. It can therefore overtake a request still on a session stream; + the receiver then ignores it as an unknown id (the spec's MAY) and the request completes. + - Params of a received `$/cancel_request` without a string or integer `requestId` are ignored + (a notification has no response to carry `-32602`). + - `AcpSchema.METHOD_CANCEL_REQUEST`, `AcpSchema.CancelRequestNotification`, + `RequestCancellation`, `AcpSchedulers.timeoutDelivery()`. + - Cross-SDK suite: the Java programs run `cancel-request.client`, `cancel-request.agent` and + `cancel-request.unknown`; their `P2` expected failures are gone. + +- **Prompt timeouts on the agent builders** (`AcpAgent.async(...)`, `AcpAgent.sync(...)` and + `AcpAgentSupport.builder()`), also as `PromptTimeouts` on the `AcpAgentSession` constructor: + - `cancelGracePeriod(Duration)`: how long a prompt handler has to answer after `session/cancel` + before the agent answers `cancelled` itself. Default 60 seconds (see Changed). + - `maxPromptDuration(Duration)`: how long a prompt may run at all. When it passes, the agent + cancels the handler and answers with JSON-RPC error `-32800` (request cancelled), message + `Prompt exceeded maxPromptDuration of ` and data `{"maxPromptDuration": ""}` + (ACP v1, Cancellation: an internally cancelled request, an internal timeout included, answers + `-32800`); a prompt already being cancelled is answered `cancelled` instead. Default: none, as + a prompt turn can legitimately run for a long time. + + `Duration.ZERO` turns either off; a negative or null duration is rejected. Either way the forced + answer leaves through the normal path: the turn ends just before it is published, it follows + every update the handler had already sent, and exactly one answer is sent when the handler + answers just as a timer fires (model checked with Lincheck, `PromptAnswerLincheckTest`). The + timers run on the SDK's shared timeout timer, with no new threads. Cancelling a sync handler + interrupts its thread (the SDK's sync handler scheduler interrupts on cancel); a handler that + ignores the interrupt, or any handler that keeps running after its subscription is cancelled, + has the updates it then sends through its prompt context dropped (see Fixed). `AcpErrorCodes.REQUEST_CANCELLED` (`-32800`) is new. +- **`session_info_update`** (`AcpSchema.SessionInfoUpdate`): the agent tells the client the + session's title and last activity time. Stable in ACP v1. Known limit: the schema lets a peer + send `null` to clear a field; the record reads an explicit `null` like a missing field and never + writes one, so it cannot express a clear. +- **`configOptions` on `NewSessionResponse`, `LoadSessionResponse` and `ResumeSessionResponse`** + (ACP v1 schema; model selection moved to config options when `session/set_model` was removed). + An agent advertises its config options and their current values when the session is created, + loaded or resumed, and the client reads them from the response. New constructors + `NewSessionResponse(sessionId, modes, configOptions)` and `LoadSessionResponse(modes, + configOptions)` / `ResumeSessionResponse(modes, configOptions)`. +- **Tool call `name`** on `ToolCall`, `ToolCallUpdateNotification` and `ToolCallUpdate` (stable in + ACP v1 since 2026-09-17): the programmatic name of the tool being invoked, next to the + human-readable `title`. Optional; absent and `null` both mean no name. +- **Terminal authentication** (stable in ACP v1 since 2026-08-20): the `terminal` auth method + (`AcpSchema.AuthMethodTerminal`, with the extra `args` and `env` the client runs the agent + program with for an interactive login) and the client capability `auth.terminal` + (`AcpSchema.AuthCapabilities` on `ClientCapabilities.auth`). Agents check it with + `NegotiatedCapabilities.supportsTerminalAuth()` / `requireTerminalAuth()`. +- **Boolean config options are stable** (ACP v1 since 2026-07-06): `SessionConfigBoolean` is no + longer `@UnstableAcpApi`. The client capability `session.configOptions.boolean` + (`ClientCapabilities.session`, `ClientSessionCapabilities`, `SessionConfigOptionsCapabilities`, + `BooleanConfigOptionCapabilities`; `SessionConfigOptionsCapabilities.withBoolean()`) tells the + agent it may send them; agents check `NegotiatedCapabilities.supportsBooleanConfigOptions()`. +- **Building a model picker and other categorized config options.** Model choice is a select + config option with `category: "model"`, and before only the 8-argument canonical + `SessionConfigSelect` constructor took a category. New: + - `SessionConfigSelect.model(id, name, currentValue, options)`, with `options` a + `List` or a (grouped) `SessionConfigSelectOptions`: a select + option with category `"model"`. + - `SessionConfigSelect.builder()`: `id`, `name`, `description`, `category`, `currentValue`, + `options(List)` (flat), `groups(List)`, `options(SessionConfigSelectOptions)` + and `meta`, then `build()`. + - `SessionConfigBoolean.builder()`: `id`, `name`, `description`, `category`, + `currentValue(boolean)` and `meta`, then `build()`. + - `AcpSchema.SessionConfigOptionCategory`: the categories ACP v1 reserves, as `String` + constants `MODE`, `MODEL`, `MODEL_CONFIG` and `THOUGHT_LEVEL`. `category` stays an open + `String`; custom categories start with `_`. + + `build()` throws `IllegalStateException` naming the first missing required field (`id`, `name`, + `currentValue`, and for a select the options). +- **`ClientCapabilities.builder()` and `AgentCapabilities.builder()`** reach every field without + positional `null`s. Advertising boolean config options was the 6-argument canonical + constructor with three `null`s; it is now + `ClientCapabilities.builder().session(ClientSessionCapabilities.withBooleanConfigOptions()).build()`. + - `ClientCapabilities.Builder`: `fs`, `terminal`, `session`, `auth`, `elicitation`, `meta`. + It starts from `new ClientCapabilities()` (file system `false`/`false`, terminal `false`). + - `AgentCapabilities.Builder`: `loadSession`, `sessionCapabilities`, `mcpCapabilities`, + `promptCapabilities`, `auth`, `providers` (`@UnstableAcpApi`), `meta`. It starts from + `new AgentCapabilities()` (no `session/load`, default MCP and prompt capabilities). + - `ClientSessionCapabilities.withBooleanConfigOptions()`: `{"configOptions": {"boolean": {}}}`. +- **Agent capability `auth.logout`** (`AgentCapabilities.auth`, `AgentAuthCapabilities`, + `LogoutCapabilities`; `AgentAuthCapabilities.withLogout()`): the agent advertises that it + supports `logout`, and clients check `NegotiatedCapabilities.supportsLogout()` / + `requireLogout()` before calling it. +- **`_meta` on every record the ACP schema gives it.** 48 records lacked it, among them + `RequestPermissionRequest`/`Response`, the `fs/*` and `terminal/*` requests and responses, + `AuthenticateRequest`/`Response`, `SetSessionModeRequest`/`Response`, `Implementation`, the + MCP server records, `PlanEntry`, `AvailableCommand`, `ToolCallUpdate`, the tool call content + records, `Annotations` and the capability records. Each gains a nullable `meta` component + (`"_meta"` on the wire) as its last component, and keeps a constructor with its previous + components, so existing calls compile; the exception is `ToolCallUpdate` (nine components + before `_meta`), which instead gains `ToolCallUpdate(toolCallId, title, kind, status)` for the + common permission-request case (migration: use it, or add `null` for `_meta`). A test walks a copy of the v1.9.1 schema + (`acp-core/src/test/resources/schema/v1/schema.json`) and fails on any object with `_meta` + whose record lacks it; the elicitation records are covered too (below). Not covered yet: the + presence markers typed `Object` (they keep `_meta` as a map entry). `CancelRequestNotification` + carries `_meta` (with `$/cancel_request`, below). + +- **`elicitation/complete` reaches a typed client handler:** `AcpClient.async(...) + .completeElicitationHandler(Function>)` and + `AcpClient.sync(...).completeElicitationHandler(Consumer)`. Before, + the agent could send the notification but a client could only read it as raw params through + `notificationHandler`. The spec requires clients to ignore unknown or already-completed + elicitation IDs; the handler sees every notification and does that check. +- `AcpSyncAgent.createElicitation` and `AcpSyncAgent.completeElicitation`, matching the async agent. +- `CreateElicitationResponse.accept()` without content (for an accepted URL elicitation), and the + mode constants `CreateElicitationRequest.MODE_FORM` and `MODE_URL`. +- The elicitation records carry every field of the stable schema: `_meta` on `ElicitationSchema`, + the five property schemas, both multi-select item types and `EnumOption`, and `description` on + `EnumOption`. The constructors without them remain, except for the two that would take seven or + more arguments. +- **Custom extension methods (`_`-prefixed), both directions, on all four APIs.** ACP v1 + (Extensibility) reserves method names that start with `_` for custom requests and notifications. + - Agents can now serve them: `AcpAgent.async(..)` and `AcpAgent.sync(..)` builders take + `extRequestHandler(method, paramsType, handler)` and + `extNotificationHandler(method, paramsType, handler)`, with a `TypeRef` for typed params, or + without one for the raw JSON value (`Map`, `List`, `String`, `Number`, `Boolean`). + Annotation-driven agents use `@ExtRequest("_name")` and `@ExtNotification("_name")` + (`acp-annotations`); the method's one parameter, if any, receives the params read as its type. + - Both sides can now send them: `AcpAsyncAgent`, `AcpSyncAgent`, `AcpAsyncClient` and + `AcpSyncClient` have `sendExtRequest(method, params, resultType)`, `sendExtRequest(method, params)` + (raw result) and `sendExtNotification(method, params)`. A `"result": null` answer completes the + async send empty and makes the sync send return `null`. + - Every one of these methods rejects a name that does not start with `_` with + `IllegalArgumentException`, so a custom handler cannot replace a protocol method and a custom + send cannot impersonate one. `ExtensionMethods` (`spec`) holds the rule. The client's untyped + `requestHandler(method, ..)` and `notificationHandler(method, ..)`, which take any name, are + unchanged. + - Unhandled ones follow the spec, on both sides: a request is answered with `-32601` (Method not + found), a notification is ignored. A handler that produces no result answers with `-32603`; return + an empty map when there is nothing to return. +- `TypeRef.of(Type)`: a type reference for a type known only at runtime. +- **`StreamableHttpAcpAgentTransport` listens on an ephemeral port when given port 0.** Before, the + constructor rejected 0 (`Port must be positive`), so a test or an embedding application had to + reserve a port and race to rebind it. A port outside 0–65535 is rejected. `getPort()` returns + the configured port (0 for an ephemeral one) until `start()` completes, then the port actually + bound, which it keeps reporting after the listener closes (before, Jetty's negative "not open" + value). +- **`AcpAgentSupport.Builder.buildFactory()`**: an `AcpAgentFactory` for a listener transport + (`StreamableHttpAcpAgentTransport`, `StreamableHttpAcpServlet`) that creates a fresh agent per + connection from one annotated handler bean. The bean is shared, like a Spring controller: every + connection's agent invokes the same instance, concurrently, so its handlers (and interceptors, + custom resolvers and return value handlers) must be thread-safe. Before, the documented pattern + rebuilt the builder, and rediscovered the handler methods, per connection. +- **`@Authenticate`**: the annotation for an `authenticate` handler, which an annotated agent could + not serve before (the client got `-32601`, Method not found). The method takes an optional + `AuthenticateRequest` (`AuthenticateRequestResolver`) and returns an `AuthenticateResponse` or a + `Mono` of one; throw `AcpProtocolException` with `AcpErrorCodes.AUTHENTICATION_REQUIRED` to reject + the attempt. +- **Any annotated handler can take its connection's agent**: an `AcpSyncAgent` or `AcpAsyncAgent` + parameter (`AgentResolver`, `AcpInvocationContext.getAgent()`), the agent serving the connection + the request arrived on, so a handler can send session updates (a `ConfigOptionUpdate`, say) and + requests to the client outside a prompt turn. Under `buildFactory()` one handler bean serves + every connection and so cannot keep "its" agent in a field; each call now gets its own + connection's. Extension handlers (`@ExtRequest`, `@ExtNotification`) may take these and + `NegotiatedCapabilities` besides their one params parameter + (`ExtensionParamsResolver.isConnectionType`). + +- **Shortcuts for the messages real code writes most:** `new NewSessionRequest(cwd)` (no MCP + servers), `new NewSessionResponse(sessionId)` (no modes or config options) and + `PromptRequest.text(sessionId, text)` (a prompt of one text block). Each equals, and writes the + same JSON as, the long form. + +- **`defaultSessionUpdateHandler(..)` on `AcpClient.AsyncSpec` and `SyncSpec`:** a session + update handler used only while no `sessionUpdateHandler` is added, so a framework can set a + default (such as logging each update at DEBUG) that the application's own handler replaces, + whichever is registered first. The Spring Boot autoconfiguration and the Micronaut and Quarkus + client beans now set their DEBUG-logging handler this way; it used to run beside every handler a customizer + added. `sessionUpdateHandler` stays additive. (Snapshots before the rename below called these + `defaultSessionUpdateConsumer` and `sessionUpdateConsumer`.) + +### Changed + +- **`AcpWsOutbound.close` returns a `CompletionStage`** (`@UnstableAcpApi`), which completes + once the close frame has been sent or the socket has closed; a graceful shutdown waits for it, + bounded by the shutdown timeout. Migration: a host of its own returns the stage its container + gives (WebFlux `session.close`, Vert.x `ServerWebSocket.close`), or one that completes when the + container reports the socket closed (Jakarta `Endpoint.onClose`). + +- **Spring Boot: a reactive (WebFlux) web application no longer fails the startup** with "The ACP + HTTP transport needs a servlet web application or the standalone listener + (acp-streamable-http-jetty); WebFlux is not supported". It serves the endpoint with + `acp-http-webflux` (see Added). Without that module the startup still fails, now naming it: "The + ACP HTTP transport in a reactive (WebFlux) web application needs acp-http-webflux on the + classpath". The error for `type=http` with no HTTP module at all names `acp-http-webflux` too. + Migration: a reactive application that serves ACP adds `com.agentclientprotocol:acp-http-webflux`. + +- **Breaking: Quarkus serves ACP on a Vert.x route, without a servlet container.** The extension + depends on `quarkus-vertx-http` instead of `quarkus-undertow`, and mounts the SDK's + `AcpHttpEndpoint` on the Quarkus router through a host of its own (`AcpVertxHost`); the servlet + and the separate WebSocket route (`AcpHttpServlet`, `AcpWebSocketRoute`, + `VertxWebSocketConnection`, the runtime bean `AcpHttpEndpoint`) are gone. Migration: the path is + under `quarkus.http.root-path`, no longer under `quarkus.servlet.context-path`; an application + that relied on the extension to bring `quarkus-undertow` adds it itself. + +- **Breaking: the HTTP transport is split into three modules.** `acp-http-core` (the endpoint and + `StreamableHttpAcpAgentTransportOptions`), `acp-http-servlet` (`StreamableHttpAcpServlet`) and + `acp-streamable-http-jetty` (`StreamableHttpAcpAgentTransport`, now embedded Jetty running the + servlet with Jetty's Jakarta WebSocket implementation). Class names and packages of the public + types are unchanged. Migration: an application mounting the servlet in its own container, a + Spring MVC application included, depends on `acp-http-servlet` instead of + `acp-streamable-http-jetty`, and no longer gets Jetty; an application using the SDK's listener + keeps `acp-streamable-http-jetty`, which now brings the Jakarta WebSocket 2.2 API (Jetty 12.1 + runs on it) rather than Jetty's 2.1, so it can no longer shadow Tomcat 11's. A Spring Boot servlet + web application on Tomcat whose classpath still puts an older Jakarta WebSocket API ahead of + Tomcat's fails at startup with a message naming the fix (`acp-http-servlet`, or managing + `jakarta.websocket-api` to 2.2.0), where before Tomcat failed every WebSocket send with + `NoSuchMethodError` and clients hung. +- **Breaking: `StreamableHttpAcpServlet` is a host of `AcpHttpEndpoint`.** It gained the + constructor `StreamableHttpAcpServlet(AcpHttpEndpoint)` and `endpoint()`; the package-private + routing, connection and SSE classes moved to `acp-http-core`. A subclass overriding `doGet`, + `doPost` or `doDelete` no longer intercepts anything: `service` hands every request to the + endpoint. Migration: wrap the `AcpHttpEndpoint` instead. +- **Breaking: the shutdown close code of a WebSocket is 1001 (going away)**, not 1000. Clients + that treated 1000 as the server's shutdown treat 1001 the same way. +- `AcpListeners.endpoint(AcpAgentSettings, AcpAgentFactory)` returns the endpoint for a framework + that mounts it itself. + +- **Breaking: new record components for the bind address and allowed origins.** + `StreamableHttpAcpAgentTransportOptions` gained `host` and `allowedOrigins`, + `AcpAgentSettings.Http` gained `allowedOrigins`, and `AcpAgentSettings.Listener` gained `host` + (its first component). Migration: build the options with `StreamableHttpAcpAgentTransportOptions.builder()` + and the settings with `AcpAgentSettings.builder()` rather than the canonical constructors; a + direct `new AcpAgentSettings.Listener(port, streams)` becomes `new Listener(null, port, streams)`. + +- **Quarkus: new `quarkus.acp.handler-executor=managed|virtual`; the client bean's network + transports run on it.** The default, `managed`, keeps the agent's handler methods on the + `ManagedExecutor` (the `executor-thread-*` worker pool), as before, with the MicroProfile Context + Propagation contexts it carries; the client bean's WebSocket and Streamable HTTP transports now run + on the same executor and create no pool of their own. `virtual` opts in to Quarkus' own + virtual-thread executor (`@VirtualThreads`, `quarkus-virtual-thread-*`) on JDK 21 and later (as + `managed` before), following Quarkus, where virtual threads are opt-in. What `virtual` does not + carry: the contexts of the submitting thread. A handler has no CDI request context of the caller's + (a `@RequestScoped` bean needs `@ActivateRequestContext` on the handler method), and no propagated + security identity, transaction or OpenTelemetry context; the Vert.x context is kept. JDK 21 to 23 + pin a virtual thread's carrier when it blocks inside `synchronized` (fixed in JDK 24, JEP 491). + +- **Micronaut: the listener and the client transports run on `TaskExecutors.VIRTUAL` too.** On JDK + 21 and later the HTTP/WebSocket listener and the auto-configured WebSocket and Streamable HTTP + client transports run on Micronaut's virtual-thread executor, as the handlers already did, and + create no pool of their own; before JDK 21 they keep the SDK's pools. Migration: none. + +- **Spring Boot follows its own virtual-thread opt-in for every ACP thread.** With + `spring.threads.virtual.enabled=true` (JDK 21 and later), the agent's handlers (as before), the + SDK's listener and the auto-configured WebSocket and Streamable HTTP client transports all run on + the `applicationTaskExecutor`, so the SDK creates no pool of its own. Without it, they all stay on + platform threads on every JDK: the handlers on the SDK's pool (`acp-agent-sync-handler`), the + listener on Jetty's pool and the client transports on their platform pools, rather than taking the + SDK's new virtual-thread default. The virtual-thread check is now Boot's own + (`Threading.VIRTUAL`), so the property set on JDK 17 no longer hands the handlers Boot's 8-thread + `applicationTaskExecutor`. Migration: none; set `spring.threads.virtual.enabled=true` for virtual + threads. + +- **The network transports use virtual threads by default on JDK 21 and later.** Without an + executor, the Streamable HTTP client runs its work on a virtual thread per task + (`acp-streamable-http`) instead of its three bounded pools (`acp-streamable-http-client`, + `-signal`, `-sse`); the WebSocket client runs its `HttpClient` and its writer on virtual threads + (`acp-ws-client`) instead of a cached pool and the `acp-ws-client-outbound` thread; and the + listener serves on Jetty's `VirtualThreadPool` (`acp-listener-*`) with its timer on a virtual + thread, instead of `qtp*` and `Scheduler-*` platform threads. The choice is made at run time; + JDK 17 keeps the pools. `httpWorkerThreads`, `httpSignalThreads` and `httpQueueCapacity` now + size only those pools, and `maxSseStreams` still bounds the streams. The same JDK 21 to 23 + `synchronized` pinning caveat applies (fixed in JDK 24, JEP 491). Migration: none; set + `virtualThreads(false)` on either options builder to keep the platform pools on JDK 21. +- **The servlet's SSE keep-alive no longer has a thread of its own.** Each + `StreamableHttpAcpServlet` (and so each listener) started a daemon thread + (`acp-streamable-http-keepalive`) for its keep-alive comments; they are now timed on the SDK's + shared timer and written off it. Migration: none. +- **`StreamableHttpAcpAgentTransportOptions`' canonical constructor takes two more components**, + `executor` and `virtualThreads`, and the client options' canonical constructor takes them too; + the client options keep their four-argument constructor. Migration: build the listener options + with `StreamableHttpAcpAgentTransportOptions.builder()`, or pass `null, true` for the new + components. + +- **Synchronous handlers run on virtual threads by default on JDK 21 and later.** A sync agent or + client built without `handlerExecutor(..)` (and `AcpAgentSupport` without one) ran its handlers + on a cached pool of daemon platform threads (`acp-agent-sync-handler`, `acp-sync-handler`). Where + the JDK has virtual threads, each handler call now runs on a virtual thread of its own, with the + same name; the choice is made at run time, so the Java 17 artifacts are unchanged and JDK 17 keeps + the pool. Caveat: on JDK 21 to 23 a virtual thread that blocks inside a `synchronized` block pins + its carrier thread, and enough pinned handlers can stall every other virtual thread; JDK 24 removed + that (JEP 491). Migration: none for most handlers; one that blocks inside `synchronized` on JDK 21 + to 23 can switch to a `ReentrantLock`, or pass a platform pool to `handlerExecutor(..)` to keep the + old behaviour. + +- **Timeout work runs on virtual threads on JDK 21 and later.** What a request timeout, the + stdio drain timer or a `$/cancel_request` triggers used to run on a cached pool of daemon + platform threads (`acp-timeout-delivery`); where the JDK has virtual threads it now runs on a + virtual thread per task, so no such pool exists. The shared timer itself (`acp-timeout`) stays + one daemon platform thread per JVM, so that handlers pinning every carrier thread cannot also + stop the timeouts meant to end them. On JDK 17 nothing changes. Migration: none. + +- **The client advertises the capabilities its handlers serve, as the agent side does.** A client + builder without `clientCapabilities(..)` now advertises `fs.readTextFile` and `fs.writeTextFile` + for their handlers, `terminal` once all five terminal handlers are registered (some but not all + log a warning naming the missing ones and advertise no terminal), and form-mode elicitation for + a `createElicitationHandler`. Before, it advertised nothing and only warned, so an SDK agent + refused to call the handlers. Merge rule: **explicit wins**. Capabilities set with + `clientCapabilities(..)` are sent as they are, nothing is derived, and the existing check + applies (an advertised capability without its handler fails `build()`; a handler without its + capability is a warning). Explicit capabilities are the one source of truth whenever they are + given, so the frameworks, which always pass the capabilities their settings name, advertise + exactly what the settings say, as before: a handler a customizer adds never widens them. + An OR of the two would let a handler silently advertise what a setting turned off. Set + capabilities explicitly for what handlers cannot express: URL-mode elicitation, boolean config + options, terminal authentication, `_meta`. Migration: none for code that sets + `clientCapabilities(..)`. A client that registered handlers without capabilities on purpose, + so that agents would not call them, passes `clientCapabilities(new ClientCapabilities())`. + +- **Breaking: the prompt context is split into its two layers.** `PromptContext` and + `SyncPromptContext` keep the convenience layer — `sendMessage`, `sendThought`, + `sendSessionUpdate`, `readFile`, `tryReadFile` (sync), `writeFile`, `askPermission`, `askChoice`, + `execute`, cancellation (`isCancelled`, `whenCancelled`/`onCancel`), `getSessionId`, + `getClientCapabilities` and `async()` (sync) — and the raw ACP requests move one step down to the + new `context.client()`: a `SessionClient` (async, `Mono`s) or `SyncSessionClient` (blocking) with + `readTextFile`, `writeTextFile`, `requestPermission`, `createTerminal`, `getTerminalOutput`, + `releaseTerminal`, `waitForTerminalExit`, `killTerminal`, `createElicitation` and + `completeElicitation`. The calls themselves are unchanged (same request records, capability + checks, timeouts and cancellation). `readFile` no longer sits beside `readTextFile`, nor + `askPermission` beside `requestPermission`, in the completion menu. Migration: insert + `.client()` before each raw call — + `context.readTextFile(request)` → `context.client().readTextFile(request)`, + `context.writeTextFile(request)` → `context.client().writeTextFile(request)`, + `context.requestPermission(request)` → `context.client().requestPermission(request)`, + `context.createTerminal(request)` → `context.client().createTerminal(request)` (likewise + `getTerminalOutput`, `releaseTerminal`, `waitForTerminalExit`, `killTerminal`), + `context.createElicitation(request)` → `context.client().createElicitation(request)`, + `context.completeElicitation(notification)` → `context.client().completeElicitation(notification)`. + A test double implementing `PromptContext` or `SyncPromptContext` drops those ten methods and + implements `client()`. + +- **Breaking: `AcpAgent.AsyncAgentBuilder` and `AcpAgent.SyncAgentBuilder` are `final`.** Their + constructors were already package-private, so no code outside the SDK could extend them; the + modifier makes that explicit and lets the builders grow overloads safely. Migration: none; build + agents with `AcpAgent.async(transport)` and `AcpAgent.sync(transport)`, and wrap a builder + rather than subclass it. + +- **Breaking: one vocabulary for session updates.** The client builders' setters are handlers, + like every other setter, and the prompt context sends session updates with the same verb as + the agent and the test kit (`AcpAsyncAgent.sendSessionUpdate`, `AcpSyncAgent.sendSessionUpdate`, + `MockAcpAgent.sendSessionUpdate`). Renamed outright, without deprecated aliases: + `AcpClient.AsyncSpec`/`SyncSpec.sessionUpdateConsumer(..)` → `sessionUpdateHandler(..)`, + `defaultSessionUpdateConsumer(..)` (snapshots only) → `defaultSessionUpdateHandler(..)`, and + `PromptContext.sendUpdate(update)` / `SyncPromptContext.sendUpdate(update)` → + `sendSessionUpdate(update)`. Behaviour and parameter types are unchanged, and so is the + registration error a raw `notificationHandler("session/update", ..)` gets, which now names + `sessionUpdateHandler`. Migration: rename the calls — + `.sessionUpdateConsumer(n -> ...)` → `.sessionUpdateHandler(n -> ...)`, + `.defaultSessionUpdateConsumer(n -> ...)` → `.defaultSessionUpdateHandler(n -> ...)`, + `context.sendUpdate(update)` → `context.sendSessionUpdate(update)`. + +- **`SessionCapabilities`' six- and four-argument constructors are `@UnstableAcpApi`.** Both take + the unstable `fork` component (the four-argument one exists only to set it) but were not marked, + so stable code could reach an unstable capability without a marker. `UnstableApiMarkerTest` + now also checks constructors that set an unstable component. Migration: none; code that must + avoid unstable API sets `additionalDirectories` through the canonical constructor or a builder + answer. + +- **Breaking: the client sends `additionalDirectories` only to an agent that advertises them, and + an annotated agent can advertise them.** ACP lets a client send additional workspace + directories only when the agent advertises `sessionCapabilities.additionalDirectories`, but + `newSession`, `loadSession`, `resumeSession` and `forkSession` sent them to any agent. A call + whose request names a non-empty `additionalDirectories` now fails with + `AcpCapabilityException` (capability `sessionCapabilities.additionalDirectories`) without being + sent, as other capability-gated calls do; no list or an empty one needs nothing. On the agent + side, `@AcpAgent(additionalDirectories = true)` advertises the capability; it needs a + `@NewSession` method, since the default `session/new` answer would drop the directories, which + ACP forbids. A builder agent advertises it from its `initializeHandler`. Migration: a client + check `getAgentCapabilities().supportsAdditionalDirectories()` before naming directories; an + annotated agent that uses them add `additionalDirectories = true` to `@AcpAgent` (an + `@Initialize` method that advertised them keeps working). + +- **`AcpAgentSupport.Builder.cancelGracePeriod(..)` and `maxPromptDuration(..)` reject a negative + or null value where it is set.** They stored any value: `build()` failed later, and + `buildFactory()` built a factory whose every connection then failed to build its agent. Both + now throw `IllegalArgumentException` at once. Migration: none for valid values. + +- **Breaking: `AcpInterceptor.afterCompletion` receives the exception the call failed with.** It + took only the context, and the runtime dropped the exception it had, so cleanup that must know + how a call ended (a span status, a failure metric) had to stash the exception from `onError` in + an attribute. The method is now `afterCompletion(AcpInvocationContext context, Throwable ex)`: + `ex` is what the call failed with (what a step threw, or what `onError` threw in its place), and + `null` when it produced a result, when `onError` returned a replacement, and when a `preInvoke` + returned `false`. Migration: add the `Throwable` parameter to an `afterCompletion` override. An + override without `@Override` keeps compiling but is no longer called, so add `@Override`. + +- **The default `session/new` answer passes through the interceptors.** An annotated agent without + a `@NewSession` method answers `session/new` with a random session id; that answer skipped the + interceptors, while the `initialize` answer derived from the annotations went through them, so a + logging or access-check interceptor missed session creation. It now passes through the chain + like any handler method: `preInvoke` can veto it or reject it with an `AcpProtocolException`. + Migration: an interceptor that should not act on `session/new` checks + `context.getAcpMethod()`. + +- **Breaking: an `@ExtNotification` method must return `void`.** A notification gets no answer, + and a value the method returned was dropped without a word, while a `void` `@ExtRequest` was + already rejected. Building the agent now rejects a non-`void` `@ExtNotification` method with an + `IllegalStateException` naming the method. Migration: return `void`, or make it an + `@ExtRequest` if the client expects an answer. + +- **An extension notification without a handler is logged at DEBUG, not WARN.** ACP asks + implementations to ignore notifications they do not recognize, and a peer can send many + (`_auth/status_update` from some agents), each of which logged a warning on both sides. A + `_`-prefixed notification without a handler is now logged at DEBUG; a protocol notification + without a handler still logs a warning. Migration: none; raise the session logger to DEBUG to + see them. + +- **Breaking: `AcpError` extends `AcpException`.** It extended `RuntimeException` directly, so + `catch (AcpException e)`, which the SDK documented as the one place to handle its failures, + missed every error the peer answered with. `AcpException` is now the base of every failure of a + call: `AcpError`, `AcpProtocolException`, `AcpCapabilityException`, `AcpConnectionException` + and `AcpTimeoutException`. `AcpError` keeps its package, constructor, methods and messages. + Migration: a multi-catch `catch (AcpException | AcpError e)` no longer compiles; catch + `AcpException` alone. A `catch (AcpException e)` placed before a `catch (AcpError e)` now takes + the peer's errors too; put the `AcpError` clause first. + +- **Breaking: a lost connection fails a request with `AcpConnectionException`, on both sides.** A + request still waiting when the transport ended failed with a plain `RuntimeException` ("ACP + session with agent terminated"), and a request or notification sent after that with an + `IllegalStateException` ("ACP client transport is not connected: ..." or "ACP agent transport is + not started: ..."), so `catch (AcpConnectionException e)` missed the commonest connection failure, + a peer that went away. Both are now `AcpConnectionException`, with the same messages and the + transport's failure as the cause; requests waiting when a session is closed fail the same way. + Building a client or agent on a transport that refuses at once (one already in use) still throws + `IllegalStateException`. Migration: replace `catch (IllegalStateException e)` or + `catch (RuntimeException e)` around calls that meant "the connection is gone" with + `catch (AcpConnectionException e)`. + +- **Breaking: a peer's error that escapes a handler is passed on unchanged.** A handler that called + the other side and let the resulting `AcpError` escape answered its own request `-32603`, losing + the peer's code (a `-32601` or `-32002` became an internal error). The `AcpError`'s JSON-RPC + error, code, message and data, is now the answer, so a handler with nothing to add can let the + failure propagate; `getError().toException()` is no longer needed for that. Migration: a handler + that relied on such a failure being reported as `-32603` catches the `AcpError` and throws + `AcpProtocolException` with the code it wants. + +- **Breaking: an interceptor's `onError` can answer with an error of its own, and reaches only the + interceptors that saw the call.** What `onError` threw used to be logged and ignored, so the + client got the original failure, and the migration note for the removed `@AcpExceptionHandler` + (handle the exception in `onError` and throw `AcpProtocolException`) did not work. Now an + `AcpProtocolException` thrown from `onError` is the answer, with its code, message and data; + any other exception thrown there is a fault in the interceptor and is answered `-32603`. Either + ends the walk, and the original failure is attached as suppressed. `onError` was also called on + every interceptor, including those whose `preInvoke` never ran or threw; it now reaches exactly + the interceptors that get `afterCompletion`, those whose `preInvoke` returned `true`. + Migration: an `onError` that throws expecting to be ignored should log and return `null` + instead; one that relied on seeing failures from a `preInvoke` that did not pass should move + that handling into its `preInvoke`. + +- **A supported Jackson floor, checked at startup: Jackson 2.18.1 or later for + `acp-json-jackson2`, Jackson 3.0.0 or later for `acp-json-jackson3`.** Frameworks manage their + own Jackson version, and the SDK stated none: an older Jackson (Vert.x 4.5's jackson-core 2.16.1 + under jackson-databind 2.22.3, for example) failed much later with a `NoSuchMethodError` while + reading a message. Each mapper supplier (`JacksonAcpJsonMapperSupplier`, + `Jackson3AcpJsonMapperSupplier`) and mapper constructor now checks the jackson-core and + jackson-databind on the classpath and fails with an `IllegalStateException` naming the version + found and the version required. The floors are the oldest releases the SDK's tests pass with + (2.18.0 drops unknown values of the open enumerations, and 2.17 the unknown fields the SDK keeps + for forward compatibility). Quarkus 3.40 (Jackson 2.21.7) and Spring Boot 4.1 (Jackson 3.1.7) are + within them. Migration: none for a + consistent Jackson at or above the floor; otherwise align the Jackson artifacts on one version, + for example by importing the `jackson-bom`. + +- **Breaking: `StdioAcpClientTransport.awaitForExit()` is renamed `awaitProcessExit()`**, so it does + not sit beside `awaitTermination()` (which completes when the transport ends, not the process). + Interrupted, it now throws `CancellationException` and keeps the thread's interrupt flag; it used + to throw a plain `RuntimeException` and clear the flag. Migration: rename the call; catch + `CancellationException` instead of `RuntimeException` around it. + +- **Breaking: sync lifecycle names line up.** `AcpSyncAgent.await()` is renamed + `awaitTermination()`, matching `AcpAsyncAgent` and the transports. `AcpSyncAgent` and + `AcpAgentSupport` implement `AutoCloseable`, and their `close()` now means what it means on + `AcpSyncClient`: close gracefully, waiting at most 10 seconds, then close at once if that failed + or took longer (`AcpSyncAgent.close()` used to close at once; `AcpAgentSupport.close()` used to + wait up to the 5-minute block timeout and never force). New `AcpSyncClient.async()` returns the + `AcpAsyncClient` it blocks on. Migration: replace `agent.await()` with + `agent.awaitTermination()`; for the old immediate close, call `agent.async().close()`; use + try-with-resources on the sync agent and the annotated agent. + +- **Breaking: `PromptContext.sendSessionUpdate(update)` and + `SyncPromptContext.sendSessionUpdate(update)` replace the two-argument + `sendUpdate(sessionId, update)`.** The context belongs to one prompt's session, + so the session ID was redundant (and a wrong one sent the update to another session). Migration: + drop the first argument and use the new name, `context.sendUpdate(sessionId, update)` becomes + `context.sendSessionUpdate(update)`; to update another session, call + `AcpAsyncAgent.sendSessionUpdate(sessionId, update)` (or `AcpSyncAgent.sendSessionUpdate`). + Test doubles implementing either context implement the one-argument method. + +- **Breaking: `handlerExecutor(ExecutorService)` on `AcpAgent.SyncAgentBuilder` and + `AcpClient.SyncSpec` (and `AcpAgentSupport.Builder` for annotated agents); the static handler + pools and the agent's default timeout are no longer public constants.** Sync handlers (and a sync client's session update consumers) ran on static, + unbounded, JVM-wide cached pools exposed as `AcpAgent.SYNC_HANDLER_SCHEDULER` and + `AcpClient.SYNC_HANDLER_SCHEDULER` (Reactor `Scheduler`s on the entry-point interfaces). Pass an + executor of your own, for example `Executors.newVirtualThreadPerTaskExecutor()`, a Quarkus worker + pool or Micronaut's `TaskExecutors.BLOCKING` executor; the SDK cancels a handler by cancelling + its task (interrupting the thread) and never shuts the executor down. Without one, handlers keep + running on the same daemon pools as before. `AcpAgent.DEFAULT_REQUEST_TIMEOUT` is removed too; + the default (60 seconds) is stated on `requestTimeout`. Migration: drop references to + `AcpAgent.SYNC_HANDLER_SCHEDULER`/`AcpClient.SYNC_HANDLER_SCHEDULER` (use your own scheduler, or + `handlerExecutor(...)` to choose where handlers run) and replace `AcpAgent.DEFAULT_REQUEST_TIMEOUT` + with `Duration.ofSeconds(60)`. + +- **Breaking: the agent builders reject a null handler and a second handler for the same + method.** The typed setters of `AcpAgent.AsyncAgentBuilder` and `AcpAgent.SyncAgentBuilder` + accepted `null` (every request for that method was then answered `-32603`), and registering a + method again silently replaced its handler. A null handler now fails with + `IllegalArgumentException` at the setter, and a second registration of the same ACP method (or + extension method) fails with `IllegalStateException` naming the setter, for example "A handler for + session/prompt is already registered; promptHandler was called twice on this builder". Migration: + register each handler once; to choose between handlers, decide before calling the setter. + +- **`askPermission` and `askChoice` ask about a tool call the client knows; new + `askPermission(String action, ToolKind kind)`** on `PromptContext` and `SyncPromptContext`. The + permission request named a tool call ID that was never announced (clients that look it up in their + tool-call list found nothing), and `askPermission` labelled every action `edit`. Both helpers now + announce the tool call first with a `tool_call` session update (status `pending`), ask about it, + and settle it with a `tool_call_update` (`completed` once the user answered, `failed` if the client + cancelled the request). `askPermission(action)` uses kind `other`; pass a kind with the new + overload, for example `askPermission("Run the tests", ToolKind.EXECUTE)`. Breaking (behaviour): + the session update consumers now see two more updates per call, and a client that showed these + as edits shows them as `other`. Migration: none needed; to keep the old kind, call + `askPermission(action, ToolKind.EDIT)`. Implementations of `PromptContext`/`SyncPromptContext` + (test doubles) add the new overload. + +- **Breaking: the sync API throws `AcpTimeoutException` for a timeout, and `CancellationException` + when interrupted, instead of Reactor's internal `Exceptions$ReactiveException`.** Every blocking + method of `AcpSyncClient`, `AcpSyncAgent` and `SyncPromptContext` let a request timeout escape as + `reactor.core.Exceptions$ReactiveException` (cause `TimeoutException`), and `AcpSyncAgent`'s block + timeout as `IllegalStateException("Timeout on blocking read ...")`. Both now throw the new + `com.agentclientprotocol.sdk.error.AcpTimeoutException` (an `AcpException`), whose cause is the + `TimeoutException`. A blocked call whose thread is interrupted (the SDK interrupts a sync handler + to cancel it) throws `java.util.concurrent.CancellationException` and leaves the interrupt flag + set; any other checked cause is thrown as an `AcpException`. The asynchronous API is unchanged + (its `Mono` fails with the `TimeoutException`). Migration: replace + `catch (RuntimeException e) { if (e.getCause() instanceof TimeoutException) ... }` (or + `Exceptions.unwrap(e)`) with `catch (AcpTimeoutException e)`, and catch `CancellationException` + where you handled an interrupt. + +- **Breaking (behaviour): a client's prompt is no longer bounded by its request timeout; new + `promptTimeout(Duration)` on `AcpClient.AsyncSpec` and `AcpClient.SyncSpec`, default none.** A + prompt's answer comes only at the end of its turn, so the 30-second request timeout cancelled + every turn that ran longer (the client failed the call with a `TimeoutException` and sent the + agent `$/cancel_request`, which ended the turn). `session/prompt` now waits for the end of the turn + however long it takes; every other request keeps the request timeout. Disposing the prompt's + `Mono` (or interrupting a blocked `AcpSyncClient.prompt`) still cancels it. Migration: to keep a + bound on turns, set `.promptTimeout(Duration.ofMinutes(10))` (any positive duration; it fails the + call with a `TimeoutException` and sends `$/cancel_request`, as before); code that raised + `requestTimeout` only to let long turns finish can drop it. In Spring Boot, set + `spring.acp.client.prompt-timeout`, in Micronaut `acp.client.prompt-timeout`, in Quarkus + `quarkus.acp.client.prompt-timeout` (unset by default). + +- **Breaking: an annotated agent with `@SetSessionMode` or `@SetSessionConfigOption` needs a + `@NewSession` method.** The default `session/new` answers with a random session id and no modes + or config options, so a client never saw any to set and never called those methods. Modes and + config options are values the agent chooses per session (their ids, names, current values, + often per client capability), which annotations cannot declare, so the SDK cannot fill them into + the default answer; building such an agent now fails with an `IllegalStateException` naming the + setter method and saying "Add a @NewSession method that returns them". **Migration:** add a + `@NewSession` method returning `new NewSessionResponse(sessionId, modes, configOptions)`. + +- **A builder agent answers `initialize` without an initialize handler, and needs a prompt + handler.** `AcpAgent.sync(..)`/`async(..)` agents with no `initializeHandler` answered + `initialize` with `-32601` (Method not found), so a prompt-only agent could not even connect. + They now answer with the protocol version negotiated with the client and the capabilities their + handlers imply (`loadSessionHandler` → `loadSession`, `listSessionsHandler`/`closeSessionHandler`/ + `resumeSessionHandler`/`deleteSessionHandler`/`forkSessionHandler` → `sessionCapabilities`, + `logoutHandler` → `auth.logout`, `listProvidersHandler` → `providers`), as an annotated agent + does, and with the `agentInfo` set by the new builder method `agentInfo(Implementation)`. `build()` now throws `IllegalStateException` when no `promptHandler` is registered, and an + annotated agent without a `@Prompt` method fails to build the same way: every agent must answer + `session/prompt`. **Migration:** register a prompt handler (test agents that never prompt can + answer `PromptResponse.endTurn()`). + +- **`AcpAgentSupport.Builder` refuses two silent states.** `build()` and `buildFactory()` throw + `IllegalStateException` when no `@AcpAgent` bean was given (they used to build an agent with no + handlers), and `buildFactory()` throws when `transport(..)` was set (the transport used to be + ignored). **Migration:** pass the bean with `create(bean)`; drop `transport(..)` before + `buildFactory()`. + +- **Annotation misuse fails when the agent is registered or built, not at the first request.** + `AcpAgentSupport` now rejects, with a message naming the class, the method and the fix: + - at registration (`create(..)`/`agent(..)`, `IllegalArgumentException`): two methods with the + same handler annotation (one of them used to win silently), one method with two handler + annotations, and a `@SessionId`, `@ConfigId` or `@ConfigValue` parameter on an `@ExtRequest` + or `@ExtNotification` method (it used to receive the extension's params, so every call failed + `-32602`); + - at `build()`/`buildFactory()` (`IllegalStateException`): a parameter no argument resolver + supplies, the request type of another method, `@SessionId` on a method without a session, a + `PromptContext`/`SyncPromptContext` outside `@Prompt`, `@ConfigId`/`@ConfigValue` outside + `@SetSessionConfigOption`, a request handler declared `void` (other than `@Prompt`), a return + type no return value handler accepts, a return type that cannot give the method's response + (including a `Mono` of another type), and a `void` `@ExtRequest`. + Each of these used to fail every call with `-32603` or `-32602`. Parameters and return types a + custom `ArgumentResolver` or `ReturnValueHandler` supports are not checked. Private and static + handler methods remain supported. + **Migration:** a build that now fails names the method to fix. + +- **`AcpError.getMessage()` no longer ends with `[code=N]`.** It is the peer's message, followed + by the detail its data carries, so `e.getCode() + " " + e.getMessage()` names the code once. + `toString()` (what stack traces show) still includes `[code=N]`. Code that parsed the code out of + the message should call `getCode()`. + +- **`StreamableHttpAcpAgentTransportOptions` gains `shutdownTimeout`** (builder + `shutdownTimeout(Duration)`, default 5 seconds, positive): how long closing the servlet or the + listener waits for its connections' agents before closing the rest at once. Breaking for code + calling the record's canonical constructor, which takes it as a new last component; the builder + is unaffected. + +- **0.80.0 is binary-incompatible with 0.18.0: recompile code built against 0.18.0.** Source + migrates as the Breaking entries below describe, but a jar compiled against 0.18.0 fails at run + time, typically with `NoSuchMethodError` or `NoClassDefFoundError` from a handler, because several + canonical constructors and types changed shape. The main ones: + - `NewSessionResponse`, `LoadSessionResponse`, `ResumeSessionResponse`: `models` is gone and + `configOptions` comes before `_meta`, so `new NewSessionResponse(id, modes, null)` compiled + against 0.18.0 calls a constructor that no longer exists. + - `ToolCall`, `ToolCallUpdate`, `ToolCallUpdateNotification` (`name` after `title`); + `ClientCapabilities` (`session`, `auth`); `AgentCapabilities` (`auth`); `StringPropertySchema`, + `MultiSelectPropertySchema` (canonical constructors only); `ElicitationCapabilities` (no-arg + constructor removed); union records (discriminator component checked). + - Types replaced or moved: `AuthMethod` (an interface; `AuthMethodAgent`), `ElicitationAction` + (a record, not an enum), `AcpError` (one `spec.AcpError` instead of the session-nested + classes), `AcpInvocationContext` and `AcpMethodParameter` (`agent.support.invocation`); the + `session/set_model` API and the `acp-websocket-jetty` module are removed; `AcpProtocolException` + no longer converts to or from `JSONRPCError`; `SyncPromptContext.askChoice` returns `Optional`. + + Since this release such an `Error` in a handler is answered `-32603` instead of leaving the peer + waiting (see Fixed), but the handler still fails until it is recompiled. +- **Breaking: the short constructors of the session updates no longer take the discriminator.** + It can only be the variant's own name (or `null`), so writing it was noise: + `new ConfigOptionUpdate(null, options)` is now `new ConfigOptionUpdate(options)`. The new + constructors replace the old ones: + - `UserMessageChunk`, `AgentMessageChunk`, `AgentThoughtChunk`: `(content)` and + `(content, messageId)`. + - `Plan(entries)`, `AvailableCommandsUpdate(availableCommands)`, `CurrentModeUpdate(currentModeId)`, + `UsageUpdate(used, size)`, `ConfigOptionUpdate(configOptions)` (the full list of options, not + only the changed ones). + + The canonical constructors (discriminator first, `null` or the variant's name) are unchanged; + `SessionInfoUpdate(title, updatedAt)` already had this shape. Migration: drop the first + argument, for example `new AgentMessageChunk("agent_message_chunk", content)` becomes + `new AgentMessageChunk(content)`. +- **Breaking: a client's capabilities and info are set only on its builder; + `initialize(InitializeRequest)` is removed** from `AcpAsyncClient` and `AcpSyncClient`. The + builder's `clientCapabilities(...)` was used only by the no-argument `initialize()`, and the + request-taking overload silently replaced it, so a client that set capabilities on the builder + and then called `initialize(new InitializeRequest(1, new ClientCapabilities()))` advertised + nothing, while its handlers enforced what the builder said. Now: + - `AcpClient.async(...)` / `AcpClient.sync(...)`: `clientCapabilities(ClientCapabilities)` (as + before) and the new `clientInfo(Implementation)`. + - `initialize()` sends protocol version 1 with the builder's capabilities and client info. + - `initialize(int protocolVersion, Map meta)` (new) sends a chosen protocol + version and `_meta` with the same builder values; for `_meta` and version-negotiation tests, + not for capabilities. + + Migration: move the request's capabilities to `.clientCapabilities(...)` and its `clientInfo` + to `.clientInfo(...)`, then call `initialize()`; pass `_meta` through `initialize(1, meta)`. A + client that passed `new InitializeRequest(1, null)` now advertises the default + `new ClientCapabilities()` (no file system, no terminal) instead of omitting + `clientCapabilities`. +- **Quieter logs for errors the caller already receives, and a named logger for agent stderr.** + - A peer's JSON-RPC error response (for example a handled `-32602`) was logged at ERROR + ("Error handling request") and then failed the caller's request with the same error. The + caller's `AcpError` is the report; the session now logs it at DEBUG only, as + `Request failed with peer error `, without the error's message or data (the + peer's payload). Applications that relied on the ERROR line should log the `AcpError` they + receive. + - `StdioAcpClientTransport` logs each line the agent writes to its standard error at INFO on its + own logger, `com.agentclientprotocol.sdk.client.transport.agent-stderr` + (`StdioAcpClientTransport.AGENT_STDERR_LOGGER`), as `agent: `. Before, it used the + transport's logger with the prefix `STDERR Message received: `. Set that logger to WARN to + hide the agent's output, or replace it with `setStdErrorHandler(...)`. +- **Breaking: error codes follow the ACP v1 schema (`$defs.ErrorCode`).** A prompt sent while the + session already has an active prompt was rejected with `-32000`, which ACP defines as + "Authentication required", so a client could ask its user to log in when the user had only sent a + second prompt. It is now rejected with `-32600` (invalid request), message + `There is already an active prompt on session ` and data `{"sessionId": ""}`, as the Rust + SDK rejects requests that are invalid in the connection's state; the Kotlin SDK answers + `-32603`. `AcpErrorCodes` keeps only codes the schema defines: + - `AUTHENTICATION_REQUIRED` is `-32000` (it was `-32004`); `AcpProtocolException.isAuthenticationRequired()` + is new. + - `SESSION_NOT_FOUND` (`-32002`) is renamed `RESOURCE_NOT_FOUND`, the schema's name for the code. + - `CONCURRENT_PROMPT` (`-32000`) and `AcpProtocolException.isConcurrentPrompt()` are removed; a + concurrent prompt is an `INVALID_REQUEST`. + - `CAPABILITY_NOT_SUPPORTED` (`-32001`), `NOT_INITIALIZED` (`-32003`) and `PERMISSION_DENIED` + (`-32005`) are removed: the SDK made them up inside the range ACP reserves for itself. + `AcpCapabilityException.toProtocolException()`, which answered `-32001`, is removed (see + Removed). +- **Elicitation is stable (the ACP schema promoted it on 2026-07-24, in protocol v1.7.0), and its + API is no longer `@UnstableAcpApi`:** `CreateElicitationRequest`, `CreateElicitationResponse`, + `ElicitationAction`, `CompleteElicitationNotification`, `ElicitationSchema`, the property schemas, + the multi-select items, `EnumOption`, `ElicitationCapabilities` and + `ClientCapabilities.elicitation`. +- **Breaking: the elicitation capability's modes are typed, and the no-argument + `ElicitationCapabilities()` constructor is removed.** `form` and `url` were `Object`; they are now + `ElicitationFormCapabilities` and `ElicitationUrlCapabilities` (each with `_meta`), as in the + schema. The removed constructor advertised form mode, although the spec says an empty capability + (`{}`) advertises no mode. Migration: `new ElicitationCapabilities()` becomes + `ElicitationCapabilities.formOnly()`; `urlOnly()` and `formAndUrl()` advertise the other modes, and + code that read `form()` or `url()` as `Object` gets the typed records. +- **Breaking: `ElicitationAction` is an open value, not an enum,** by the rule for the schema's + closed string unions (see the `AcpSchema` Javadoc): a record over the wire string with the + constants `ACCEPT`, `DECLINE` and `CANCEL`, `of(String)`, `known()` and `isKnown()`. An action + this SDK does not know (a newer peer) is kept and written back instead of failing the response. + Migration: compare with `equals`, not `==`, and replace a `switch` over the enum with `if`/`else` + or a switch over `action().value()`. +- **Elicitation form properties of an unknown type and multi-select items of an unknown shape no + longer fail the request.** A property of an unknown type reads as + `UnknownElicitationPropertySchema`, keeping `type` and every other field, and is written back + unchanged, as for the other discriminated unions. Multi-select items have no shared + discriminator, so the SDK tells their shapes apart by their members: items with a `type` or an + `enum` member read as `UntitledMultiSelectItems` and items with an `anyOf` member as + `TitledMultiSelectItems`, whatever the `type`, and lose the members that record does not have. + Only items with none of these members read as `UnknownMultiSelectItems`, which keeps every + field and writes it back unchanged. The five property schemas now write their own `type` discriminator: + their canonical constructors accept `null` for it (it becomes the record's type) and reject a + different type, which they used to accept and ignore. +- **Breaking: `StringPropertySchema` and `MultiSelectPropertySchema` have only their canonical + constructors,** which end with `_meta`. Migration: pass `null` as the last argument, or use + `StringPropertySchema.text(...)` and `singleSelect(...)`. +- **Behaviour change: the agent checks the requested elicitation mode, not only elicitation.** + `createElicitation` fails with `AcpCapabilityException` (`elicitation.form` or `elicitation.url`) + when the client did not advertise the request's mode; before, any `elicitation` object, even `{}`, + let both modes through. The spec says agents must not request a mode the client did not advertise. + A mode the SDK does not know still needs only `elicitation`. +- **Behaviour change: a client with a typed `createElicitationHandler` answers a request for a mode + it did not advertise at initialization with `-32602` (invalid params)**, as the spec says, without + calling the handler. Before initialization, and for a mode the SDK does not know, the handler is + called. Migration: advertise the modes the handler supports in `clientCapabilities.elicitation`. + A raw `requestHandler("elicitation/create", ...)` is not checked. + +- **Unknown union variants no longer fail the message** (ACP forward compatibility). A newer agent's + `sessionUpdate` type used to fail the whole `session/update` notification, which the client then + dropped with an error log; an unknown content block, tool call content type, config option type + or permission outcome failed its message the same way. Each union now reads an unknown variant as + its `Unknown*` record (`UnknownSessionUpdate`, `UnknownContentBlock`, `UnknownToolCallContent`, + `UnknownSessionConfigOption`, `UnknownPermissionOutcome`), which keeps the discriminator and + every other field and writes them back unchanged. Session update consumers receive it like any + other update; code that does not know the variant ignores it. The Rust, TypeScript and Python + SDKs drop such a notification; the Kotlin SDK and the v2 schema keep the raw payload, which this + SDK follows. The rule is documented on `AcpSchema`. Unions with a default variant keep it + (`McpServer` reads an unknown `type` as stdio). +- **Breaking: a select config option's `options` is flat or grouped.** The schema's + `SessionConfigSelectOptions` is a list of options or a list of groups + (`SessionConfigSelectGroup`: `group`, `name`, `options`, `_meta`); a grouped list failed to read. + `SessionConfigSelect.options()` now returns `SessionConfigSelectOptions`, either + `UngroupedSelectOptions` or `GroupedSelectOptions`, both written as the JSON array they read from; + `allOptions()` lists every option across groups, and a list mixing options and groups is rejected. + Migration: `select.options()` used as a list becomes `select.options().allOptions()` (or match on + the two records); the canonical constructor takes `SessionConfigSelectOptions.ungrouped(list)` or + `grouped(groups)`; the `(id, name, currentValue, List)` constructor is + unchanged. +- **Breaking: unknown enum values no longer fail the message; most schema enums are open value + types.** A newer peer's stop reason, tool call status, permission option kind, plan entry status + or priority, or annotation role failed deserialization of the whole message (a prompt's + response included). `StopReason`, `ToolCallStatus`, `PermissionOptionKind`, `PlanEntryStatus`, + `PlanEntryPriority` and `Role` are now records over the wire string: the constants keep their + names (`StopReason.END_TURN`), `of(String)` returns the constant for a known value, an unknown + value is kept and written back (`isKnown()` false), `known()` lists the defined values and + `toString()` is the wire value. `ToolKind` stays an enum and reads an unknown kind as `OTHER`, + the schema's catch-all (as in the Rust SDK); it gains `value()` and `of(String)`. The rule is + documented on `AcpSchema`. Migration: compare with `equals` instead of `==` (a value built with + `new` is not the constant); replace `switch` over these types with `if`/`equals` or a switch on + `value()`; `values()` becomes `known()`, `name()` becomes `value()` (the wire name, e.g. + `end_turn`, not `END_TURN`). +- **Breaking: union records write their own discriminator, and reject a wrong one.** Every union + above is now declared `include = EXISTING_PROPERTY`, so the `sessionUpdate`, `type` or `outcome` + component of a record such as `AgentMessageChunk` or `TextContent` is written as is. Before, + Jackson replaced whatever the caller passed with the registered name. A canonical constructor + now turns `null` into the record's own name and throws `IllegalArgumentException` for any other + value (`new AgentMessageChunk("agentMessage", ...)` used to be silently corrected). Migration: + pass the variant's wire name (`"agent_message_chunk"`) or `null`, or use the convenience + constructors, which already do. Code that switches exhaustively over a union's known records + needs a branch for its `Unknown*` record. +- **Breaking: the canonical constructors of `NewSessionResponse`, `LoadSessionResponse` and + `ResumeSessionResponse` take `configOptions` before `_meta`.** `new NewSessionResponse(id, modes, + null)` and `new LoadSessionResponse(modes, null)` still compile (the `null` is now the config + options); a call that passed a `_meta` map in that position no longer compiles. Migration: pass + `(id, modes, configOptions, meta)` or `(modes, configOptions, meta)`. +- **Breaking: `ToolCall`, `ToolCallUpdate` and `ToolCallUpdateNotification` take `name` after + `title`.** Migration: insert the tool's name, or `null`, after the title argument of the + canonical constructor. +- **Breaking: `AuthMethod` is an interface; the agent method is `AuthMethodAgent`.** The schema + makes `AuthMethod` a union (no `type`: agent; `"terminal"`: terminal). A method of any other + type reads as `AuthMethodAgent`, as in the Rust SDK. Migration: `new AuthMethod(id, name, + description)` becomes `new AcpSchema.AuthMethodAgent(id, name, description)`; `id()`, `name()` + and `description()` stay on the interface. `AuthMethodAgent` also carries `_meta`. +- **Breaking: `ClientCapabilities` takes `session` and `auth` after `terminal`** in its canonical + constructor, in schema order. Migration: `new ClientCapabilities(fs, terminal, elicitation, + meta)` becomes `new ClientCapabilities(fs, terminal, session, auth, elicitation, meta)` (both + may be `null`). +- **Breaking: `AgentCapabilities` takes `auth` after `promptCapabilities`** in its canonical + constructor. Migration: `new AgentCapabilities(loadSession, session, mcp, prompt, providers, + meta)` becomes `new AgentCapabilities(loadSession, session, mcp, prompt, auth, providers, meta)` + (`auth` may be `null`); the shorter constructors are unchanged. +- **Behaviour change: a request whose caller gives up is cancelled at the peer.** Disposing a + request `Mono`, or its timeout firing (the client's default request timeout is 30 seconds, the + agent's 60), now sends `$/cancel_request`, so a peer that supports it stops the work and answers + `-32800`. A `session/prompt` that times out on the client is therefore cancelled at an agent that + supports `$/cancel_request` (the Java agent answers it at once and ends the turn), where before + the agent kept running the turn. Migration: raise `requestTimeout` for long prompts, or send + `session/cancel` for the graceful prompt cancel with stop reason `cancelled`. +- **Fix: a resubscribed request is a new request.** Subscribing twice to one `sendRequest` `Mono` + (a `retry()`, say) reused the request id, so the first response completed both. Each + subscription now gets its own id. +- **Behaviour change: `session/cancel` no longer ends the prompt turn; the cancelled prompt's + response does.** The agent session used to free the session for a new prompt as soon as the + cancel notification arrived, so a client could start a second prompt while the cancelled one's + handler was still running. ACP v1 (prompt turn, Cancellation) says the agent may still send + `session/update`s after the cancel and must then answer the original `session/prompt` with stop + reason `cancelled`, and "once a prompt turn completes, the Client may send another + `session/prompt`". A prompt sent between the cancel and that answer is now rejected with + `-32600` (invalid request; see below), like any prompt during an active turn. The turn ends when the + answer is published (before it reaches the client, as for every prompt), when the handler fails + (a timeout the handler applies included), when the request is cancelled, when the session + closes, or when the cancel grace period passes (below). Migration: clients send the next prompt + after the cancelled one has answered (the SDK client sends nothing on its own after `cancel`); + agent prompt handlers must answer a cancelled prompt. +- **New default: a cancelled prompt is answered within 60 seconds.** If the prompt handler has not + answered 60 seconds after `session/cancel`, the agent cancels the handler's subscription and + answers the prompt with stop reason `cancelled` itself (the answer ACP v1 requires of a + cancelled prompt), which ends the turn. Before, a handler that never answered a cancelled prompt + kept its session busy for good. `cancelGracePeriod(Duration.ZERO)` restores that. The public + four-argument `AcpAgentSession` constructor uses the same default. +- **Breaking (unstable providers API): the provider identifier is `providerId`, on the wire and in + Java.** `ProviderInfo`, `SetProviderRequest` and `DisableProviderRequest` wrote and read `"id"`, + but the unstable schema names the property `providerId`, so no spec-conforming peer could exchange + them with the SDK. The record component is renamed with it. Migration: `id()` becomes + `providerId()`; the constructors keep their parameter order. +- **Breaking: `AcpProtocolException` no longer converts to or from `JSONRPCError`.** Use + `AcpSchema.JSONRPCError.from(exception)` instead of `exception.toJsonRpcError()`, and + `error.toException()` instead of `new AcpProtocolException(error)`. Dependencies between the `spec` and + `error` packages now run one way. +- **Every package is null-marked (JSpecify `@NullMarked`), and the nullness is now part of the API.** + Types are non-null unless annotated `@Nullable`, and the annotations say where absence is legal: + `AcpSchema` record components follow the ACP schema (optional and nullable fields are `@Nullable`, + 217 of them; required ones are not), as do the JSON-RPC envelope (`id`, `params`, `result`, `error`, + `data`), `AcpJsonMapper.readValue` (the JSON `null` literal), `getClientCapabilities()` and + `getAgentCapabilities()` (before initialization), `AcpProtocolException.getData()`, and the + annotation-driven support types (resolved arguments, handler return values, interceptor results). + Kotlin and other nullness-aware callers see these types; Java callers compile unchanged. +- **Breaking: `SyncPromptContext.askChoice` returns `Optional`**, empty when the client cancels + the choice (it was documented to return null, and failed instead, below). `PromptContext.askChoice` + completes empty on cancellation. Migration: `askChoice(...).orElse(...)`, or test `isPresent()`. +- **Breaking: `SyncPromptContext` has an abstract `async()` method**, returning the `PromptContext` it + blocks on (the same session, turn and client). Migration: an implementation of `SyncPromptContext`, + such as a test double, implements `async()`. +- **Breaking: `CommandResult` carries a nullable exit code and the terminating signal, and no + `timedOut` flag.** Its components are `(String output, @Nullable Integer exitCode, + @Nullable String signal)`: a command killed by a signal has no exit code. `timedOut` is removed, + with the `(output, int exitCode, boolean timedOut)` constructor: `execute` has no timeout (neither + `Command` nor `terminal/wait_for_exit` has one), so it never set the flag, and a result could not + report a timeout truthfully. The `(output, int exitCode)` constructor remains. Migration: + `exitCode()` returns `Integer`; use `success()`, or check `exitCode()` for null before comparing + it. To bound a command, use the terminal methods (`createTerminal`, `killTerminal`) directly. +- **Breaking: `Command.env()` is never null**; it is empty when no variables are set (`Command.of` + now builds it with `Map.of()`, and the canonical constructor takes a non-null map). Migration: test + `env().isEmpty()` instead of `env() == null`. +- `AcpException.getMessage()` is declared non-null: every constructor sets a message. +- `StdioAcpClientTransport.awaitForExit()` before `connect` throws `IllegalStateException` instead of + `NullPointerException`. +- `AcpInvocationContext.Builder.build()` requires `acpMethod` and `request`. +- **Breaking: `AcpInvocationContext` and `AcpMethodParameter` moved to + `com.agentclientprotocol.sdk.agent.support.invocation`.** The resolver, handler and interceptor + packages used them from `agent.support`, which in turn used those packages, so the four packages + formed a cycle. They now depend on the `invocation` package and `AcpAgentSupport` depends on them, + one way. Migration: change the imports in custom `ArgumentResolver`, `ReturnValueHandler` and + `AcpInterceptor` implementations to `com.agentclientprotocol.sdk.agent.support.invocation.*`. +- **Breaking: the error a request fails with when the peer answers with a JSON-RPC error is one + type, `com.agentclientprotocol.sdk.spec.AcpError`,** on both sides. It replaces the two identical + nested classes `AcpClientSession.AcpError` and `AcpAgentSession.AcpError`; its constructor, + message, `getError()`, `getCode()` and `getData()` are unchanged. Migration: catch or test for + `AcpError` (import `com.agentclientprotocol.sdk.spec.AcpError`) instead of + `AcpClientSession.AcpError` or `AcpAgentSession.AcpError`. +- `com.agentclientprotocol.sdk.util.OutboundSinks` is new: the emission onto a transport's outbound + sink that retries while another thread emits (#14), and the reply pump from inbound messages + through the session handler to that sink, which the stdio and WebSocket transports shared as + copies. +- **`@UnstableAcpApi` marks all of the unstable `session/fork` and `providers/*` API.** Those methods + are not in the stable schema, and their records, annotations and provider handlers were marked, + but `METHOD_SESSION_FORK` and `METHOD_PROVIDERS_*`, `AcpAgent.ForkSessionHandler` and + `SyncForkSessionHandler`, both builders' `forkSessionHandler`, `AcpAsyncClient` and + `AcpSyncClient` `forkSession`, `listProviders`, `setProvider` and `disableProvider`, + `NegotiatedCapabilities` `supportsForkSession`, `supportsProviders`, `requireForkSession`, + `requireProviders` and the builder's `forkSession` and `providers`, and the four request + resolvers were not. They are now; nothing else changes. Tests derive the unstable methods from + the stable schema copy and fail when a public member serving one lacks the marker, or a stable + method's member carries it. + +- **Breaking: `SessionConfigSelect.Builder.build()` checks the option's value.** It now throws + `IllegalStateException` when the options (across all groups) are empty, or when `currentValue` + is not the value of one of them, instead of building an option no client can display correctly. + The record's constructors stay lenient, since they also read other agents' options. + **Migration:** pass at least one option and set `currentValue` to one of their values. + +- **Breaking (behaviour): one default request timeout, 60 seconds.** The client builders + (`AcpClient.sync/async`) and `AcpAgentSupport.Builder` defaulted to 30 seconds while the agent + builders defaulted to 60; all now wait 60 seconds for an answer unless `requestTimeout` is set. + 60 seconds because the agent's requests (a permission prompt, an elicitation) wait on a person, + and a client's `session/new` may wait for the agent to start its MCP servers. + `AcpAgentSupport.Builder.requestTimeout` now accepts `null`, meaning the SDK default, and an + annotated agent no longer restates the default. The Spring Boot property + `spring.acp.client.request-timeout` and the Micronaut setting `acp.client.request-timeout` + default to 60 s as well; Quarkus's `quarkus.acp.client.request-timeout`, unset, already follows the + SDK. **Migration:** none needed; to keep the old bound, set + `requestTimeout(Duration.ofSeconds(30))` (or the property to `30s`). + +- **Breaking (behaviour): a client handler is registered once.** On `AcpClient.AsyncSpec` and + `SyncSpec`, registering a second handler for a method (a typed setter such as + `readTextFileHandler` called twice, `requestHandler`, `notificationHandler`, + `extRequestHandler`/`extNotificationHandler` for a method that already has one, or a raw handler + and the typed setter for the same method) now throws `IllegalStateException` naming the setter, + instead of silently replacing the first. A null handler still throws `IllegalArgumentException`. + `sessionUpdateHandler` stays additive. **Migration:** register each method once; to compose + behaviour, do it inside one handler. + +- **Breaking: the raw client handlers are for methods the SDK does not model.** + `AcpClient.AsyncSpec`/`SyncSpec.requestHandler(method, ..)` now throws `IllegalArgumentException` + for a method that has a typed setter (`fs/read_text_file`, `fs/write_text_file`, + `session/request_permission`, the five `terminal/*` methods, `elicitation/create`), and + `notificationHandler(method, ..)` for `session/update` and `elicitation/complete`, naming the + setter to use: the raw handler bypassed the typed params and the elicitation mode check, and a + raw `session/update` handler was silently replaced by the session update consumers. + `SyncSpec.notificationHandler` now takes a blocking `Consumer` run on the sync handler + threads, like the sync builder's other handlers, instead of the async `NotificationHandler` + returning a `Mono`. **Migration:** use the typed setter the exception names; on the sync + builder, drop the `Mono` from a raw notification handler (`params -> { ... }`). + +- **Breaking (behaviour): a client's advertised capabilities must have their handlers.** + `AcpClient.AsyncSpec`/`SyncSpec.build()` now throws `IllegalStateException` when the client + capabilities advertise `fs.readTextFile` without `readTextFileHandler`, `fs.writeTextFile` + without `writeTextFileHandler`, `terminal` without all five terminal handlers + (`createTerminalHandler`, `terminalOutputHandler`, `releaseTerminalHandler`, + `waitForTerminalExitHandler`, `killTerminalHandler`), or an elicitation mode without + `createElicitationHandler`; the message names every missing setter. Such a client used to build, + and the agent's requests were answered "Method not found". A handler for a capability the client + does not advertise logs one WARN, since an SDK agent will not call it. The Spring Boot + (`spring.acp.client.capabilities.*`), Micronaut (`acp.client.capabilities.*`) and Quarkus + (`quarkus.acp.client.capabilities.*`) client beans add the setting that advertised the capability + to the message. The test `MockAcpClient` no longer + advertises `terminal`, which it never served. **Migration:** register the handlers (in an + `AcpClientCustomizer` when the capabilities come from properties), or stop advertising the + capability. + +- **Breaking (behaviour): a client initializes first, and calls only what the agent advertised.** + Every `AcpAsyncClient`/`AcpSyncClient` ACP call but `initialize` now fails with + `IllegalStateException` ("Call initialize() first") until the agent has answered `initialize`; + it used to be sent. `loadSession`, `listSessions`, `closeSession`, `deleteSession`, + `resumeSession`, `forkSession`, `logout` and the `providers/*` calls fail with + `AcpCapabilityException` (naming the capability, such as `sessionCapabilities.close`) when the + agent's `initialize` answer did not advertise it, as the agent side already does for the + client's capabilities. Neither sends anything. The check runs when the call is subscribed, so + `client.initialize().then(client.newSession(..))` works. Extension methods (`sendExtRequest`, + `sendExtNotification`) are outside ACP's lifecycle and are not checked. **Migration:** call + `initialize()` first; check `getAgentCapabilities()` before an optional call, or catch + `AcpCapabilityException`. A test agent with its own `initializeHandler` must advertise the + capabilities its handlers serve (or drop the handler: a builder agent's default `initialize` + advertises them). + +### Removed + +- **Breaking: `AcpCapabilityException.toProtocolException()` is removed.** It answered `-32600` + (invalid request) for a missing capability, while the one refusal ACP specifies, a client asked + for an elicitation in a mode it did not declare, is `-32602` (invalid params; ACP's elicitation + RFD, "Error Handling"), which the SDK's client sends. The SDK never called the method, so it + only offered a second code for the same case. Migration: throw + `new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, e.getMessage(), e.getCapability())`. + +- **Breaking: `AcpAgent.logger` and `AcpClient.logger` are removed.** As fields of public + interfaces they were public `org.slf4j.Logger` constants, API by accident. Migration: use a + logger of your own (`LoggerFactory.getLogger(MyAgent.class)`); to see the client builders' + session-update logging, configure the logger named `com.agentclientprotocol.sdk.client.AcpClient` + as before. + +- **Breaking: `PromptResponse.text(String)` is removed, and a `@Prompt` method's String now reaches + the client.** `text(String)` threw its text away and returned the same response as `endTurn()`: a + prompt response carries no content, and a static factory cannot send a session update. So an + annotated `@Prompt` method that returned a `String` (documented as "converted to + `PromptResponse.text()`") ended the turn and sent nothing. That String is now sent to the client + as an `agent_message_chunk` session update of the prompt's session, then the turn ends with + `end_turn`; a null or empty String sends nothing. Migration: replace + `return PromptResponse.text(message);` with `context.sendMessage(message); return + PromptResponse.endTurn();` (`SyncPromptContext` or, in an async handler, `PromptContext`), or, + in a `@Prompt` method, return the String itself. + +- **Breaking: the session-model API (`session/set_model`) is removed.** Deprecated for removal in + 0.14.0; the ACP schema 1.9.1 (stable and unstable) no longer defines the method, its request and + response, or the `models` field on session responses. Removed: `AcpSchema.METHOD_SESSION_SET_MODEL`, + `SetSessionModelRequest`, `SetSessionModelResponse`, `SessionModelState`, `ModelInfo`; the `models` + component of `NewSessionResponse`, `LoadSessionResponse`, `ResumeSessionResponse` and + `ForkSessionResponse` (and the convenience constructors that took it: they now take the session ID + and/or `modes` only); `AcpAgent.SetSessionModelHandler`, `SyncSetSessionModelHandler` and the + builders' `setSessionModelHandler`; `AcpAsyncClient.setSessionModel` and + `AcpSyncClient.setSessionModel`; the `@SetSessionModel` annotation and + `SetSessionModelRequestResolver`; the method's Streamable HTTP routing entries. An agent no longer + answers `session/set_model` (a peer that sends it gets "method not found"). Migration: expose model + selection as a session config option whose `category` is `"model"`, and change it with + `session/set_config_option` (`setSessionConfigOptionHandler`, `@SetSessionConfigOption`, + `AcpAsyncClient.setSessionConfigOption`). +- **Breaking: the `acp-websocket-jetty` module and its `WebSocketAcpAgentTransport` are removed.** + Deprecated for removal in 0.18.0; the module held only that class, a WebSocket agent transport + that served a single client. Migration: depend on `acp-streamable-http-jetty` and serve agents with + `StreamableHttpAcpAgentTransport`, which accepts WebSocket upgrades at `/acp` (and the Streamable + HTTP profile on the same path) and creates one agent per connection through an `AcpAgentFactory`: + replace `AcpAgent.async(new WebSocketAcpAgentTransport(port, mapper))...build()` with + `new StreamableHttpAcpAgentTransport(port, mapper, AcpAgentFactory.async(t -> AcpAgent.async(t)...build()))`. + `WebSocketAcpClientTransport` (in `acp-core`) is unchanged and connects to it as before. +- **Breaking: the `@SessionState` and `@AcpExceptionHandler` annotations are removed.** Both were + declared in `acp-annotations` but nothing implemented them: a `@SessionState` parameter failed + every call with "No resolver for parameter", and an `@AcpExceptionHandler` method was never + called. Migration: keep per-session state in the agent, in a thread-safe map keyed by the + session ID (an `@SessionId String` parameter or the request's `sessionId()`), and remove it in + the `@CloseSession`/`@DeleteSession` handler; handle exceptions in the handler method itself, or + in an `AcpInterceptor`'s `onError`, and throw `AcpProtocolException` to answer with a specific + JSON-RPC error. + +### Fixed + +- **A WebSocket client receives the going-away close (1001) when a Jetty-hosted endpoint shuts + down.** On Jetty, the SDK listener (`StreamableHttpAcpAgentTransport`) and a servlet on Jetty, the + close frame is sent asynchronously, and the graceful shutdown completed once the agents had + closed; Jetty was then stopped before the frame went out, and the client saw the connection drop + (1006) instead. The drain now waits for each WebSocket's close to complete, within the shutdown + timeout: a client that never answers the close holds the shutdown no longer than that. + +- **A WebSocket that opens while the endpoint shuts down no longer leaves its agent running.** A + graceful shutdown (`closeGracefully()` of the SDK listener or of a host's endpoint) that came + between a WebSocket upgrade's shutdown check and the start of its agent closed the connection + while it had no agent yet; the agent created a moment later was started and never closed, and + the shutdown did not wait for it. `RemoteAcpConnection.start` now creates no agent once the + connection is closing (it fails with `AcpConnectionException`, not reported to the exception + handler), and a graceful close that arrives while the agent starts closes it once it has started. + +- **acp-test: `InMemoryTransportPair` no longer drops a message when two threads send at once under + load.** A send that found another thread still emitting on the same direction retried for 100 ms + and then failed with `FAIL_NON_SERIALIZED`, and the message was lost; since delivery runs on the + emitting thread, a slow receiver or a descheduled thread held the sink that long. A send is now + queued, and the thread already emitting delivers it, in order, without making the sender wait. + +- **Micronaut: `acp.agent.transport.type` and `acp.client.transport.type` bind in any case.** + `AcpTransportType` reads the type in any case, as Spring Boot and Quarkus do, but Micronaut's own + enum conversion took only `websocket` or `WEBSOCKET`: a mixed-case `WebSocket` failed the agent's + startup, and on the client was silently dropped, so the transport was inferred as if no type were + set. Both now bind through `AcpTransportType.parse`, and an unknown value fails the startup with + "Unknown ACP transport type '...': use stdio, websocket or http" on both sides. + +- **Micronaut: only a type, a command or a URI creates the client beans.** Any + `acp.client.transport.*` property created them, so `acp.client.transport.websocket.connect-timeout` + alone (or `stdio.args`, `stdio.env.*`) made the client beans, which then failed with "An ACP + client needs a transport". The beans are now created only when `acp.client.transport.type`, + `.stdio.command`, `.websocket.uri` or `.http.uri` is set, the test Spring Boot and + `AcpClientSettings.hasTransport()` use. Behaviour change: such a property alone now leaves the + application without a client, instead of failing it. + +- **Micronaut: a stdio agent's close has its own bound, `acp.agent.shutdown-timeout`** (default + `10s`). Closing the context waited for a stdio agent at most + `acp.agent.transport.http.shutdown-timeout` plus 5 seconds, a listener setting that a stdio agent + otherwise ignores. The HTTP listener keeps that bound; a stdio agent, or an `AcpAgentTransport` + bean of the application's own, now waits at most `acp.agent.shutdown-timeout`, then closes at + once. The default keeps the old default wait, 10 seconds, which is also as long as the SDK's own + graceful close takes at most. Behaviour change: an application that set the HTTP key to bound its + stdio agent's close sets `acp.agent.shutdown-timeout` instead. + +- **Spring Boot: `spring.acp.agent.enabled=false` turns the agent off on every transport.** The + stdio transport and the HTTP endpoint already backed off, but `AcpAgentAutoConfiguration` did + not: it still built the `AcpAgentFactory` from the `@AcpAgent` bean and still served the agent on + an `AcpAgentTransport` bean the application defined itself. With `enabled=false` it now creates + no agent beans and serves nothing, as Micronaut and Quarkus already did. + +- **Spring Boot: two `@AcpAgent` beans no longer fail the startup when + `spring.acp.agent.enabled=false`.** The startup failed with the error naming both beans, whose + advice was to set `spring.acp.agent.enabled=false`. Same cause and fix as the entry above. + +- **Spring Boot: `spring.acp.agent.transport.type=http` in a reactive (WebFlux) web application + served no endpoint and gave no error.** It now serves the endpoint with `acp-http-webflux`, and + fails the startup naming that module when it is missing (see Added and Changed; the interim + "WebFlux is not supported" failure is gone). + +- **A prompt answers `cancelled` once `session/cancel` was received, whatever its handler + returns.** ACP requires an agent to answer a cancelled prompt with stop reason `cancelled`. A + handler that noticed the cancel and returned another stop reason within the grace period had + that stop reason sent: a `void`, `String`, `Mono` or `CompletionStage` `@Prompt` + method sent `end_turn`, and so did a builder handler returning `PromptResponse.endTurn()`. The + agent session now sends a handler's `PromptResponse` with stop reason `cancelled` once + `session/cancel` was received for that prompt, keeping its `_meta`, and logs the change at + DEBUG. It uses the prompt's existing cancelling state, the one the cancel grace period uses. + Behaviour change; migration: a handler that returned another stop reason after cancel now sends + `cancelled`. + +- **A prompt that fails after `session/cancel` answers `cancelled`, not an error.** ACP requires + agents to catch the errors aborted work raises and answer `cancelled`. Only a cancellation + (`CancellationException`, an interrupt, an `AcpProtocolException` with `-32800`) was answered + `cancelled`; any other exception, such as the one a model client throws when its call is aborted, + went out as `-32603`. Once `session/cancel` was received for the prompt, any failure is now + answered `{"stopReason":"cancelled"}` and logged at DEBUG. Before a cancel, failures are answered + as before. Behaviour change; covered by the migration line above. + +- **A builder agent answers `session/new` without a new-session handler.** ACP requires every + agent to support `session/new`; a builder agent built without `newSessionHandler` answered it + `-32601`, so no client could open a session. It now answers with a random UUID as the session ID + and no modes or config options, as an annotated agent without a `@NewSession` method already did. + Both use the new `AcpSchema.NewSessionResponse.withGeneratedId()`. A registered handler replaces + the default, and `build()` still requires only a prompt handler. + +- **The agent checks the client's `terminal` capability for every terminal method.** ACP forbids + an agent to call any terminal method when the client did not advertise `terminal`. Only + `terminal/create` was checked; `terminal/output`, `terminal/wait_for_exit`, `terminal/kill` and + `terminal/release` were sent regardless, for example with a terminal ID from another connection. + All five now fail locally with `AcpCapabilityException` and send nothing, from the agent and + from a prompt context. + +- **A prompt's updates no longer follow its answer.** ACP requires an agent to send a prompt's + `session/update` notifications before it answers the prompt, also after `session/cancel`. When + the SDK answered a prompt itself (the cancel grace period or `maxPromptDuration` passed), a + handler that kept running could still send updates through its prompt context, and they went out + after the answer; so could a handler that sent through its context after returning its own + answer. Once a prompt has been answered, by its handler or by the SDK, `PromptContext.sendUpdate` + and `SyncPromptContext.sendUpdate` (and the helpers built on them, such as `sendMessage`) drop + the update and log it at DEBUG, without its content; the `Mono` completes empty. The check uses + the prompt's answered-once state that decides who answers. Updates sent with + `sendSessionUpdate` outside a prompt context are not affected. + +- **A builder agent advertises `providers` for any provider handler.** Without an initialize + handler, a builder agent advertised `providers` only for a `providers/list` handler, while an + annotated agent does for any of the three provider methods; with only a set or disable handler + the client refused those calls. It now advertises `providers` for any of them. + +- **`SessionCapabilities` writes only objects, and reads only objects as advertised.** Its + components are typed `Object`: `Boolean.TRUE` wrote `"list":true`, which the schema forbids (a + session capability is an object), and on reading any non-null value counted as advertised, so a + peer's `"close": false` made `supportsCloseSession()` true. The record now holds `Boolean.TRUE` + as `{}` and `Boolean.FALSE` as absent, and reading JSON counts only an object as advertised; a + `true`, `false` or other value reads as not advertised. The component types are unchanged. + +- **The `acp-agent-support` README's custom return value handler example no longer breaks + `CompletableFuture`.** The example registered a `CompletableFutureHandler`; custom handlers are + asked before the built-in ones, so it replaced the built-in `CompletionStage` support and a + `CompletableFuture` from `@Prompt` failed every prompt with `-32603`. The example now + adds a type the SDK does not handle (a plain `Future`), and a test keeps it in step with the + README. The README's interceptor example no longer says `afterCompletion` is always called: it + is called for the interceptors whose `preInvoke` returned `true`. + +- **`AcpAgentSupport.Builder.interceptor(null)` fails at once** with `IllegalArgumentException`, + as `argumentResolver(null)` and `returnValueHandler(null)` do. It was accepted, and the next + build failed with a bare `NullPointerException`. + +- **An `Error` while reading a stdio agent's output is reported as itself.** When handling a line + of the agent's standard output threw an `Error` (typically a `NoSuchMethodError` from a Jackson + version clash), `StdioAcpClientTransport` ended as if the agent had "closed its standard output + but has not exited". The real cause is now logged, passed to the exception handler, and is the + cause of the `AcpConnectionException` that ends the transport ("Reading the agent's output + failed: ..."). + +- **A message sent after a transport closed fails instead of being dropped.** + `StdioAcpClientTransport`, `StdioAcpAgentTransport` and `WebSocketAcpClientTransport` dropped a + message sent once they were closed and completed its `Mono`, so a request then waited out its + timeout; `OutboundSinks.emit` documented a failure it never raised (Reactor's `emitNext` drops a + value on a terminated sink). `sendMessage` now fails with `AcpConnectionException` ("The + transport is closed"), as `StreamableHttpAcpClientTransport` already did, and `OutboundSinks.emit` + throws `Sinks.EmissionException` with `FAIL_TERMINATED` as documented. + +- **`WebSocketAcpClientTransport` shuts down its own HTTP client's threads on close.** A transport + built without an `HttpClient` creates one with a cached thread pool, which was never shut down, + so its threads lingered for about a minute after close. Closing the transport now shuts that + executor down; an `HttpClient` you pass in is left alone. + +- **A `WebSocketAcpClientTransport` whose connect failed can connect again.** The failed `connect` + had already subscribed the transport's single-subscriber inbound sink, so a retry on the same + transport "succeeded" but no message from the agent ever reached the handler (Reactor logged + "unicast() sinks only allow a single Subscriber"). The inbound side is now subscribed only once + the connection is open. + +- **`CommandResult` carries the terminal output's `truncated` flag** (new last record component + `truncated`, accessor `truncated()`). `execute` dropped the flag of the client's + `terminal/output` answer, so a caller could not tell output cut to the command's output limit + from complete output. Breaking only for code calling the canonical constructor + `new CommandResult(output, exitCode, signal)`, which still exists as a convenience constructor + (complete output); the new canonical form is + `new CommandResult(output, exitCode, signal, truncated)`. + +- **`SyncPromptContext.tryReadFile` no longer swallows the cancelling interrupt.** It returned + empty for every failure, including the interrupt the SDK uses to cancel a sync handler, and the + interrupt flag was lost, so a cancelled handler carried on as if the file were missing. It now + throws `CancellationException` (interrupt flag set) when the handler is cancelled while it waits, + as the other calls do; any other failure still gives empty. + +- **`askChoice` fails clearly when the client answers with an option it was not offered.** It + parsed the chosen option ID as an index, so an unexpected answer failed with + `NumberFormatException` or `ArrayIndexOutOfBoundsException`. It now fails with an + `AcpError` (`-32603`) naming the ID, with data `{"reason": "unoffered-option", "method": + "session/request_permission", "optionId": }`, as any answer the SDK rejects does; a cancelled + request still gives an empty result. + +- **A request from the agent reaches its client handler only after the session updates sent + before it.** Requests were handled as they arrived, outside the ordered notification drain, so a + `session/request_permission` could reach its handler while the session update consumers had not + yet seen the `tool_call` update the agent sent just before it, and the client could not look the + tool call up. A request now takes its place in the drain like a response: it is dispatched once + the notifications before it have been handled (handlers still run concurrently with each other + and with later updates). To keep a consumer that waits for an answer of its own from deadlocking, + held requests are dispatched at once while the running consumer waits for a response. A + `$/cancel_request` for a request still held answers it `-32800` without calling its handler. + Model checked with Lincheck (`InboundOrderLincheckTest`). + +- **`PromptContext.execute`/`SyncPromptContext.execute` release the terminal when cancelled.** ACP + says the agent MUST release every terminal it creates (`terminals.mdx`). `execute` released it + when the command ended and when a step failed, but not when the prompt was cancelled + (`session/cancel` grace period, `maxPromptDuration`, `$/cancel_request`) while it waited in + `terminal/wait_for_exit`: the client kept the terminal. It now sends `terminal/release` exactly + once on every path. + +- **Annotated agents work as framework-proxied beans and with inherited handlers.** + `AcpAgentSupport` read `@AcpAgent` and the handler methods from the bean's own class only. A + proxy that Spring (CGLIB), Quarkus (ArC) or Micronaut (AOP) generates for the bean is an + unannotated subclass, so `create(proxy)` failed with "Class must be annotated with @AcpAgent", + and handlers declared on a superclass were never found (the client got `-32601`). Discovery + now walks the class hierarchy: `@AcpAgent` may be on a superclass, handlers are collected + from every class up to `Object` and invoked on the instance given (through the proxy, so its + interceptors run), an annotated override is one handler, and bridge and synthetic methods are + skipped. `agentInfo.name` defaults to the annotated class's simple name, not the proxy's. + +- **An annotated agent advertises what its annotations declare.** Without an `@Initialize` method, + `AcpAgentSupport` answered `initialize` with `InitializeResponse.ok()`: `loadSession: false`, no + session, auth or provider capabilities, no `authMethods` and no `agentInfo`. An agent with + `@LoadSession`, `@ListSessions`, `@ResumeSession`, `@CloseSession`, `@DeleteSession`, `@Logout` + or `@Authenticate` told clients it supported none of them, and clients that honour capabilities + never called those methods; `@AcpAgent(name, version)` was never sent. The response is now + derived from the annotation model: + - each handler annotation advertises the capability its method needs: `@LoadSession` → + `loadSession`; `@ListSessions`, `@ResumeSession`, `@CloseSession`, `@DeleteSession` → + `sessionCapabilities.list`/`resume`/`close`/`delete`; `@Logout` → `auth.logout`; the unstable + `@ForkSession` → `sessionCapabilities.fork` and `@ListProviders`/`@SetProvider`/`@DisableProvider` + → `providers`. The others need no capability (baseline methods, per-session modes and config + options, extensions), and `HandlerAnnotationCoverageTest` fails if a new handler annotation has + no declared mapping; + - `@AcpAgent(name, version, title)` → `agentInfo`; a blank name sends the class's simple name, a + blank version the jar manifest's `Implementation-Version`, else `"unknown"`; + - new `@AcpAgent(authMethods = @AuthMethod(...))` → `authMethods`, agent or terminal methods. + Terminal methods are advertised only to a client that announced + `clientCapabilities.auth.terminal`. Declaring an agent method without an `@Authenticate` + handler fails the build, as do duplicate ids and an `env` entry not written `NAME=value`; + - new `@Prompt(image, audio, embeddedContext)` → `promptCapabilities` and + `@AcpAgent(mcpHttp, mcpSse)` → `mcpCapabilities`, default false; + - `protocolVersion` is the client's when the SDK speaks it, otherwise the latest it speaks. + + Interceptors see the derived `initialize` exactly as they see a declared `@Initialize` method + (`preInvoke`, `postInvoke`, `onError`, `afterCompletion`). + + An `@Initialize` method is now optional. When present, its response is laid over the derived + one: a capability is advertised when either side advertises it, so existing code returning + `InitializeResponse.ok()` keeps every derived capability; returned `authMethods` follow the + derived ones, replacing any with the same id; the returned `protocolVersion`, and the returned + `agentInfo` and `_meta` when not null, win. + + **Migration:** an `@Initialize` that only returned `InitializeResponse.ok()` can be deleted. One + that built capabilities by hand can drop what the annotations now derive. A capability a handler + implies can no longer be withdrawn from `@Initialize`; remove the handler instead. +- **`session/close` cancels the session's ongoing work before it closes.** ACP v1 says the agent + "**must** cancel any ongoing work related to the session (treat it as if `session/cancel` was + called) and then free up any resources associated with the session" (schema + `CloseSessionRequest`). The agent session passed `session/close` straight to its handler: a + running prompt kept running and the `session/cancel` handler was never told. Now, when the agent + has a `session/close` handler, the session first does what a `session/cancel` does: it tells the + `session/cancel` handler (`cancelHandler`, `@Cancel`) and marks a running prompt cancelled, so + it answers stop reason `cancelled` (by itself, or from the session after the cancel grace + period). The close handler runs once that prompt has answered. An agent without a close handler + still answers `session/close` with "Method not found" and cancels nothing. + +- **A response result that lacks a required field fails the request.** Only inbound params were + checked for the fields the ACP schema requires, so a peer's `{}` read as a `PromptResponse` whose + `stopReason()` was null, or a `NewSessionResponse` whose `sessionId()` was null, against the + records' `@NullMarked` contract. A result is now checked the same way, on both sides and down + into nested records: the caller's `Mono` fails with an `AcpError` with code `-32603` (internal + error), the message `The response to lacks the required field ` (for example + `modes.currentModeId`) and data `{"reason": "missing-required-field", "method": , + "field": }`. JSON-RPC 2.0 defines no code for an invalid response. A null result for a + response type whose fields are all optional still reads as `{}`. + + **A caller catches one type, `AcpError`, for every request that failed with an error code:** a + peer's error response, or a response the SDK rejected itself. Besides a missing required field, + a response without a result (data reason `missing-result`; it failed with an + `AcpProtocolException`) and a result that cannot be read as the method's result type (reason + `unreadable-result`; it failed with the mapper's `IllegalArgumentException`) now fail with an + `AcpError` with code `-32603`. A rejected response's data is a map with `reason` and `method`; + a peer's error carries the peer's data. `askChoice` answered with an option it did not offer + fails the same way (reason `unoffered-option`). Timeouts and capability checks keep their own types. + `AcpProtocolException` remains what a handler throws to answer with an error. **Migration:** + catch `AcpError` instead of `AcpProtocolException` (or `IllegalArgumentException`) around a + client or agent call. + +- **A failed close is logged as the failure it is.** `AcpSyncClient.closeGracefully()` logged + "Client didn't close within timeout of 10000 ms" for any failure, also one that happened at once; + it now logs that only when the timeout passes, and otherwise `Client close failed: ` with + the cause (it still returns false). `AcpTransport.close()`'s default subscribed to + `closeGracefully()` without an error consumer, so a failure reached Reactor's "Operator called + default onErrorDropped" at ERROR; it is now logged at WARN by `AcpTransport`'s logger. The + transports' and the client session's internal pipelines that subscribed without an error + consumer now log their error (at WARN, or at DEBUG where it was already logged or reported), and + a stdio client's standard-error handler that throws is logged instead of being dropped. + +- **Every transport can be closed more than once, with `close()` and `closeGracefully()` in + either order.** `WebSocketAcpClientTransport.closeGracefully()` sent a second WebSocket close + frame on a second call, which failed with `IOException: Output closed`, so a second + `AcpSyncClient.closeGracefully()` returned false and logged stack traces (try-with-resources after + an explicit close, or a container that destroys a client bean more than once, did this). Only + the first call now closes; later calls complete when it has, and a close frame is not sent once + the connection's output is closed. `TransportCloseIdempotenceTest` closes every shipped transport + (WebSocket client, Streamable HTTP client and its agent-side connection over HTTP and WebSocket, + stdio client and agent, in-memory client and agent) twice in each order. + +- **The stdio client's graceful close lets the agent exit by itself, and logs the stop once.** + `StdioAcpClientTransport.closeGracefully()` sent SIGTERM straight away without closing the + agent's standard input, so an agent's end-of-input handling (an SDK stdio agent answers what it + received, flushes, and exits 0) never ran and every close ended in exit 143. It now closes the + agent's standard input first and waits up to `StdioAcpClientTransport.END_OF_INPUT_WAIT_MILLIS` + (2 seconds) for the agent to exit; only an agent still running then is sent SIGTERM, and killed + five seconds later as before. Closing again, as `closeGracefully()` followed by try-with-resources + `close()` does, no longer stops the process a second time or logs "ACP agent process stopped" + twice. + +- **`prompt()` returns only after the session-update consumers have handled the turn's updates.** + On the client, notifications are delivered one at a time on their own drain, while a response + completed its caller as soon as it arrived. A prompt's response follows its turn's updates, so a + sync `prompt()` (or the async `Mono`) could return while the consumer was still handling the + last updates, and a caller that read what it had collected missed some, silently. Now every + response from the agent completes its caller only once every notification that arrived before it + on the session has been handled: the response takes its place in the notification queue + (`InboundOrder`, model checked with Lincheck in `InboundOrderLincheckTest`). Notification order, + `closeGracefully()` waiting for the drain, and `close()` not waiting are unchanged; a response + still held when the session closes is completed then. A slow consumer delays the response, which + still counts against the request timeout. So that a consumer which sends a request of its own and + waits for it cannot deadlock, a response is not held behind a handler that was already running + when its request was sent. A consumer must not wait for the prompt in flight to complete. The + agent side is unchanged: its notification handlers are not ordered, and `session/cancel` takes + effect on the inbound thread before any later message is handled. + +- **Closing the Streamable HTTP servlet is bounded, and an `initialize` in flight no longer + outlives it.** `StreamableHttpAcpServlet.destroy()` waited up to 30 seconds (the initialize + timeout) for its connections, and `closeGracefully()` had no bound at all, so an agent whose + close did not finish held the container's shutdown. Both now finish within the new + `StreamableHttpAcpAgentTransportOptions.shutdownTimeout` (default 5 seconds; see Changed): a + connection whose agent has not closed by then is closed at once (`RemoteAcpConnection.close()` + now also closes a connection whose graceful close has not finished). The listener + (`StreamableHttpAcpAgentTransport.closeGracefully()`) bounds its WebSocket connections the same + way. An `initialize` still being answered when the servlet closes is answered `503` at once, + instead of holding its request until the agent answered or 30 seconds passed, and an answer that + arrives after the close no longer registers a connection on the closed servlet (it used to stay + open, with its agent, until the JVM exited). Closing never waits for a client: SSE responses are + completed, not drained. + The 30-second stall reported from Spring Boot is not `destroy()`: Boot shuts its server down + gracefully by default, and every SSE stream a connected client holds is an in-flight request, + so Boot waits its `timeout-per-shutdown-phase` (30 seconds) before servlets are destroyed. The + servlet Javadoc now documents the remedy: call `closeGracefully()` before the server stops (a + `SmartLifecycle` in the default phase). + +- **The stdio agent transport's hold on `System.in` is documented.** Closing a + `StdioAcpAgentTransport` built on `System.in` cannot end a read already in progress, because a + read of `System.in` cannot be interrupted: its `acp-agent-inbound` thread stays blocked, holding + `System.in`'s lock, until the next line or the end of standard input, then discards what it read + and ends (now covered by a test). Under Maven Surefire, standard input is the fork's command + stream, so such a reader, left by a test that builds the transport on `System.in`, took the + fork's commands and stalled its exit until Surefire killed it 30 seconds after `System.exit(0)`. + The class, its `System.in` constructors and `closeGracefully()` now say so, and that embedders and + tests should pass explicit streams, use `acp-test`'s in-memory transport, or install an empty + standard input before the tests run. The SDK's own tests never start a reader on `System.in`; the + one that built the transport on it now passes explicit streams. The `AcpAgentSupport` Javadoc + example no longer calls a `StdioAcpAgentTransport.create()` that does not exist. + +- **An `@Prompt` method taking the async `PromptContext` failed every call.** `PromptContextResolver` + accepted the parameter, but `AcpAgentSupport` supplied only the `SyncPromptContext`, so the prompt + was answered with `-32603` "PromptContext is only available for @Prompt handlers". The parameter + now receives the context the sync one wraps (`SyncPromptContext.async()`), so a `@Prompt` method + can take either and return a `Mono` composed from the async calls. + +- **Annotated handlers other than `@Prompt` could not take `NegotiatedCapabilities`.** Only the + prompt handler was given them, so a `@NewSession` (or any other) method taking them failed with + `-32603` "NegotiatedCapabilities not available in current context". Every handler, `@Initialize` + and extension handlers included, now receives the capabilities negotiated on its own connection + (the agent records them from the client's `initialize` request before the handler runs). + +- **A handler that threw an `Error` left the peer waiting.** Reactor rethrows what it treats as + JVM-fatal (`LinkageError`, `VirtualMachineError`) instead of signalling it, so a handler that + threw, say, the `NoSuchMethodError` of code compiled against 0.18.0 calling a removed constructor + unwound the handler thread, its request was never answered, and the peer waited out its request + timeout. Now any `Throwable` escaping a request handler, sync or async, on the agent and the + client, annotation-based handlers included, is answered with `-32603` (Internal error), message + `Internal error in the handler ()` with no stack trace, error message or + payload; it is logged at ERROR (`InboundMessages`) with the throwable, and the connection keeps + serving. A notification handler's `Error` is logged at ERROR and skipped. A + `VirtualMachineError` (`OutOfMemoryError`, `StackOverflowError`) is not hidden: it is answered + the same way as far as the JVM still can, and also handed to the uncaught-exception handler of + the thread that ran the handler, where it would have gone had it escaped (JVM options such as + `-XX:+ExitOnOutOfMemoryError` act when it is thrown and are unaffected). Not reachable: an async + handler's `Mono` that throws a JVM-fatal error inside its own operators after the handler + returned, which Reactor rethrows on the emitting thread. `HandlerFailures` (`util`) holds the + guard. + +- **`AcpAgentSupport.Builder` could not be built twice.** `build()` appended the default argument + resolvers and return value handlers to the builder's own lists, so a second build duplicated + them, a custom resolver or handler added after the first build came after the defaults and never + ran, and agents already built shared, and saw changes to, the builder's lists. `build()` now + composes the custom entries and then the defaults into a snapshot without changing the builder; + each agent keeps the configuration it was built with. + +- **Params of an application's own record type were rejected as missing their fields.** The + required-field check on inbound params (`-32602`) walked every record, so a non-public record + that the SDK could not read reported each of its fields missing, and an application record was + held to rules only the schema defines. It now checks the schema's records (`AcpSchema`) only. + +- **A stdio agent dropped the replies to requests that arrived just before its input ended.** When + the client closed the agent's standard input, `StdioAcpAgentTransport` completed + `awaitTermination()` at once and stopped writing, so an agent written as documented + (`agent.start().then(agent.awaitTermination()).block()`) exited with replies still to come: a + client that writes its requests and closes the pipe (a script, a test) got none, or some, of its + answers. The end of standard input is now a half-close, not a cancellation: every request already + received is handled and answered, the notifications its handler sends are written in order, then + standard output is closed and `awaitTermination()` completes. A request the agent sends to the + client, which can no longer answer, fails at once: one waiting when the input ends with a `-32603` + error response, one sent after it with an `AcpConnectionException`. The drain is bounded by a drain + timeout, `StdioAcpAgentTransport.DEFAULT_DRAIN_TIMEOUT` (60 seconds) or the one given to the new + constructor `StdioAcpAgentTransport(AcpJsonMapper, InputStream, OutputStream, Duration)`; a request + still unanswered then is answered with `-32800` (request cancelled) and the transport terminates + without it. `closeGracefully()` now also completes `awaitTermination()`. +- **JSON-RPC request validation (JSON-RPC 2.0 sections 4 and 5.1), on both sides and every + transport.** A request whose `method` is not a string was answered `-32601` (method not found); + one whose `id` is not a string, number or null (an object, say), or whose `jsonrpc` is not exactly + `"2.0"`, was executed. All three are now answered `-32600` Invalid Request, with the request's id + when it can be read and `null` otherwise (section 5); `AcpSchema.deserializeJsonRpcMessage` refuses + them, and `unreadableMessageResponse` answers them. Over Streamable HTTP a JSON object posted on a + connection that is no valid request is answered on the connection stream with `202`, as the + TypeScript server does; a body that is not a JSON object is still refused with `400`. Params the + method cannot read are now `-32602` Invalid params, message `Invalid params`, with the reason as + `data`, instead of `-32603`, and the handler is not called: a value of the wrong JSON type, and a + required field (a schema record component without `@Nullable`, the schema's `required` list) + that is missing, such as `session/new` without `cwd`. The new `AcpTransport.unmarshalParams` + does this for both the agent's and the client's handlers. A notification whose handler fails + (such params, say) is now logged at WARN and skipped; on the agent the failure used to end the + session's inbound stream, so the connection stopped answering. The Streamable HTTP client now + answers an SSE event that is no JSON-RPC message, in the scope of its stream, as the stdio and + WebSocket transports do; it used to skip it, and the agent waited for an answer. +- **Behaviour change: the default JSON mappers no longer coerce scalars.** `JacksonAcpJsonMapper` + and `Jackson3AcpJsonMapper` read `"cwd": 42` as the string `"42"`, and a string as a number or + boolean; they now refuse a scalar of the wrong JSON type (`ALLOW_COERCION_OF_SCALARS` off, and no + number or boolean to string), so such params are `-32602`. +- **A stray response without an id was logged at ERROR as a bug in this SDK.** A response with no + id and no error answers no request this side sent; it is now logged at WARN. +- **A JSON-RPC error's wire `message` carried the code.** An `AcpProtocolException` thrown by a + handler was answered with the exception's log form as the message, for example + `"message": "[-32602] unknown directive: #nope"`; JSON-RPC 2.0 (section 5.1) keeps the number in + `code` and a short description in `message`. The wire message is now the plain message + (`"unknown directive: #nope"`). `AcpProtocolException.getMessage()` still prefixes the code, for + logs; the new `getErrorMessage()` returns the plain message, and `JSONRPCError.from` uses it, so a + received error passed on is unchanged. +- **Streamable HTTP routed `session/delete`, `session/close`, `session/list`, + `session/set_config_option`, `session/fork` and `logout` by guesswork, and the server refused a + session it did not know with 404.** The client had rules for only seven methods and inferred the + rest from their params with a WARN per call; the server likewise. Both now route every + client-to-agent method of ACP v1, stable and unstable, from a table: `authenticate`, `logout`, + `session/new`, `session/list`, `providers/*`, `nes/start`, `mcp/message` and `$/cancel_request` on + the connection; every method whose params require a `sessionId` (`session/prompt`, `cancel`, + `set_mode`, `set_config_option`, `close`, `delete`, `fork`, `load`, `resume`, `nes/suggest`, + `accept`, `reject`, `close`, `document/*`) session-scoped with `Acp-Session-Id`, as the transport + RFD's Identity Model asks and the Rust and TypeScript SDKs do. Only methods outside v1, such as + extension methods, are still routed by their params (logged at DEBUG). The server no longer + answers a session-scoped POST naming a session the connection does not know with 404: the RFD's + POST decision tree has none, so the request goes to the agent, which decides; a `session/delete` + of a session that never existed succeeds, as `session-delete.mdx` asks. Its reply comes on that + session's stream when it has one, else on the connection stream. `session/delete` and + `session/fork` are accepted without `Acp-Session-Id`, as `session/load` was, and answered on the + connection stream: the RFD names no session-scoped method and the Python client sends neither + with the header. + A session-scoped GET for a session the connection does not know still opens a bounded + provisional stream: the RFD's GET tree says 404, but its own resume flow opens the session stream + before `session/load`, and the TypeScript client does so for `session/load` and `session/delete` + (it fails against a server that answers 404). The client now copes with a server that does answer + 404 (the Python server): it posts the request on the connection without `Acp-Session-Id`, and the + reply comes on the connection stream. +- **A stdio client did not notice its agent process exiting.** `StdioAcpClientTransport` did not + implement `awaitTermination()`, so when the agent exited or crashed, pending requests waited out the + request timeout. It now completes when the transport is closed, and errors with an + `AcpConnectionException` such as `ACP agent process exited with code 137 (signal 9)` once the + agent's output has ended and the process has exited on its own; the session fails pending requests + with it at once. +- **A peer's error answer to a message it could not read was logged as an SDK bug.** An error + response with `"id": null` (what JSON-RPC 2.0 prescribes, and what this SDK and its peers send, for + a message whose id could not be read) was logged at ERROR as "a bug in the request sender code". + It is now logged at WARN as the peer's error report, with its code and message. +- **Inbound payloads no longer reach logs above DEBUG.** An unhandled notification was logged at + WARN with its params: Claude's and Codex's agents send `_auth/status_update`, whose params carry + the account's email address in login mode. `AcpClientSession` now logs the method only, as + `AcpAgentSession` already did. The same rule now holds wherever an inbound message reached a log + at INFO or above: an error response's `data` (`OutboundMessages`), a line from the agent that is + not a JSON-RPC message and a message the stdio client could not queue (`StdioAcpClientTransport`), + and a message of unknown type (both sessions). Each is logged at DEBUG instead. The exceptions + `AcpSchema.deserializeJsonRpcMessage` throws for JSON that is no JSON-RPC message, and for a + request JSON-RPC 2.0 does not allow, which every transport logs, no longer quote the text. A dropped outbound response is logged without its + content too (`OutboundSinks`). +- **`closeGracefully()` could wait out the whole request timeout when a notification arrived + as it closed.** Found by the real-agent smoke tier: Claude's ACP agent sends a + `session_info_update` about 10 ms after a prompt answers, and closing the client right after the + turn hung for the request timeout in 4 of 9 runs. A notification handler runs on the inbound + thread inside the emission on the session's notification sink; the close's completion of that + sink collided with it, was refused (`FAIL_NON_SERIALIZED`) and dropped, and the close then waited + for a drain that never ended. Completion is now never lost: the close records that completion is + wanted, and an emission it collided with completes the sink once it returns (model checked with + Lincheck, `NotificationQueueLincheckTest`). A bounded busy-loop retry would not do: a handler + that works synchronously holds the sink for as long as it runs. Notifications are still delivered + in order through one drain, `closeGracefully()` still waits for it at most the request timeout, + and `close()` still interrupts it. +- **Both stdio transports stopped reading on the first line that was not a JSON-RPC message.** + `StdioAcpAgentTransport` and `StdioAcpClientTransport` ended their inbound stream on one malformed + line, so the peer's later messages were never read; the client did so without telling anyone. Such + a line is now logged, reported to the transport's exception handler, answered, and skipped, and + reading goes on; a blank line is skipped silently. The answer is the one JSON-RPC 2.0 prescribes + when the receiver cannot read a message, so cannot know whether it was a request: an error + response with `"id": null` and code `-32700` (`Parse error`) for text that is not JSON, or + `-32600` (`Invalid Request`) for JSON that is no JSON-RPC message. Both stdio sides answer, since + each serves the requests of the other; the TypeScript SDK's stdio and WebSocket streams do the + same. `StdioAcpClientTransport` now implements `setExceptionHandler` (it inherited the no-op + default), and also reports read and write failures to it. +- **The WebSocket transports now answer a malformed message the same way.** + `WebSocketAcpClientTransport` already skipped and reported one, and now also answers it. + `StreamableHttpAcpAgentTransport`'s WebSocket upgrade closed the whole connection (code 1002) on + one; it now answers, reports and skips it, and the connection stays open. A Streamable HTTP POST + with a malformed body is still refused with 400, the HTTP answer, and a malformed SSE event is + skipped unanswered (an SSE stream has no reply channel) but is now reported to the client + transport's exception handler. `AcpSchema.unreadableMessageResponse(jsonMapper, text)` builds the + answer, and `JSONRPCResponse` now writes a null id as `"id": null` (it omitted it), as JSON-RPC 2.0 + requires of a response. +- **The transport errors of a Streamable HTTP or WebSocket connection were only logged.** The agent + runtime installs no exception handler on the transport `RemoteAcpConnection` gives it, and the + listener offered no way to install one, so a host never saw a connection's transport errors. + `StreamableHttpAcpAgentTransport.setExceptionHandler` and `StreamableHttpAcpServlet.setExceptionHandler` + now receive them for every connection, HTTP/SSE and WebSocket alike, as + `StdioAcpAgentTransport.setExceptionHandler` does for stdio; the default still logs them, and an + agent factory may still install its own on the transport it is given. `RemoteAcpConnection` takes + the handler as a new constructor argument. +- **A request that timed out or was cancelled stayed registered until the session closed.** Both + session sides kept the entry for every request still awaiting a response, and removed it only when + the response arrived or the session ended. A request that hit the request timeout, or whose + subscriber cancelled, kept its entry, so a long-lived session grew by one entry per such request, + and a late response was silently swallowed rather than logged as unexpected. The entry is now + removed when the request times out or is cancelled (when its sink is disposed, and only if the + entry is still that request's). Found by the PIT mutation-testing pilot and, independently, by + model checking with Lincheck (`PendingResponsesLincheckTest`). +- **Streamable HTTP: a JSON-RPC message with `"id": null` broke the agent transport.** A client + request posted with `"id": null` (legal JSON-RPC) was answered with `"id": null`, and routing that + answer looked the id up in a map that rejects null keys; the `NullPointerException` closed the whole + connection. A client error response with `"id": null` (JSON-RPC's answer to a request it could not + parse) failed the POST with 500. The answer now goes to the connection stream, and the response is + accepted with 202. +- **`StreamableHttpAcpClientTransport`: a response with `"id": null` failed the stream reader.** An + agent's error response with `"id": null` (JSON-RPC's answer to a request it could not parse) was + looked up in a map that rejects null keys, and the `NullPointerException` replaced delivery; a + client response with `"id": null` failed the same way on its way out. Both are now delivered. +- **`AgentParameters.Builder.arg` after `args(String...)` threw `UnsupportedOperationException`**: + the varargs form kept the fixed-size `Arrays.asList` view. It now copies the arguments. +- **`StreamableHttpAcpAgentTransport`'s WebSocket upgrade closed the connection on any inbound text + message over 64 KB** (Jetty's default; close code 1009), so a prompt carrying a file of that size + ended the session. The single-client `WebSocketAcpAgentTransport` accepted 4 MB. The upgrade now + accepts messages up to `StreamableHttpAcpAgentTransportOptions.maxPostBodyBytes` (16 MB by + default), the same inbound limit as a Streamable HTTP POST body. +- **Streamable HTTP: an event written as the client dropped its SSE stream could be lost.** When a + client closed a session stream (an HTTP/2 reset) and the agent emitted an event before the server + had detached that stream, Jetty accepted the write and its flush, and reported the failure only on + the next write; the event was counted as sent and the reconnecting client never received it (a + prompt's `session/update` went missing while its result arrived). A subscriber now keeps each event + it writes until the write has completed without error, checking a write that completed at once + with an empty write, and returns unconfirmed events to the front of the stream's mailbox when it + goes away, so the next GET receives them in order. An event whose write was still pending when its + stream was replaced may now arrive twice rather than not at all. Bytes the server did put on the + wire before the client's reset arrived can still be lost: closing that needs SSE event ids and + `Last-Event-ID`, which the transport RFD defers to v2. +- **A response with `"result": null` failed the request** ("carried no result") even where the + result is an empty object, a regression in this release: the Python SDK answers + `fs/write_text_file` with `"result": null` when its handler returns `None`, so a Java agent could + not write files through a Python client. As in the Rust SDK (`default_on_null`, schema 1.9.1), a + response type whose every field is optional now reads a null or missing result as `{}`; these types + implement the new marker `AcpSchema.DefaultOnNull` (`AuthenticateResponse`, `LogoutResponse`, + `LoadSessionResponse`, `ResumeSessionResponse`, `CloseSessionResponse`, `DeleteSessionResponse`, + `SetSessionModeResponse`, `WriteTextFileResponse`, + `ReleaseTerminalResponse`, `KillTerminalCommandResponse`, `WaitForTerminalExitResponse`, + `SetProviderResponse`, `DisableProviderResponse`). A response type with a required field still + fails clearly. A null result of an extension (`_`-prefixed) method completes the request's `Mono` + empty, and a message with an `id` but no `method`, `result` or `error` is read as a response + without a result instead of being rejected. +- An interceptor's `afterCompletion` ran twice when a later interceptor vetoed the call or threw from + `preInvoke`: once inside `InterceptorChain.applyPreInvoke` and again from the invocation's cleanup. + `applyPreInvoke` no longer runs cleanup, and `triggerAfterCompletion` runs at most once per chain, + so `afterCompletion` runs exactly once per invocation on every path. + +Found by the NullAway adoption; each has a test. + +- A request or notification that omits `params` (legal JSON-RPC) reached its handler as `null`; it now + reads as an empty object, as if the peer had sent `{}`. +- A request whose handler produced no result got no JSON-RPC response at all, so the peer waited for + its timeout: a core request handler that completed empty, and in `acp-agent-support` a `void` or + null-returning handler method, or one vetoed by an interceptor. Such requests are now answered with + an `INTERNAL_ERROR` saying the handler produced no response. +- A handler exception without a message produced a JSON-RPC error without the required `message`; the + exception's type name is sent instead. +- A success response without a `result` failed the request with an opaque `NullPointerException`; the + error now says the response carried no result. +- `PromptContext.askChoice` failed the prompt with a `NullPointerException` when the client cancelled. +- `PromptContext.execute` failed the prompt with a `NullPointerException` when the command was killed + by a signal (no exit code). +- `AcpSchema.deserializeJsonRpcMessage` threw `NullPointerException` for the JSON `null` literal + instead of the documented `IllegalArgumentException`. + +Found by enabling Error Prone's bug checks; each has a test. + +- `StdioAcpClientTransport` read the agent's stdout and stderr in the platform default charset + while writing to it in UTF-8. On Java 17 that charset follows the platform (Cp1252 on Windows, + US-ASCII under a POSIX locale), so non-ASCII text from the agent arrived garbled. Both are now + read as UTF-8. +- The Streamable HTTP client rejected an initialize response whose `Content-Type` was upper case + when the JVM's default locale was Turkish (`"APPLICATION/JSON".toLowerCase()` is + `"applıcatıon/json"` there). The client's and the servlet's media-type checks, and the `os.name` + check in `AgentParameters`, now lower-case with `Locale.ROOT`. +- In `acp-agent-support`, a handler method whose result was not the ACP method's response type + failed with an opaque `ClassCastException` message; the `INTERNAL_ERROR` now names the method and + both types. + +Found by model checking with Lincheck; each has a Lincheck test as its regression. + +- **`AcpAgentSession.hasActivePrompt()` and `getActivePromptSessionIds()` could report no active + prompt while one was active**, when a prompt of another session started or ended at the same + moment: they asked the `ConcurrentHashMap` for its size, whose counters concurrent updates change + after the entries themselves. They now look at the entries. +- When the transport terminated while the session was closing, each request still waiting for a + response was failed twice (Reactor dropped the second failure), and a request registered during + that dismissal could be cleared from the table without being failed, leaving its caller to wait + for the timeout. Each request is now removed from the table before it is failed, so it is failed + exactly once. +- **Streamable HTTP: a session stream first used after the connection closed stayed open.** A + session GET that raced the connection's close (a `DELETE`, or the agent ending the connection) + held its response open until the client gave up, and agent messages for a session after close + queued in a new stream nobody would read. Such a stream is now closed, so the GET completes at + once, as on the connection stream. The connection's session table now makes each check and the + change it decides one step. + +Found by measuring coverage with JaCoCo; each has a test. + +- **Annotated agents registered by class got a new instance for every request.** + `AcpAgentSupport.create(MyAgent.class)` and `builder().agent(MyAgent.class, factory)` created the + agent anew on each handler call, so state kept in its fields (sessions stored by `@NewSession` and + read by `@Prompt`, as in the module README's complete example) was lost between requests. The + agent is now created once, when it is registered, and that instance serves every request; a class + that cannot be instantiated fails at registration with an `IllegalArgumentException` rather than on + the first request. The module README's factory example, which called a + `create(Class, Supplier)` that does not exist, is corrected. + +### Build + +- **The Quarkus tests no longer bind the fixed port 8081.** Quarkus serves `@QuarkusTest` and + `QuarkusUnitTest` applications on `quarkus.http.test-port`, 8081 by default, so two builds on one + machine (or anything else on 8081) failed with "Port already bound: 8081". The deployment + module's tests now run with `quarkus.http.test-port=0`, a random port, and the integration tests + on a free port a test resource picks (the client bean's URI is read at startup, before a random + port is known). The tests read the bound port through `@TestHTTPResource`. + +- **Spring Boot's configuration metadata is written on JDK 21 and later too.** The `errorprone` + profile (JDK 21+) sets the compiler's processor path to Error Prone and NullAway, which turns off + discovering processors on the classpath, so `acp-spring-boot-autoconfigure` built on JDK 21 had + no `META-INF/spring-configuration-metadata.json` (no IDE completion or descriptions for the 32 + `spring.acp.*` properties) and no `spring-autoconfigure-metadata.properties`. The module now + names `spring-boot-configuration-processor` and `spring-boot-autoconfigure-processor` on its + processor path, so every JDK writes both. Released jars, built on JDK 17, were not affected. + +- **The Javadoc build knows the `@apiNote`, `@implSpec` and `@implNote` tags.** They were not + registered with the javadoc plugin, so their text was silently left out of the generated pages + (and doclint reports them as unknown tags). The parent POM now registers them, with the JDK's + headings. + +- **The JaCoCo coverage gate skips with the tests** (`-DskipTests`). It read whatever execution data + an earlier run had left in `target/`, so `./mvnw -DskipTests install` after a `-Dtest` run (a + partial `jacoco.exec`) failed the gate; this broke the cross-SDK suite's SDK install. +- Model checking with Lincheck (`org.jetbrains.lincheck:lincheck` 3.7, test scope) for the SDK's + concurrent state: the single-turn prompt rule and its release before the response is published + (#14), the requests waiting for a response, emission on a transport's outbound sink, the + Streamable HTTP SSE mailbox (no event lost or reordered across reconnects, client resets and + pending writes) and the connection's session table. Each test checks every interleaving of a + bounded, seeded set of scenarios against a sequential specification and invariants, and was + shown to fail, with a minimal interleaving, against the bug it guards. They run in the normal + build (about a minute in total); the `lincheck` CI job runs them with ten times the scenarios. + The single-turn rule and the requests table moved into their own classes (`ActivePrompts`, + `PendingResponses`) so that they can be checked without a transport. + +- Architecture rules (ArchUnit) guard the package structure: acp-core's layers (util and json under the + protocol, protocol under capabilities, capabilities under client and agent, which never depend on each + other), no package cycles, no Jackson databind in acp-core, and no Jetty types on the path of + `StreamableHttpAcpServlet`, which must stay mountable in any Servlet 6 container. +- The architecture rules cover every module. Each module's rules import only that module's main + classes and check that the import is non-empty (`archRule.failOnEmptyShould=true`), and every module + is free of package cycles at any depth. acp-annotations depends on nothing in the SDK; the JSON + modules use only acp-core's JSON SPI and the schema; acp-agent-support layers `AcpAgentSupport` over + independent resolver, handler and interceptor packages over the invocation model, and uses only + acp-core's public agent API (no session implementations, client code, concrete transports or JSON + implementation); acp-test uses only public SDK types and nothing from the annotation layer; and the + transports, in acp-core and acp-streamable-http-jetty, see only the protocol (the `spec` transport + interfaces and schema) plus, on the agent side, the per-connection seam (`AcpAgentFactory`, + `AcpAsyncAgent`), never the session implementations or other client and agent runtime classes. The + session (`spec`) and transport packages also have no dependency cycles between classes. +- Null safety is enforced, not just declared: every package is `@NullMarked` (JSpecify 1.0.0, a + compile dependency of each module; optional in `acp-annotations`, which keeps no transitive + dependencies), and NullAway 0.13.8 on Error Prone 2.50.0 checks main sources at ERROR. Error Prone + needs JDK 21, so the check runs in a `nullaway` profile activated on JDK 21+ and in a new JDK 21 CI + job; the JDK 17 build and release are unchanged. Compilation uses `--release 17`, so a JDK 21 build + still compiles against the Java 17 API. `.mvn/jvm.config` opens the javac internals Error Prone needs + (harmless on JDK 17). +- Error Prone's own bug checks run beside NullAway, at ERROR, in the JDK 21 build. The profile is + renamed `errorprone` (from `nullaway`), and so is its CI job. Every check Error Prone enables by + default runs, and `failOnWarning` makes its warnings fail the build too (as it does javac's own + default warnings, such as use of an API deprecated for removal). One check is disabled as style, + `StatementSwitchToExpressionSwitch`. Two more are enabled: `SystemOut`, since anything printed to + standard out corrupts a stdio agent's JSON-RPC stream, and `CheckReturnValue` over + `reactor.core.publisher`, so a Mono or Flux operator whose result is dropped (and never runs) + fails the build; `config/errorprone/reactor-ignorable-results.txt` lists the results that may be + dropped, with reasons. Still main sources only, still JDK 21+ only. +- A scheduled dependency vulnerability scan (`dependency-scan.yml`) checks the resolved runtime + dependency tree of every module against OSV weekly, on demand, and on each push to `main` that + changes a `pom.xml`, so a CVE published after a release no longer waits for someone to look. Maven + resolves the tree (a CycloneDX BOM, test scope excluded) and osv-scanner 2.6.0 checks it; no API key + or secret is involved. The job fails on HIGH or CRITICAL (CVSS 7.0 or more) or an unscored finding, + lists lower findings without failing, and uploads SARIF to code scanning. An unfixable finding can be + accepted in `osv-scanner.toml` with a reason and an `ignoreUntil` date, after which it fails again. + Run it locally with `.github/scripts/osv-scan.sh`. +- A size, complexity and duplication gate fails `verify`: maven-pmd-plugin 3.28.0 (PMD 7.28.0) runs + `pmd:check` and `pmd:cpd-check` on main sources with the ruleset in `config/pmd/ruleset.xml`, which + records each threshold and the measurement behind it. Per method: cognitive complexity 15, + cyclomatic complexity 10, 30 statements (NCSS), and 30 statements in one lambda body (a rule of the + ruleset's own, since NCSS does not look inside lambdas); per class: 300 statements and cyclomatic + complexity 80; 7 parameters; CPD at 100 tokens. There is no baseline: the code was refactored to + pass. The one exemption is structural, the class-size limit for a wire record container + (`AcpSchema`). Agent handlers are registered as data, so `DefaultAcpAsyncAgent.start()` (150 lines) + is a loop; `StreamableHttpAcpClientTransport` (1,045 lines) is a facade over package-private + classes for HTTP exchanges, routes, SSE streams and inbound delivery; the two sessions share their + request plumbing. The class-cycle rule in acp-core's `ArchitectureTest` now covers every package + except `json` (whose default-mapper lookup is a cycle by design), with the `AcpAgent` <-> + `DefaultAcpAsyncAgent` cycle gone. +- A bug-pattern gate fails `verify`: SpotBugs 4.10.4 (spotbugs-maven-plugin 4.9.8.5; the 4.10 + plugin needs Maven 3.8.9 and the wrapper is 3.8.6) runs `spotbugs:check` on main sources in every + module, on JDK 17 and JDK 21 alike, at effort Max, and fails on any finding of rank 9 or less + (SpotBugs' "scariest" and "scary" bands). Rank, not confidence alone, is the gate: it weighs the + pattern's severity with its confidence, so the long tail of rank 10 to 20 advice (exposed record + arrays and collections, inner classes that could be static, serialization hygiene) does not fail + the build. find-sec-bugs was evaluated and not added: it raised nothing at rank 9 or less, and its + findings below that are the library's purpose (starting the configured agent process, writing + JSON-RPC to a servlet response). The first run found three: two false positives, excluded one by + one with their reasons in `config/spotbugs/spotbugs-exclude.xml`, and the Streamable HTTP SSE + stream locking on its own monitor, which any code holding the stream could also take; it now + locks a private object. +- The SpotBugs gate also fails on concurrency bugs below rank 9. SpotBugs ranks most + multithreaded-correctness patterns 14 to 17 (inconsistent synchronization is 17), so a rank gate + alone never sees them. The analysis now reports up to rank 20, and + `config/spotbugs/spotbugs-include.xml` selects what fails the build: every pattern of rank 9 or + less, plus a listed set of concurrency patterns at any rank (inconsistent synchronization, + wait/notify and explicit-lock misuse, broken double-checked locking, `volatile` increments, + locking on the wrong object, mutable servlet fields), each group with its reason, and the + concurrency patterns left out with theirs. The code base had no such finding. One analysis serves + both: a second `check` execution with its own filter does not work, since `check` analyses with + the plugin-level configuration and the second execution would pass on a report never written. + +- A coverage gate fails `verify`: JaCoCo's `check` requires each module's line and branch + coverage to stay at or above floors declared in its `pom.xml` (`jacoco.minimum.line`, + `jacoco.minimum.branch`). Each floor is the coverage measured from a clean `verify`, less a margin + of 2 points, rounded down to a whole percent: line / branch floors are acp-core 82 / 70, + acp-agent-support 85 / 76, acp-streamable-http-jetty 86 / 76, acp-json-jackson2 98 / 60, + acp-json-jackson3 94 / 60 and acp-test 76 / 38; acp-annotations has no executable code. Each + module counts only its own classes, exercised by its own tests (and, in acp-core, its integration + tests); the JSON modules run acp-core's contract suite but count only their mapper classes. Nothing + is excluded, `AcpSchema`'s records included: they are covered like the rest (80 % of their lines). + The parent's default floor is full coverage, so a new module fails until it declares measured + floors. The per-module HTML report, now written in `verify` so it shows what the gate checks, is + at `/target/site/jacoco/index.html`. The measurement found code no test in its own module + reached, now tested: `RemoteAcpConnection`, the per-connection core of the remote agent + transports (0 %, exercised only through acp-streamable-http-jetty); the WebSocket client + transport's listener (fragmented and malformed frames, server close, socket errors, failed sends); + the stdio client transport's reader and writer, which only a child JVM had run; and in + acp-agent-support the fork, session-config and disable-provider handlers, `Mono` return values, + and the errors a client gets when a handler cannot be invoked or answered (which found the bug + above). acp-core moved from 77 % to 84 % of lines, acp-agent-support from 70 % to 88 %. ## [0.18.0] - 2026-09-25 @@ -380,6 +2591,7 @@ Protocol currency: catching up to ACP spec v0.13.6 (June 2026). Supersedes the n - SLF4J 2.0.16 [0.9.0]: https://github.com/agentclientprotocol/java-sdk/releases/tag/v0.9.0 +[0.80.0]: https://github.com/agentclientprotocol/java-sdk/releases/tag/v0.80.0 [0.18.0]: https://github.com/agentclientprotocol/java-sdk/releases/tag/v0.18.0 [0.17.0]: https://github.com/agentclientprotocol/java-sdk/releases/tag/v0.17.0 [0.16.1]: https://github.com/agentclientprotocol/java-sdk/releases/tag/v0.16.1 diff --git a/README.md b/README.md index aefdb9c4..abaa0b01 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # ACP Java SDK +[![CI](https://img.shields.io/github/actions/workflow/status/agentclientprotocol/java-sdk/ci.yml?branch=main&label=CI)](https://github.com/agentclientprotocol/java-sdk/actions/workflows/ci.yml) +[![Cross-SDK nightly](https://img.shields.io/github/actions/workflow/status/agentclientprotocol/java-sdk/cross-sdk.yml?branch=main&label=cross-SDK%20nightly)](https://github.com/agentclientprotocol/java-sdk/actions/workflows/cross-sdk.yml) +[![Dependency scan](https://img.shields.io/github/actions/workflow/status/agentclientprotocol/java-sdk/dependency-scan.yml?branch=main&label=dependency%20scan)](https://github.com/agentclientprotocol/java-sdk/actions/workflows/dependency-scan.yml) +[![Java interop](https://img.shields.io/github/actions/workflow/status/agentclientprotocol/java-sdk/interop-java.yml?branch=main&label=Java%20interop)](https://github.com/agentclientprotocol/java-sdk/actions/workflows/interop-java.yml) +[![Maven Central](https://img.shields.io/maven-metadata/v?metadataUrl=https%3A%2F%2Frepo1.maven.org%2Fmaven2%2Fcom%2Fagentclientprotocol%2Facp-core%2Fmaven-metadata.xml&label=Maven%20Central)](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) +[![Java 17+](https://img.shields.io/badge/Java-17%2B-blue)](pom.xml) + > **Documentation**: https://lab.pollack.ai/docs/acp-java-sdk | [API Reference](https://lab.pollack.ai/docs/acp-java-sdk/reference/java) | [Tutorial](https://lab.pollack.ai/docs/acp-java-sdk/tutorial) Pure Java implementation of the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) specification for building both clients and agents. @@ -20,35 +28,55 @@ Three API styles for building agents: | [**Async**](#4-hello-world-agent-async) | Reactive applications using Project Reactor `Mono` | [Jump to example](#4-hello-world-agent-async) | **Key Features:** -- Java 17+, type-safe, stdio and WebSocket transports +- Java 17+, type-safe, stdio, WebSocket and Streamable HTTP transports - Capability negotiation and structured error handling - For a hands-on walkthrough, see the **[ACP Java Tutorial](https://github.com/markpollack/acp-java-tutorial)** +## Quality + +Every gate below runs in `./mvnw verify` (or in the workflow named) and fails the build; violations +are fixed, never baselined. Each badge links to the file that enforces it. + +| Gate | What it guarantees | +|------|--------------------| +| [![NullAway](https://img.shields.io/badge/NullAway-JSpecify%20%C2%B7%20%40NullMarked-brightgreen)](pom.xml) | Every SDK package is `@NullMarked`; a possible null dereference is a compile error (JDK 21+ build, `errorprone` profile). | +| [![Error Prone](https://img.shields.io/badge/Error%20Prone-warnings%20fail-brightgreen)](pom.xml) | Error Prone's default checks, plus unused Reactor results and stray `System.out`, fail compilation. | +| [![ArchUnit](https://img.shields.io/badge/ArchUnit-every%20module%20%C2%B7%20no%20cycles-brightgreen)](acp-core/src/test/java/com/agentclientprotocol/sdk/ArchitectureTest.java) | Each module's layering is tested; no package cycles, and transports never reach session internals. | +| [![PMD](https://img.shields.io/badge/PMD-complexity%20%2B%20duplication%20%C2%B7%20no%20baseline-brightgreen)](config/pmd/ruleset.xml) | Methods stay below cognitive and cyclomatic complexity limits; copy-paste over 100 tokens fails. | +| [![SpotBugs](https://img.shields.io/badge/SpotBugs-rank%20%E2%89%A4%209%20%2B%20concurrency-brightgreen)](config/spotbugs/spotbugs-include.xml) | Every bug at rank 9 or worse, and the multithreading bug patterns at any rank, fail the build. | +| [![JaCoCo](https://img.shields.io/badge/JaCoCo-acp--core%2090%25%20line%20%C2%B7%2078%25%20branch-brightgreen)](acp-core/pom.xml) | Each module has line and branch coverage floors in its `pom.xml`; coverage cannot silently drop. | +| [![Lincheck](https://img.shields.io/badge/Lincheck-model--checked%20concurrency-brightgreen)](acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsLincheckTest.java) | The concurrent core (prompt turns, pending responses, inbound requests, SSE streams) is model-checked for linearizability. | +| [![PIT](https://img.shields.io/badge/PIT-protocol%20core%20%E2%89%A5%2085%25-brightgreen)](acp-core/pom.xml) | Mutation testing proves the protocol core's tests catch at least 85% of injected bugs (`-Ppit`, `pit.yml`). | +| [![OSV scan](https://img.shields.io/badge/OSV-dependency%20scan-brightgreen)](.github/workflows/dependency-scan.yml) | Resolved dependencies are scanned weekly and on every pom change; CVSS 7 or higher (or unscored) fails. | +| [![Cross-SDK interop](https://img.shields.io/badge/interop-TypeScript%20%C2%B7%20Rust%20%C2%B7%20Python%20%C2%B7%20Kotlin%20%C3%97%20stdio%20%C2%B7%20HTTP%20%C2%B7%20WebSocket-brightgreen)](integration-testing/README.md) | The Java client and agent talk to the other ACP SDKs over every transport each supports (Kotlin: stdio and WebSocket). | + +The interop and conformance suite is described in [`integration-testing/README.md`](integration-testing/README.md). + ## Installation ```xml com.agentclientprotocol acp-core - 0.18.0 + 0.80.0 ``` From 0.18.0, `acp-core` contains no JSON implementation. If you depend on `acp-core` alone, add one -JSON module next to it; `acp-agent-support`, `acp-test`, `acp-websocket-jetty` and +JSON module next to it; `acp-agent-support`, `acp-test` and `acp-streamable-http-jetty` already bring `acp-json-jackson2`, so users of those need nothing more: ```xml com.agentclientprotocol acp-json-jackson2 - 0.18.0 + 0.80.0 com.agentclientprotocol acp-json-jackson3 - 0.18.0 + 0.80.0 ``` @@ -58,12 +86,21 @@ Jackson 2 (-200), and an application's own supplier (default 0) wins over both. the system property `acp.json.mapper.supplier` to the supplier's class name, for example `com.agentclientprotocol.sdk.json.JacksonAcpJsonMapperSupplier`. +**Supported Jackson versions.** `acp-json-jackson2` needs Jackson **2.18.1 or later** and +`acp-json-jackson3` needs Jackson **3.0.0 or later**, for jackson-core and jackson-databind alike; +the SDK is built and released against the versions in its POM (Jackson 2.22.3 and 3.1.7). +Frameworks manage their own Jackson version: Quarkus 3.40 (Jackson 2.21.7) and Spring Boot 4.1 +(Jackson 3.1.7) are within these floors. Creating a mapper checks the versions on the classpath and +fails at once with an `IllegalStateException` that names the version found and the one required, +instead of a `NoSuchMethodError` on the first message. Keep the Jackson artifacts on one version, +for example by importing `com.fasterxml.jackson:jackson-bom` (or `tools.jackson:jackson-bom`). + For annotation-based agent development: ```xml com.agentclientprotocol acp-agent-support - 0.18.0 + 0.80.0 ``` @@ -72,11 +109,11 @@ For Streamable HTTP server support (agents accepting remote HTTP/SSE connections com.agentclientprotocol acp-streamable-http-jetty - 0.18.0 + 0.80.0 ``` -For snapshot builds (unreleased features), add the snapshot repository and use `0.19.0-SNAPSHOT`: +For snapshot builds (unreleased features), add the snapshot repository and use `0.81.0-SNAPSHOT`: ```xml @@ -106,9 +143,9 @@ import java.util.List; var params = AgentParameters.builder("gemini").arg("--experimental-acp").build(); var transport = new StdioAcpClientTransport(params); -// Create client — sessionUpdateConsumer prints the agent's streamed response +// Create client — sessionUpdateHandler prints the agent's streamed response AcpSyncClient client = AcpClient.sync(transport) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { if (notification.update() instanceof AgentMessageChunk msg) { System.out.print(((TextContent) msg.content()).text()); } @@ -139,14 +176,9 @@ import com.agentclientprotocol.sdk.agent.SyncPromptContext; import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; import com.agentclientprotocol.sdk.spec.AcpSchema.*; -@AcpAgent +@AcpAgent(name = "hello-agent", version = "1.0") class HelloAgent { - @Initialize - InitializeResponse init() { - return InitializeResponse.ok(); - } - @NewSession NewSessionResponse newSession() { return new NewSessionResponse(UUID.randomUUID().toString(), null, null); @@ -165,6 +197,10 @@ AcpAgentSupport.create(new HelloAgent()) .run(); ``` +The agent answers `initialize` with what its annotations declare: a capability for each handler +(`@LoadSession` advertises `loadSession`, and so on), `agentInfo` from `@AcpAgent`, and declared +auth methods, prompt content and MCP transports. No `@Initialize` method is needed. + ### 3. Hello World Agent (Sync) The builder API with blocking handlers and plain return values ([tutorial](https://github.com/markpollack/acp-java-tutorial/tree/main/module-13-agent-handlers)): @@ -217,6 +253,11 @@ AcpAsyncAgent agent = AcpAgent.async(transport) agent.start().then(agent.awaitTermination()).block(); ``` +`awaitTermination()` completes once the client has closed the agent's standard input and every +request received before has been answered (at most `StdioAcpAgentTransport.DEFAULT_DRAIN_TIMEOUT` +later; a request still unanswered then is answered with `-32800`), so a client that writes its +requests and closes the pipe still reads every answer. + --- ## Progressive Examples @@ -255,7 +296,7 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { **Client - receiving updates:** ```java AcpSyncClient client = AcpClient.sync(transport) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { var update = notification.update(); if (update instanceof AgentMessageChunk msg) { System.out.print(((TextContent) msg.content()).text()); @@ -275,6 +316,10 @@ AcpSyncAgent agent = AcpAgent.sync(transport) // Convenience methods on SyncPromptContext String content = context.readFile("pom.xml"); context.writeFile("output.txt", "Hello!"); + // The raw ACP requests are on context.client() + String firstLines = context.client() + .readTextFile(new ReadTextFileRequest(context.getSessionId(), "pom.xml", 1, 10)) + .content(); return PromptResponse.endTurn(); }) .build(); @@ -301,9 +346,21 @@ AcpSyncClient client = AcpClient.sync(transport) Check what features the peer supports before using them ([tutorial](https://github.com/markpollack/acp-java-tutorial/tree/main/module-17-capability-negotiation)): +A client builder without `clientCapabilities(..)` advertises what its handlers serve: +`fs.readTextFile` and `fs.writeTextFile` for their handlers, `terminal` once all five terminal +handlers are registered, and form-mode elicitation for an elicitation handler. Set +`clientCapabilities(..)` for what handlers cannot say (URL-mode elicitation, boolean config +options, `_meta`); explicit capabilities replace the derived ones, and `build()` fails if one is +advertised without its handler: +`ClientCapabilities.builder().readTextFile().writeTextFile().session(ClientSessionCapabilities.withBooleanConfigOptions()).build()`. + ```java -// Client: check agent capabilities after initialize -client.initialize(new InitializeRequest(1, clientCaps)); +// Client: the handlers advertise the file capabilities, check the agent's after initialize +AcpSyncClient client = AcpClient.sync(transport) + .readTextFileHandler(req -> new ReadTextFileResponse(read(req.path()))) + .writeTextFileHandler(req -> write(req.path(), req.content())) + .build(); +client.initialize(); NegotiatedCapabilities agentCaps = client.getAgentCapabilities(); if (agentCaps.supportsLoadSession()) { @@ -330,17 +387,20 @@ agent.writeTextFile(...); ### 8. Error Handling -Handle protocol errors with structured exceptions ([tutorial](https://github.com/markpollack/acp-java-tutorial/tree/main/module-11-error-handling)): +A request that fails with a JSON-RPC error code throws `AcpError`; a handler answers with an error by +throwing `AcpProtocolException`. Handle protocol errors with structured exceptions ([tutorial](https://github.com/markpollack/acp-java-tutorial/tree/main/module-11-error-handling)): ```java import com.agentclientprotocol.sdk.error.*; +import com.agentclientprotocol.sdk.spec.AcpError; try { client.prompt(request); -} catch (AcpProtocolException e) { - if (e.isConcurrentPrompt()) { - // Another prompt is already in progress - } else if (e.isMethodNotFound()) { +} catch (AcpError e) { + // The agent answered with an error, or with a response the SDK rejected (see getData()) + if (e.getCode() == AcpErrorCodes.AUTHENTICATION_REQUIRED) { + // The agent wants authenticate() first + } else if (e.getCode() == AcpErrorCodes.METHOD_NOT_FOUND) { // Agent doesn't support this method } System.err.println("Error " + e.getCode() + ": " + e.getMessage()); @@ -382,8 +442,11 @@ var server = new StreamableHttpAcpAgentTransport(8080, AcpJsonMapper.createDefau server.start().block(); // ws://localhost:8080/acp and http://localhost:8080/acp ``` -`WebSocketAcpAgentTransport` in `acp-websocket-jetty` is deprecated for removal: it serves a single -client. Existing `WebSocketAcpClientTransport` clients connect to the transport above unchanged. +The listener binds the loopback interface (`127.0.0.1` and `::1`) by default and has no +authentication of its own, so only programs on the same machine can reach it. Exposing it is an +explicit opt-in, `StreamableHttpAcpAgentTransportOptions.builder().host("0.0.0.0")`; put a proxy +that authenticates in front. Browser requests whose `Origin` is not a loopback one are refused with +403 unless listed with `allowedOrigins(...)`. --- @@ -393,13 +456,20 @@ client. Existing `WebSocketAcpClientTransport` clients connect to the transport | Package | Description | |---------|-------------| -| `com.agentclientprotocol.sdk.spec` | Protocol types (`AcpSchema.*`) | +| `com.agentclientprotocol.sdk.spec` | Protocol types (`AcpSchema.*`), the JSON-RPC sessions and the transport contracts (`AcpClientTransport`, `AcpAgentTransport`) | | `com.agentclientprotocol.sdk.client` | Client SDK (`AcpClient`, `AcpAsyncClient`, `AcpSyncClient`) | -| `com.agentclientprotocol.sdk.agent` | Agent SDK (`AcpAgent`, `AcpAsyncAgent`, `AcpSyncAgent`) | -| `com.agentclientprotocol.sdk.agent.support` | Annotation-based agent runtime (`AcpAgentSupport`) | +| `com.agentclientprotocol.sdk.client.transport` | Client transports (`StdioAcpClientTransport`, `WebSocketAcpClientTransport`, `StreamableHttpAcpClientTransport`) | +| `com.agentclientprotocol.sdk.agent` | Agent SDK (`AcpAgent`, `AcpAsyncAgent`, `AcpSyncAgent`, `AcpAgentFactory`) | +| `com.agentclientprotocol.sdk.agent.transport` | Agent transports (`StdioAcpAgentTransport`; `StreamableHttpAcpServlet` from acp-http-servlet; `StreamableHttpAcpAgentTransport` from acp-streamable-http-jetty; `StreamableHttpAcpAgentTransportOptions` from acp-http-core) | +| `com.agentclientprotocol.sdk.http.webflux` | The Spring WebFlux host from acp-http-webflux (`AcpWebFluxHost`) | +| `com.agentclientprotocol.sdk.http.server` | The host contract from acp-http-core (`AcpHttpEndpoint`, `AcpHttpExchange`, `AcpHttpReply`, `AcpWsHandshake`), for writing a server adapter; unstable | +| `com.agentclientprotocol.sdk.agent.support` | Annotation-based agent runtime (`AcpAgentSupport`), with its `resolver`, `handler`, `interceptor` and `invocation` extension points | | `com.agentclientprotocol.sdk.annotation` | Agent annotations (`@AcpAgent`, `@Prompt`, etc.) | | `com.agentclientprotocol.sdk.capabilities` | Capability negotiation (`NegotiatedCapabilities`) | | `com.agentclientprotocol.sdk.error` | Exceptions (`AcpProtocolException`, `AcpCapabilityException`) | +| `com.agentclientprotocol.sdk.json` | JSON mapping abstraction (`AcpJsonMapper`, `TypeRef`); the Jackson 2 implementation (`JacksonAcpJsonMapper`) from acp-json-jackson2 | +| `com.agentclientprotocol.sdk.json.jackson3` | Jackson 3 implementation (`Jackson3AcpJsonMapper`) from acp-json-jackson3 | +| `com.agentclientprotocol.sdk.test` | Test support from acp-test (`InMemoryTransportPair`, `MockAcpAgent`, `MockAcpClient`) | ### Maven Artifacts @@ -408,11 +478,16 @@ client. Existing `WebSocketAcpClientTransport` clients connect to the transport | [`acp-core`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core) | Client and Agent SDKs, stdio, WebSocket, and Streamable HTTP client transports; needs one JSON module | | `acp-json-jackson2` | JSON implementation on Jackson 2 (`JacksonAcpJsonMapper`); brought in by the transport and agent-support modules | | `acp-json-jackson3` | JSON implementation on Jackson 3 (`Jackson3AcpJsonMapper`) | -| `acp-streamable-http-jetty` | Jetty-backed Streamable HTTP agent transport for listener-backed remote agents | +| `acp-http-core` | The framework-neutral ACP endpoint behind every HTTP host: routing, sessions, SSE mailboxes, the status and close-code table, the `Origin` check and shutdown, and the host contract (`com.agentclientprotocol.sdk.http.server`) | +| `acp-http-servlet` | The endpoint as a Servlet 6 servlet, `StreamableHttpAcpServlet`, with WebSocket upgrades on the same path through Jakarta WebSocket 2.1: Spring MVC, Tomcat, Jetty, Undertow | +| `acp-http-webflux` | The endpoint as a Spring WebFlux `RouterFunction`, `AcpWebFluxHost`: Streamable HTTP and WebSocket on one path of a WebFlux application's own server (Reactor Netty, Tomcat, Jetty) | +| `acp-streamable-http-jetty` | The SDK's own listener, `StreamableHttpAcpAgentTransport`: embedded Jetty 12.1 running the servlet, for agents served from a plain Java program | | [`acp-annotations`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-annotations) | `@AcpAgent`, `@Prompt`, and other annotations | | [`acp-agent-support`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-agent-support) | Annotation-based agent runtime | | [`acp-test`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-test) | In-memory transport and mock utilities for testing | -| [`acp-websocket-jetty`](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-websocket-jetty) | **Deprecated** single-client WebSocket agent transport; use `acp-streamable-http-jetty` | +| `acp-spring-boot-starter` | Spring Boot 4.1 starter: serves an `@AcpAgent` bean over stdio, Streamable HTTP or WebSocket, and builds a client from `spring.acp.client.*`; the autoconfiguration is `acp-spring-boot-autoconfigure` ([README](acp-spring-boot-autoconfigure/README.md)) | +| `acp-micronaut` | Micronaut 4 integration: serves an `@AcpAgent` bean over stdio, Streamable HTTP or WebSocket, and builds a client from `acp.client.*` ([README](acp-micronaut/README.md)) | +| `acp-quarkus` | Quarkus extension: an `@AcpAgent` bean over stdio, or Streamable HTTP and WebSocket on the Quarkus HTTP server; client beans from `quarkus.acp.*` ([README](acp-quarkus/README.md)) | ### Transports @@ -421,6 +496,22 @@ client. Existing `WebSocketAcpClientTransport` clients connect to the transport | Stdio | `StdioAcpClientTransport` | `StdioAcpAgentTransport` | acp-core | | WebSocket | `WebSocketAcpClientTransport` | `StreamableHttpAcpAgentTransport` (upgrade on the same path) | acp-core / acp-streamable-http-jetty | | Streamable HTTP | `StreamableHttpAcpClientTransport` | `StreamableHttpAcpAgentTransport` | acp-core / acp-streamable-http-jetty | +| Streamable HTTP and WebSocket in your own Servlet 6 container | `StreamableHttpAcpClientTransport`, `WebSocketAcpClientTransport` | `StreamableHttpAcpServlet` (WebSocket where the container has Jakarta WebSocket 2.1) | acp-core / acp-http-servlet | +| Streamable HTTP and WebSocket in a Spring WebFlux application | `StreamableHttpAcpClientTransport`, `WebSocketAcpClientTransport` | `AcpWebFluxHost` (a `RouterFunction`) | acp-core / acp-http-webflux | + +#### Logging and sensitive data + +Two of the SDK's loggers can carry message content or other sensitive data: + +- **The agent's standard error.** `StdioAcpClientTransport` logs every line the agent process + writes to standard error at INFO on the logger `com.agentclientprotocol.sdk.client.transport.agent-stderr`, + unfiltered. An agent's diagnostics can carry sensitive data (paths, prompts, account details). + Silence it by setting that logger's level to `WARN` or `OFF`, or route the lines yourself with + `setStdErrorHandler`. +- **DEBUG and TRACE.** At DEBUG and TRACE, loggers under `com.agentclientprotocol.sdk` log message + payloads in full: the stdio client's sent and received lines, the sessions' messages, and the + session updates the framework integrations log by default. Do not enable them where logs are + shipped or retained. --- @@ -433,6 +524,18 @@ client. Existing `WebSocketAcpClientTransport` clients connect to the transport ./mvnw install # Install to local Maven repository ``` +### Coverage + +`./mvnw verify` writes a JaCoCo coverage report for each module to +`/target/site/jacoco/index.html`, and fails if a module's line or branch coverage drops +below the floor declared in its `pom.xml` (measured coverage less 2 points). Each module counts +only its own classes, covered by its own tests. A module whose JDK 21 build covers more (its +virtual-thread code runs only there) also declares JDK 21 floors, which a JDK 21 build checks +instead. The classes that choose between virtual threads +(JDK 21 and later) and platform threads (JDK 17) must have every branch covered on whichever JDK +builds, so CI's JDK 17 and JDK 21 builds each prove both choices. When running a subset of tests +under `verify` (`-Dtest=...`), add `-Djacoco.skip` to skip the gate. + ### Integration Tests Integration tests connect to real ACP agents and require additional setup: @@ -450,6 +553,22 @@ export GEMINI_API_KEY=your_key_here | Clean shutdown IT | `./mvnw verify` | 4 | None | | Gemini CLI IT | `./mvnw verify` | 5 | `GEMINI_API_KEY`, `gemini` CLI in PATH | +### Mutation Testing + +The `pit` profile runs [PIT](https://pitest.org) mutation testing over acp-core's protocol core +(the client and agent session classes, their shared request/response plumbing, and the `json` +package). It is not part of the default build: + +```bash +./mvnw -Ppit -pl acp-core -am test # DEFAULTS mutators, about 3 minutes +./mvnw -Ppit -pl acp-core -am test -Dpit.mutators=STRONGER # larger mutator set +./mvnw -Ppit -pl acp-core -am test -Dpit.threads=8 # more threads on a larger machine +``` + +The report is written to `acp-core/target/pit-reports/index.html`. The run fails if the mutation +score drops below the profile's `mutationThreshold`. The `Mutation testing (PIT)` workflow runs the +profile on demand and uploads the report. + ## Testing Your Code Use the mock utilities for testing: @@ -494,7 +613,21 @@ If you need a stable target, pin to an exact version. ## Releases -### 0.18.0 (Current — [Maven Central](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core)) +A release candidate must have a green [framework smoke matrix](integration-testing/README.md#framework-smoke-matrix) +(`framework-smoke.yml`: Spring Boot, Micronaut and Quarkus against the TypeScript SDK) on the +commit being released before `release.yml` is dispatched. + +### 0.80.0 (Current — [Maven Central](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core)) + +Every stable ACP v1 method and type on both client and agent, with an annotation model that +derives what an agent advertises from its handlers. Spring Boot, Quarkus and Micronaut +integrations on one framework-neutral module (`acp-integration`). Streamable HTTP and WebSocket +served by the application's own server (servlet, WebFlux, Quarkus) or by the SDK's listener, which +binds loopback by default; browser origins are checked, and idle or never-initialized WebSockets +are closed. This release breaks the API in places: [CHANGELOG.md](CHANGELOG.md) has every break, +its migration and the known issues. + +### 0.18.0 ([Maven Central](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core)) Remote agents: the Streamable HTTP and WebSocket transport from the ACP RFD, contributed by @kamikaz1k, on plain `http://` (HTTP/2 over cleartext) and `https://`, with a new @@ -536,7 +669,7 @@ New stable methods: `logout`, `session/delete`; the session config-option API (` New unstable methods (marked `@UnstableAcpApi`): provider configuration — `providers/list`, `providers/set`, `providers/disable`. -Also: `additionalDirectories` workspace roots on session requests; per-chunk `messageId` on streamed chunks with `sendMessage`/`sendThought` overloads. The session-model API (`session/set_model`) is deprecated for removal — use `session/set_config_option` with a `"model"` category instead. Jackson aligned to 2.21.2; WebSocket max message size raised to 4 MB. +Also: `additionalDirectories` workspace roots on session requests; per-chunk `messageId` on streamed chunks with `sendMessage`/`sendThought` overloads. The session-model API is deprecated for removal — use `session/set_config_option` with a `"model"` category instead. Jackson aligned to 2.21.2; WebSocket max message size raised to 4 MB. ### 0.12.0 ([Maven Central](https://central.sonatype.com/artifact/com.agentclientprotocol/acp-core)) diff --git a/acp-agent-support/README.md b/acp-agent-support/README.md index daec2333..59ea95c4 100644 --- a/acp-agent-support/README.md +++ b/acp-agent-support/README.md @@ -5,14 +5,9 @@ The `acp-agent-support` module provides an annotation-based programming model fo ## Quick Start ```java -@AcpAgent +@AcpAgent(name = "my-agent", version = "1.0.0") class MyAgent { - @Initialize - InitializeResponse init() { - return InitializeResponse.ok(); - } - @NewSession NewSessionResponse newSession(NewSessionRequest req) { return new NewSessionResponse("session-" + UUID.randomUUID(), null, null); @@ -21,16 +16,20 @@ class MyAgent { @Prompt PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ctx.sendMessage("Processing your request..."); - return PromptResponse.text("Done!"); + ctx.sendMessage("Done!"); + return PromptResponse.endTurn(); } } // Bootstrap and run AcpAgentSupport.create(new MyAgent()) - .transport(StdioAcpAgentTransport.create()) + .transport(new StdioAcpAgentTransport()) .run(); ``` +No `@Initialize` method is needed: the agent answers `initialize` with what its annotations declare +(see [Advertising Capabilities](#advertising-capabilities)). + ## Installation Add the dependency to your `pom.xml`: @@ -51,32 +50,105 @@ This module transitively includes `acp-annotations` and `acp-core`. | Annotation | Description | |------------|-------------| -| `@AcpAgent` | Marks a class as an ACP agent. Required on all agent classes. Optional `name` and `version` attributes. | +| `@AcpAgent` | Marks a class as an ACP agent. Required on the agent class or a superclass of it: handlers are discovered on the class and its superclasses, so a framework proxy of the bean (Spring CGLIB, Quarkus ArC, Micronaut AOP) is accepted and invoked through the proxy. Its attributes are advertised in the `initialize` response: `name`, `version` and `title` as `agentInfo` (the name defaults to the class's simple name, the version to the jar manifest's `Implementation-Version`, else `"unknown"`), `authMethods` (`@AuthMethod`s) as `authMethods`, and `mcpHttp`/`mcpSse` as `mcpCapabilities`. | +| `@AuthMethod` | One authentication method inside `@AcpAgent(authMethods = ...)`: `id`, `name`, `description`, and `type` `AGENT` (served by `@Authenticate`) or `TERMINAL` (the client reruns the agent program with `args` and `env`). | ### Handler Methods -| Annotation | JSON-RPC Method | Description | -|------------|-----------------|-------------| -| `@Initialize` | `initialize` | Handles protocol initialization and capability negotiation. | -| `@NewSession` | `session/new` | Creates a new agent session. | -| `@LoadSession` | `session/load` | Loads an existing session by ID. | -| `@Prompt` | `session/prompt` | Handles user prompts within a session. | -| `@SetSessionMode` | `session/set_mode` | Changes the operational mode of a session. | -| `@SetSessionModel` | `session/set_model` | Changes the AI model used for a session. | -| `@Cancel` | `session/cancel` | Handles cancellation notifications (fire-and-forget). | +| Annotation | JSON-RPC Method | Advertises | Description | +|------------|-----------------|------------|-------------| +| `@Initialize` | `initialize` | (the exchange itself) | Optional. Without one, the agent answers with the response derived from its annotations; with one, the method's response is laid over the derived one (see [Advertising Capabilities](#advertising-capabilities)). | +| `@Authenticate` | `authenticate` | `authMethods`, from `@AcpAgent(authMethods = ...)` | Authenticates with one of the agent methods the agent advertised. | +| `@Logout` | `logout` | `auth.logout` | Clears stored credentials. | +| `@NewSession` | `session/new` | nothing (baseline) | Creates a new agent session. Without one, the agent answers with a random session ID. | +| `@LoadSession` | `session/load` | `loadSession` | Loads an existing session by ID, replaying its history. | +| `@ResumeSession` | `session/resume` | `sessionCapabilities.resume` | Reconnects to an existing session without replaying history. | +| `@ListSessions` | `session/list` | `sessionCapabilities.list` | Lists sessions, optionally filtered by working directory. | +| `@CloseSession` | `session/close` | `sessionCapabilities.close` | Closes an active session. | +| `@DeleteSession` | `session/delete` | `sessionCapabilities.delete` | Permanently deletes a stored session. | +| `@ForkSession` | `session/fork` | `sessionCapabilities.fork` | Creates a session branched from an existing one (unstable, `@UnstableAcpApi`). | +| `@Prompt` | `session/prompt` | `promptCapabilities`, from its `image`, `audio` and `embeddedContext` attributes | Handles user prompts within a session. | +| `@SetSessionMode` | `session/set_mode` | nothing (modes are offered per session) | Changes the operational mode of a session. Needs a `@NewSession` method that returns the modes. | +| `@SetSessionConfigOption` | `session/set_config_option` | nothing (options are offered per session) | Changes a session configuration option. Needs a `@NewSession` method that returns the options. | +| `@ListProviders` | `providers/list` | `providers` | Lists the providers the agent can route to (unstable). | +| `@SetProvider` | `providers/set` | `providers` | Configures a provider (unstable). | +| `@DisableProvider` | `providers/disable` | `providers` | Disables a provider (unstable). | +| `@Cancel` | `session/cancel` | nothing (baseline) | Handles cancellation notifications (fire-and-forget). The cancel does not end the prompt turn: the cancelled `@Prompt` method still returns, with stop reason `cancelled`, and the session rejects a new prompt until it has, or until the cancel grace period (default 60 s, `cancelGracePeriod`) passes and the agent answers `cancelled` itself. | +| `@ExtRequest("_name")` | any `_`-prefixed request | nothing (agreed outside the protocol) | Serves a custom extension request; the name must start with `_`. | +| `@ExtNotification("_name")` | any `_`-prefixed notification | nothing | Handles a custom extension notification; the name must start with `_`. | + +A method without a handler for it is answered with `-32601` (Method not found); a notification +without one is ignored. ### Parameter Annotations | Annotation | Description | |------------|-------------| -| `@SessionId` | Injects the current session ID as a `String`. | -| `@SessionState` | Injects session-specific state (placeholder for future). | +| `@SessionId` | Injects the current session ID as a `String`, in handlers of session-scoped methods. | +| `@ConfigId` | In a `@SetSessionConfigOption` method, injects the id of the option being set as a `String`. | +| `@ConfigValue` | In a `@SetSessionConfigOption` method, injects the value being set, typed by the parameter: `String` (a select option), `boolean`/`Boolean` (a boolean option) or `Object` (either). A value of the other kind is answered `-32602` without calling the method. | -### Exception Handling +## Advertising Capabilities -| Annotation | Description | -|------------|-------------| -| `@AcpExceptionHandler` | Marks a method as an exception handler (runtime support pending). | +A client learns what an agent supports from the agent's `initialize` response, and only uses what +is advertised there: a client that is not told `loadSession` never calls `session/load`. An annotated +agent's response is derived from its class, so every handler it has is advertised: + +```java +@AcpAgent(name = "notes-agent", version = "1.2.0", mcpHttp = true, + authMethods = { + @AuthMethod(id = "api-key", name = "API key", description = "Uses NOTES_API_KEY"), + @AuthMethod(id = "login", name = "Log in", type = AuthMethod.Type.TERMINAL, args = "--login") }) +class NotesAgent { + + @Authenticate AuthenticateResponse authenticate(AuthenticateRequest req) { ... } + @Logout LogoutResponse logout() { ... } + @LoadSession LoadSessionResponse load(LoadSessionRequest req) { ... } + @ListSessions ListSessionsResponse list(ListSessionsRequest req) { ... } + + @Prompt(image = true, embeddedContext = true) + PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ... } +} +``` + +answers `initialize` with + +```json +{ + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "sessionCapabilities": { "list": {} }, + "mcpCapabilities": { "http": true, "sse": false }, + "promptCapabilities": { "audio": false, "embeddedContext": true, "image": true }, + "auth": { "logout": {} } + }, + "authMethods": [ + { "id": "api-key", "name": "API key", "description": "Uses NOTES_API_KEY" }, + { "id": "login", "name": "Log in", "args": ["--login"], "type": "terminal" } + ], + "agentInfo": { "name": "notes-agent", "version": "1.2.0" } +} +``` + +- **Capabilities** come from the handler annotations, as the *Advertises* column of the + [handler table](#handler-methods) lists. A test fails the build if a handler annotation has no + declared mapping. +- **Prompt content and MCP transports** the agent accepts are declared, default false: + `@Prompt(image, audio, embeddedContext)` and `@AcpAgent(mcpHttp, mcpSse)`. +- **Auth methods** are declared with `@AcpAgent(authMethods = @AuthMethod(...))`. An `AGENT` method + needs an `@Authenticate` handler (the builder fails otherwise); a `TERMINAL` method is advertised + only to a client that announced `clientCapabilities.auth.terminal`, as ACP requires. +- **`agentInfo`** is `@AcpAgent`'s `name`, `version` and `title`. +- **`protocolVersion`** is the client's when the SDK speaks it, otherwise the latest the SDK speaks. + +**With an `@Initialize` method**, the derived response is the base and the method's response is laid +over it: a capability is advertised when either side advertises it (so `return InitializeResponse.ok()` +keeps every derived capability, and a handler's capability cannot be withdrawn: remove the handler); +the returned `authMethods` follow the derived ones, replacing any with the same id; the returned +`protocolVersion`, and the returned `agentInfo` and `_meta` when not null, win. Use it to read the +client's request, or to advertise what annotations cannot express, such as +`sessionCapabilities.additionalDirectories`. ## Handler Method Signatures @@ -86,16 +158,29 @@ Handler methods support flexible signatures. The runtime automatically resolves | Parameter Type | Source | |----------------|--------| -| `InitializeRequest` | The raw initialize request (in `@Initialize` handlers). | -| `NewSessionRequest` | The raw new session request (in `@NewSession` handlers). | -| `LoadSessionRequest` | The raw load session request (in `@LoadSession` handlers). | -| `PromptRequest` | The raw prompt request (in `@Prompt` handlers). | -| `SetSessionModeRequest` | The raw set mode request (in `@SetSessionMode` handlers). | -| `SetSessionModelRequest` | The raw set model request (in `@SetSessionModel` handlers). | -| `CancelNotification` | The raw cancel notification (in `@Cancel` handlers). | -| `SyncPromptContext` | Synchronous context for sending messages, file I/O, permissions, etc. | -| `NegotiatedCapabilities` | The capabilities negotiated with the client. | +| The method's request type | The raw request or notification, in its handler: `InitializeRequest`, `AuthenticateRequest`, `LogoutRequest`, `NewSessionRequest`, `LoadSessionRequest`, `ResumeSessionRequest`, `ListSessionsRequest`, `CloseSessionRequest`, `DeleteSessionRequest`, `ForkSessionRequest`, `PromptRequest`, `SetSessionModeRequest`, `SetSessionConfigOptionRequest`, `ListProvidersRequest`, `SetProviderRequest`, `DisableProviderRequest`, `CancelNotification`. | +| Any type the JSON mapper can read | The params of an `@ExtRequest` or `@ExtNotification` (at most one such parameter). | +| `SyncPromptContext` | Synchronous context for sending messages, file I/O, permissions, etc. (in `@Prompt` handlers). | +| `PromptContext` | The async context the sync one wraps, for a `@Prompt` handler that composes `Mono`s. | | `@SessionId String` | The current session ID. | +| `@ConfigId String`, `@ConfigValue String/boolean/Object` | The option id and typed value, in a `@SetSessionConfigOption` handler. | +| `NegotiatedCapabilities` | The capabilities negotiated with the client on the request's connection (any handler). | +| `AcpSyncAgent` / `AcpAsyncAgent` | The agent serving the request's connection (any handler). | + +Every handler, extension handlers included, can take `NegotiatedCapabilities`, `AcpSyncAgent` or +`AcpAsyncAgent`, resolved for the connection the request arrived on. The capabilities are what the +client offered in its `initialize` request, so they are available from the `@Initialize` handler +on. Under `buildFactory()`, one handler bean serves every connection, so a bean cannot keep "its" +agent in a field; take it as a parameter instead, for example to push changed config options +outside a prompt turn: + +```java +@ExtNotification("_example.com/models_changed") +void modelsChanged(ModelsChanged event, AcpSyncAgent agent) { + agent.sendSessionUpdate(event.sessionId(), + new ConfigOptionUpdate(configOptions(event.models()))); +} +``` ### Example Handler Signatures @@ -116,7 +201,7 @@ PromptResponse answer(PromptRequest req, SyncPromptContext ctx) { ... } PromptResponse answer(SyncPromptContext ctx, @SessionId String sessionId) { ... } @Prompt -String simpleAnswer(PromptRequest req) { ... } // Converted to PromptResponse +String simpleAnswer(PromptRequest req) { ... } // Sent as an agent message chunk; the turn ends end_turn @Prompt void streamingAnswer(PromptRequest req, SyncPromptContext ctx) { ... } // Returns endTurn() @@ -131,15 +216,29 @@ The runtime automatically converts return values to protocol response types: | Return Type | Conversion | |-------------|------------| -| `InitializeResponse` | Passed through directly. | -| `NewSessionResponse` | Passed through directly. | -| `LoadSessionResponse` | Passed through directly. | -| `PromptResponse` | Passed through directly. | -| `SetSessionModeResponse` | Passed through directly. | -| `SetSessionModelResponse` | Passed through directly. | -| `String` | Converted to `PromptResponse.text(value)`. | -| `void` | Converted to `PromptResponse.endTurn()`. | -| `Mono` | Unwrapped and returned (for async handlers). | +| The method's response type (`InitializeResponse`, `NewSessionResponse`, `PromptResponse`, ...) | Passed through directly. | +| `Mono`, `CompletionStage` (`CompletableFuture`) or single-value `Publisher` of the response type (or of `String` for `@Prompt`) | Awaited on the handler thread; the value is then handled as if returned directly. No value (an empty `Mono` or `Publisher`, or a stage that completes with `null`) gives no response, so the request is answered `-32603`, even for `@Prompt`, where a `String` method returning `null` ends the turn. | +| `String` (`@Prompt` only) | Sent to the client as an `agent_message_chunk` session update, then the turn ends (`PromptResponse.endTurn()`). | +| `void` (`@Prompt` only) | Converted to `PromptResponse.endTurn()`. | +| `void` (`@Cancel`, `@ExtNotification`) | Notifications have no response. | +| Any value the JSON mapper can write (`@ExtRequest`) | Sent as the extension request's result. | + +A request handler other than `@Prompt` declared `void`, or declared to return a type that cannot +give its response, is rejected when the agent is built; one that returns `null` (or an empty `Mono`) +is answered with `-32603` (Internal error): a request must get a result. + +### Mistakes Fail Early + +Annotation misuse fails when the agent is registered or built, with a message naming the class, the +method and the fix, never at the first request: two methods for one annotation, two handler +annotations on one method, a parameter no resolver supplies or that does not suit the method (the +request type of another method, `@SessionId` where there is no session, a prompt context outside +`@Prompt`, `@ConfigValue` outside `@SetSessionConfigOption`), and a return type that cannot give the +method's response. The one exception is the extension methods: on an `@ExtRequest` or `@ExtNotification` +method every parameter that is not a connection parameter is read from the params, so a prompt +context there is not rejected when the agent is built; instead every `@ExtRequest` call is answered +`-32602` (invalid params). Parameters and return types that a custom `ArgumentResolver` or +`ReturnValueHandler` supports are left to it. ## Using SyncPromptContext @@ -166,12 +265,12 @@ PromptResponse handle(PromptRequest req, SyncPromptContext ctx) { // Ask for user permission boolean allowed = ctx.askPermission("Delete all files in /tmp?"); - // Multiple choice - String choice = ctx.askChoice("Which format?", "JSON", "XML", "YAML"); + // Multiple choice (empty if the user cancelled) + Optional choice = ctx.askChoice("Which format?", "JSON", "XML", "YAML"); // Execute terminal commands (requires client capabilities) CommandResult result = ctx.execute("ls", "-la"); - if (result.exitCode() == 0) { + if (result.success()) { ctx.sendMessage("Output: " + result.output()); } @@ -179,6 +278,50 @@ PromptResponse handle(PromptRequest req, SyncPromptContext ctx) { } ``` +The raw ACP requests sit one step down, on `ctx.client()`: `readTextFile`, `writeTextFile`, +`requestPermission`, the five terminal calls and the elicitation calls, each taking the ACP request +record. Use them for what the convenience calls do not cover, for example a line range or a +permission request for a tool call you announced yourself: + +```java +String head = ctx.client() + .readTextFile(new ReadTextFileRequest(ctx.getSessionId(), "/path/to/file.txt", 1, 20)) + .content(); +``` + +### Cancellation + +A prompt method learns that its prompt was cancelled from its context, whether by `session/cancel` +for its session or by `$/cancel_request` for its request; no `@Cancel` handler or shared state is +needed: + +```java +@Prompt +PromptResponse handle(PromptRequest req, SyncPromptContext ctx) { + for (Step step : plan(req)) { + if (ctx.isCancelled()) { + ctx.sendMessage("Stopped."); + return PromptResponse.cancelled(); + } + step.run(ctx); + } + return PromptResponse.endTurn(); +} + +@Prompt +Mono handle(PromptRequest req, PromptContext ctx) { + return work(req, ctx) + .takeUntilOther(ctx.whenCancelled()) + .defaultIfEmpty(PromptResponse.cancelled()); +} +``` + +`SyncPromptContext.onCancel(Runnable)` runs an action once on cancel, for work that cannot poll +(a subprocess, an HTTP call). After `session/cancel` the method must answer `cancelled` within the +cancel grace period (`cancelGracePeriod`, default 60 s); once it passes the agent answers +`cancelled` itself and interrupts the method's thread. After `$/cancel_request` the agent has already +answered (`-32800`, or `cancelled` if the session was cancelled too), so the method just stops. + ## Interceptors Interceptors allow cross-cutting concerns like logging, metrics, or error handling: @@ -201,12 +344,15 @@ public class LoggingInterceptor implements AcpInterceptor { @Override public Object onError(AcpInvocationContext context, Throwable error) { log.error("Error in {}: {}", context.getAcpMethod(), error.getMessage()); - return null; // Return null to re-throw, or return a replacement value + // Return null to keep the failure, return a replacement response, or throw an + // AcpProtocolException to answer with that JSON-RPC error instead + return null; } @Override - public void afterCompletion(AcpInvocationContext context, Throwable error) { - // Always called, even if exceptions occur + public void afterCompletion(AcpInvocationContext context, Throwable ex) { + // Called once for each call whose preInvoke returned true, however it ended; + // ex is what the call failed with, or null } @Override @@ -236,7 +382,7 @@ public class UserResolver implements ArgumentResolver { @Override public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { - String sessionId = context.getSessionId(); + String sessionId = context.getSessionId().orElseThrow(); return userService.findBySession(sessionId); } } @@ -250,28 +396,42 @@ AcpAgentSupport.create(new MyAgent()) ## Custom Return Value Handlers -Handle custom return types: +The built-in handlers already accept a handler method's response itself, a `Mono`, a +`CompletionStage` (such as a `CompletableFuture`) and a single-value `Publisher`, and a `String` +from a `@Prompt` method. Register a handler for another return type. Custom handlers are asked +before the built-in ones, so a handler that also claims a built-in type replaces it: keep +`supportsReturnType` to the type you add. For example, a plain `Future`, as an `ExecutorService` +returns: ```java -public class CompletableFutureHandler implements ReturnValueHandler { +public class FutureHandler implements ReturnValueHandler { @Override public boolean supportsReturnType(AcpMethodParameter returnType) { - return CompletableFuture.class.isAssignableFrom(returnType.getParameterType()); + // Future itself only: a CompletableFuture stays with the built-in handler + return returnType.getParameterType() == Future.class; } @Override public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { - CompletableFuture future = (CompletableFuture) returnValue; - return future.join(); // Block and return result + try { + return ((Future) returnValue).get(); // the method's response, such as a PromptResponse + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CancellationException("Interrupted while waiting for the handler's result"); + } + catch (ExecutionException e) { + throw new ReturnValueHandlingException("The handler's Future failed", e.getCause()); + } } } // Register handler AcpAgentSupport.create(new MyAgent()) .transport(transport) - .returnValueHandler(new CompletableFutureHandler()) + .returnValueHandler(new FutureHandler()) .build(); ``` @@ -281,24 +441,30 @@ AcpAgentSupport.create(new MyAgent()) ```java AcpAgentSupport.create(new MyAgent()) - .transport(StdioAcpAgentTransport.create()) + .transport(new StdioAcpAgentTransport()) .run(); ``` -### WebSocket Transport +### Remote Transport (Streamable HTTP and WebSocket) + +`StreamableHttpAcpAgentTransport` (module `acp-streamable-http-jetty`) serves the Streamable HTTP +profile and the WebSocket upgrade on one path and creates one agent per connection, which +`buildFactory()` provides: ```java -WebSocketAcpAgentTransport transport = WebSocketAcpAgentTransport.builder() - .host("localhost") - .port(8080) - .path("/acp") - .build(); +AcpAgentFactory agents = AcpAgentSupport.create(new MyAgent()).buildFactory(); -AcpAgentSupport.create(new MyAgent()) - .transport(transport) - .run(); +var server = new StreamableHttpAcpAgentTransport(8080, AcpJsonMapper.createDefault(), agents); +server.start().block(); // http://localhost:8080/acp and ws://localhost:8080/acp ``` +Every connection's agent invokes the same `MyAgent` instance, the way every request to a Spring +controller reaches one bean: its handler methods run concurrently across connections and sessions, +so they must be thread-safe, with per-session state keyed by session id. Port `0` listens on an +ephemeral port; `server.getPort()` returns it once started. The listener binds the loopback +interface only unless `StreamableHttpAcpAgentTransportOptions.builder().host(...)` says otherwise, +and refuses browser requests from foreign origins. + ### InMemory Transport (For Testing) ```java @@ -320,12 +486,18 @@ AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); AcpAgentSupport.create(agentInstance) // Start with agent instance .transport(transport) // Required: set transport .requestTimeout(Duration.ofSeconds(60)) // Optional: request timeout (default: 30s) + .cancelGracePeriod(Duration.ofSeconds(10)) // Optional: time a cancelled prompt has to return (default: 60s) + .maxPromptDuration(Duration.ofMinutes(5)) // Optional: longest a prompt may run (default: none) .interceptor(interceptor) // Optional: add interceptor .argumentResolver(resolver) // Optional: add custom resolver .returnValueHandler(handler) // Optional: add custom handler .build(); // Build the support instance ``` +A builder can be built more than once: `build()` and `buildFactory()` add the default resolvers +and return value handlers after the custom ones at build time without changing the builder, and +later builder changes do not affect what was already built. + ### Alternative Creation Methods ```java @@ -333,9 +505,12 @@ AcpAgentSupport.create(agentInstance) // Start with agent instance AcpAgentSupport.create(MyAgent.class) // With factory supplier -AcpAgentSupport.create(MyAgent.class, () -> new MyAgent(dependency)) +AcpAgentSupport.builder().agent(MyAgent.class, () -> new MyAgent(dependency)) ``` +Either way the agent is instantiated once, when it is registered, and that instance serves +every request, so state kept in its fields is shared across handlers. + ### Running the Agent ```java @@ -360,26 +535,20 @@ class CodeAssistant { private final Map> sessionHistory = new ConcurrentHashMap<>(); - @Initialize - InitializeResponse init(InitializeRequest req) { - // Customize response based on client capabilities - return InitializeResponse.ok(); - } - @NewSession NewSessionResponse newSession(NewSessionRequest req) { String sessionId = UUID.randomUUID().toString(); sessionHistory.put(sessionId, new ArrayList<>()); - return new NewSessionResponse(sessionId, List.of(), List.of()); + return new NewSessionResponse(sessionId, null, null); } @LoadSession LoadSessionResponse loadSession(LoadSessionRequest req) { if (!sessionHistory.containsKey(req.sessionId())) { - throw new AcpProtocolException(AcpErrorCodes.SESSION_NOT_FOUND, + throw new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND, "Session not found: " + req.sessionId()); } - return new LoadSessionResponse(List.of(), List.of()); + return new LoadSessionResponse(null, null); } @Prompt @@ -389,8 +558,9 @@ class CodeAssistant { ctx.sendThought("Analyzing the code..."); - // Check if we can read files - if (ctx.getClientCapabilities().readTextFile()) { + // Check if we can read files (capabilities are null before initialize) + NegotiatedCapabilities caps = ctx.getClientCapabilities(); + if (caps != null && caps.supportsReadTextFile()) { ctx.sendMessage("I can access files if needed."); } @@ -420,7 +590,7 @@ class CodeAssistant { public class Main { public static void main(String[] args) { AcpAgentSupport.create(new CodeAssistant()) - .transport(StdioAcpAgentTransport.create()) + .transport(new StdioAcpAgentTransport()) .interceptor(new MetricsInterceptor()) .run(); } @@ -448,9 +618,8 @@ AcpAgent.sync(transport) ### Annotation API (After) ```java -@AcpAgent +@AcpAgent(name = "my-agent", version = "1.0") class MyAgent { - @Initialize InitializeResponse init() { return InitializeResponse.ok(); } @NewSession NewSessionResponse newSession() { return new NewSessionResponse("session-1", null, null); } @Prompt PromptResponse prompt(SyncPromptContext ctx) { ctx.sendMessage("Hello!"); @@ -461,7 +630,9 @@ class MyAgent { AcpAgentSupport.create(new MyAgent()).transport(transport).run(); ``` -Both approaches produce identical runtime behavior and can coexist in the same application. +Both approaches produce the same runtime behavior and can coexist in the same application. The +annotated agent needs no initialize handler: it advertises what its annotations declare, where a +builder agent's initialize handler must build the response itself. ## Architecture @@ -469,8 +640,9 @@ Both approaches produce identical runtime behavior and can coexist in the same a acp-agent-support ├── AcpAgentSupport # Bootstrap and builder ├── AcpHandlerMethod # Method + bean encapsulation -├── AcpMethodParameter # Parameter metadata -├── AcpInvocationContext # Request context during invocation +├── invocation/ # Invocation model shared by the extension points +│ ├── AcpMethodParameter # Parameter metadata +│ └── AcpInvocationContext # Request context during invocation ├── resolver/ # Argument resolvers │ ├── ArgumentResolver # Interface │ ├── ArgumentResolverComposite @@ -484,12 +656,16 @@ acp-agent-support │ ├── DirectResponseHandler │ ├── StringToPromptResponseHandler │ ├── VoidHandler -│ └── MonoHandler +│ ├── AsyncValueHandler +│ └── ExtensionResultHandler └── interceptor/ # Interceptor chain ├── AcpInterceptor # Interface └── InterceptorChain # Execution chain ``` +`AcpAgentSupport` uses `resolver`, `handler` and `interceptor`; those use only `invocation`, never +`AcpAgentSupport`, so there are no package cycles. The module uses acp-core's public API only. + ## Dependencies - `acp-annotations` - Zero-dependency annotation definitions diff --git a/acp-agent-support/pom.xml b/acp-agent-support/pom.xml index 948cc935..21f93fd5 100644 --- a/acp-agent-support/pom.xml +++ b/acp-agent-support/pom.xml @@ -7,14 +7,25 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-agent-support ACP Agent Support Reflection-based runtime for annotation-driven ACP agents + + + 0.85 + 0.76 + + + + + org.jspecify + jspecify + com.agentclientprotocol @@ -45,6 +56,11 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + test + org.assertj assertj-core @@ -61,6 +77,13 @@ ${project.version} test + + + com.agentclientprotocol + acp-streamable-http-jetty + ${project.version} + test + diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupport.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupport.java index 4ec16083..125e712c 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupport.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupport.java @@ -4,32 +4,49 @@ package com.agentclientprotocol.sdk.agent.support; +import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.time.Duration; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; import java.util.function.Supplier; import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; import com.agentclientprotocol.sdk.agent.AcpSyncAgent; import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.AsyncValueHandler; import com.agentclientprotocol.sdk.agent.support.handler.DirectResponseHandler; -import com.agentclientprotocol.sdk.agent.support.handler.MonoHandler; +import com.agentclientprotocol.sdk.agent.support.handler.ExtensionResultHandler; import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlerComposite; import com.agentclientprotocol.sdk.agent.support.handler.StringToPromptResponseHandler; import com.agentclientprotocol.sdk.agent.support.handler.VoidHandler; import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; import com.agentclientprotocol.sdk.agent.support.interceptor.InterceptorChain; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.AgentResolver; import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolverComposite; +import com.agentclientprotocol.sdk.agent.support.resolver.AuthenticateRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.CancelNotificationResolver; import com.agentclientprotocol.sdk.agent.support.resolver.CapabilitiesResolver; import com.agentclientprotocol.sdk.agent.support.resolver.CloseSessionRequestResolver; +import com.agentclientprotocol.sdk.agent.support.resolver.ConfigOptionResolver; import com.agentclientprotocol.sdk.agent.support.resolver.DeleteSessionRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.DisableProviderRequestResolver; +import com.agentclientprotocol.sdk.agent.support.resolver.ExtensionParamsResolver; import com.agentclientprotocol.sdk.agent.support.resolver.ForkSessionRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.InitializeRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.ListProvidersRequestResolver; @@ -44,11 +61,15 @@ import com.agentclientprotocol.sdk.agent.support.resolver.SetProviderRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.SetSessionConfigOptionRequestResolver; import com.agentclientprotocol.sdk.agent.support.resolver.SetSessionModeRequestResolver; -import com.agentclientprotocol.sdk.agent.support.resolver.SetSessionModelRequestResolver; +import com.agentclientprotocol.sdk.annotation.Authenticate; import com.agentclientprotocol.sdk.annotation.Cancel; +import com.agentclientprotocol.sdk.annotation.ConfigId; +import com.agentclientprotocol.sdk.annotation.ConfigValue; import com.agentclientprotocol.sdk.annotation.CloseSession; import com.agentclientprotocol.sdk.annotation.DeleteSession; import com.agentclientprotocol.sdk.annotation.DisableProvider; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; import com.agentclientprotocol.sdk.annotation.ForkSession; import com.agentclientprotocol.sdk.annotation.Initialize; import com.agentclientprotocol.sdk.annotation.ListProviders; @@ -60,51 +81,111 @@ import com.agentclientprotocol.sdk.annotation.ResumeSession; import com.agentclientprotocol.sdk.annotation.SetProvider; import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.annotation.SessionId; import com.agentclientprotocol.sdk.annotation.SetSessionMode; -import com.agentclientprotocol.sdk.annotation.SetSessionModel; -import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.PromptTimeouts; +import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Bootstrap class for annotation-based ACP agents. + * Runs an ACP agent written with annotations. Give it an object whose class is marked + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent @AcpAgent}: it finds the handler methods, + * answers {@code initialize} from the annotations, and serves the methods over a transport. Use it + * when one method per request reads better than lambdas on the + * {@link AcpAgent#sync(AcpAgentTransport) AcpAgent.sync(..)} builder, which it uses underneath. The + * rules for the handler methods themselves (parameters, return values, errors, threads) are on + * {@code @AcpAgent}. * - *

This class provides a fluent builder API to configure and run - * annotation-based agents without requiring any external framework. - * - *

Example usage: *

{@code
- * @AcpAgent
+ * @AcpAgent(name = "my-agent", version = "1.0")
  * class MyAgent {
- *     @Initialize
- *     InitializeResponse init(InitializeRequest req) {
- *         return InitializeResponse.ok();
+ *
+ *     @LoadSession
+ *     LoadSessionResponse load(LoadSessionRequest request) {
+ *         return new LoadSessionResponse(null, null);
  *     }
  *
  *     @Prompt
- *     PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) {
- *         ctx.sendMessage("Hello!");
+ *     PromptResponse prompt(PromptRequest request, SyncPromptContext context) {
+ *         context.sendMessage("Hello!");
  *         return PromptResponse.endTurn();
  *     }
  * }
  *
- * // Bootstrap
- * AcpAgentSupport.create(new MyAgent())
- *     .transport(StdioAcpAgentTransport.create())
- *     .run();
+ * // In main: serve the agent on stdio until the client closes its input
+ * AcpAgentSupport.create(new MyAgent()).transport(new StdioAcpAgentTransport()).run();
+ * }
+ * + *

Building and running

+ *

{@link #create(Object)} returns a {@link Builder}. The builder finds the handler methods when + * the bean is given to it and checks them when it builds, so a mistake, such as a parameter that no + * resolver can fill or a missing {@code @Prompt} method, throws there with a message naming the + * method and the fix, not at the first request. {@link Builder#build()} gives an agent on the + * builder's transport: {@link #start()} starts it and returns, {@link #run()} starts it and blocks + * until the transport ends, and {@link #close()} closes it (try-with-resources works). + * {@link Builder#run()} does all of it in one call, for a {@code main} method. + * + *

A listener transport, such as {@code StreamableHttpAcpAgentTransport} from the + * {@code acp-streamable-http-jetty} module, accepts many connections and needs one agent for each: + * {@link Builder#buildFactory()} gives it an {@link AcpAgentFactory} instead. + *

{@code
+ * AcpAgentFactory factory = AcpAgentSupport.create(new MyAgent()).buildFactory();
+ * new StreamableHttpAcpAgentTransport(8080, factory).start().block();
  * }
* + *

Answering {@code initialize}

+ *

The agent answers {@code initialize} from its annotations, with no {@code @Initialize} method + * needed: each handler annotation advertises the capability its ACP method needs (above, + * {@code loadSession}), the {@code @AcpAgent} attributes give {@code agentInfo}, + * {@code authMethods} and the MCP transports, and the {@code @Prompt} attributes give the prompt + * content the agent accepts. An {@code @Initialize} method's response is laid over the derived one; + * see {@link com.agentclientprotocol.sdk.annotation.Initialize @Initialize} for the merge rule. + * Without a {@code @NewSession} method, {@code session/new} is answered with a random UUID as the + * session id. + * + *

One handler bean, shared

+ *

The annotated object is the application's bean, like a Spring controller: the builder finds + * its handler methods once, and every agent the builder builds, every connection a factory serves + * included, calls the same instance. Its handler methods are therefore called concurrently, from + * different connections and from different sessions of one connection, and must be thread-safe; + * keep per-session state in a concurrent map keyed by session id, not in plain fields. The same + * holds for interceptors, argument resolvers and return value handlers. For the same reason a bean + * cannot hold "its" agent: a handler method that needs the connection's agent (to send a session + * update outside a prompt turn) or its negotiated capabilities takes an {@code AcpSyncAgent}, + * {@code AcpAsyncAgent} or {@code NegotiatedCapabilities} parameter, filled for each call from the + * connection the request arrived on. + * + *

Extension points

+ *

Each call of a handler method runs these steps on the handler's thread: the + * {@link AcpInterceptor}s' {@code preInvoke}, an {@link ArgumentResolver} for each parameter, the + * method itself, the interceptors' {@code postInvoke}, a {@link ReturnValueHandler} that turns the + * returned value into the response, and the interceptors' {@code afterCompletion}. Add your own + * with {@link Builder#interceptor}, {@link Builder#argumentResolver} and + * {@link Builder#returnValueHandler}. Custom resolvers and return value handlers are asked before + * the built-in ones, so they can also replace one. The {@code initialize} answer derived from the + * annotations, and the default {@code session/new} answer given when there is no + * {@code @NewSession} method, pass through the interceptors as a handler method's would. + * * @author Mark Pollack - * @since 1.0.0 */ -public class AcpAgentSupport { +public class AcpAgentSupport implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(AcpAgentSupport.class); + /** The handler annotations and the ACP method each one marks a handler for. */ + private static final Map, String> HANDLER_ANNOTATIONS = handlerAnnotations(); + private final Map handlers; private final ArgumentResolverComposite argumentResolvers; @@ -113,17 +194,30 @@ public class AcpAgentSupport { private final List interceptors; + private final AgentAdvertisement advertisement; + private final AcpSyncAgent agent; - private AcpAgentSupport(Builder builder) { - this.handlers = builder.handlers; - this.argumentResolvers = builder.argumentResolvers; - this.returnValueHandlers = builder.returnValueHandlers; - this.interceptors = builder.interceptors; + private AcpAgentSupport(Definition definition, AcpAgentTransport transport) { + this.handlers = definition.handlers(); + this.advertisement = definition.advertisement(); + this.argumentResolvers = definition.argumentResolvers(); + this.returnValueHandlers = definition.returnValueHandlers(); + this.interceptors = definition.interceptors(); // Build the underlying sync agent - var agentBuilder = AcpAgent.sync(builder.transport) - .requestTimeout(builder.requestTimeout); + var agentBuilder = AcpAgent.sync(transport) + .cancelGracePeriod(definition.cancelGracePeriod()) + .maxPromptDuration(definition.maxPromptDuration()); + ExecutorService handlerExecutor = definition.handlerExecutor(); + if (handlerExecutor != null) { + agentBuilder.handlerExecutor(handlerExecutor); + } + // Unset, the agent builder's default applies: the SDK has one default request timeout. + Duration requestTimeout = definition.requestTimeout(); + if (requestTimeout != null) { + agentBuilder.requestTimeout(requestTimeout); + } // Wire discovered handlers to the agent builder wireHandlers(agentBuilder); @@ -132,25 +226,35 @@ private AcpAgentSupport(Builder builder) { } /** - * Create a new builder for an agent instance. - * @param agentInstance the annotated agent instance - * @return a new builder + * Starts a builder for an agent served by {@code agentInstance}, as + * {@code builder().agent(agentInstance)} does. Its handler methods are found here, once. + * @param agentInstance the handler bean, an instance of a class marked {@code @AcpAgent} (or of + * a subclass of one) + * @return a new builder with the bean added + * @throws IllegalArgumentException if the bean's handler methods are malformed (see + * {@link Builder#agent(Object)}) */ public static Builder create(Object agentInstance) { return new Builder().agent(agentInstance); } /** - * Create a new builder for an agent class (requires no-arg constructor). - * @param agentClass the annotated agent class - * @return a new builder + * Starts a builder for an agent served by a new instance of {@code agentClass}, as + * {@code builder().agent(agentClass)} does. The class is instantiated once, here, with its + * no-argument constructor, and that instance serves every request. + * @param agentClass the class marked {@code @AcpAgent} + * @return a new builder with the bean added + * @throws IllegalArgumentException if the class cannot be instantiated, or its handler methods + * are malformed (see {@link Builder#agent(Object)}) */ public static Builder create(Class agentClass) { return new Builder().agent(agentClass); } /** - * Create a new empty builder. + * Starts an empty builder. Add the handler bean with one of the {@code agent(..)} methods + * before building; {@link Builder#agent(Class, Supplier)} suits a framework that creates the + * bean itself. * @return a new builder */ public static Builder builder() { @@ -158,7 +262,9 @@ public static Builder builder() { } /** - * Start the agent (non-blocking). + * Starts the agent: its transport starts and it begins answering the client. Returns at once; + * the transport's threads serve the client. Call it once. + * @throws IllegalStateException if the agent was already started */ public void start() { log.info("Starting annotation-based ACP agent"); @@ -166,234 +272,374 @@ public void start() { } /** - * Run the agent (blocking until close). + * Starts the agent and blocks until its transport ends: on stdio, once the client has closed + * the agent's input and every request received before has been answered; on any transport, once + * {@link #close()} has been called. Use it to keep a {@code main} method alive. + * @throws IllegalStateException if the agent was already started */ public void run() { start(); - agent.await(); + agent.awaitTermination(); } /** - * Close the agent gracefully. + * Closes the agent as {@link AcpSyncAgent#close()} does: gracefully, waiting at most 10 + * seconds, then at once if that failed or took longer. A {@link #run()} in progress then + * returns. Try-with-resources calls it. */ + @Override public void close() { log.info("Closing annotation-based ACP agent"); - agent.closeGracefully(); + agent.close(); } /** - * Get the underlying sync agent. - * @return the sync agent + * Returns the sync agent this object runs. Use it to send session updates and requests to the + * client from outside a handler method, such as from a background task, once the agent has + * started. Inside a handler method, take an {@code AcpSyncAgent} parameter instead, which works + * under {@link Builder#buildFactory()} too. + * @return the agent */ public AcpSyncAgent getAgent() { return agent; } - @SuppressWarnings("removal") // wires the deprecated-for-removal session/set_model handler - private void wireHandlers(AcpAgent.SyncAgentBuilder agentBuilder) { - // Initialize handler - AcpHandlerMethod initHandler = handlers.get("initialize"); - if (initHandler != null) { - agentBuilder.initializeHandler(req -> invokeHandler(initHandler, req, null, null, null)); - } - else { - // Default initialize handler - agentBuilder.initializeHandler(req -> InitializeResponse.ok()); - } + /** + * Binds each discovered handler method to the agent builder, one ACP method per line. + * Initialize and session/new have defaults, so an agent with only a prompt handler can + * still be initialized and open sessions. Initialize answers with the response derived + * from the annotations ({@link AgentAdvertisement}), with an {@code @Initialize} method's + * response laid over it when there is one. + */ + private void wireHandlers(AcpAgent.SyncAgentBuilder agent) { + bind(AcpSchema.METHOD_INITIALIZE, + handler -> agent.initializeHandler(req -> AgentAdvertisement.merge(advertisement.derive(req), + respond(handler, InitializeResponse.class, req, null))), + () -> agent.initializeHandler(req -> answerByDefault(AcpSchema.METHOD_INITIALIZE, req, + InitializeResponse.class, () -> advertisement.derive(req)))); + bind(AcpSchema.METHOD_SESSION_NEW, + handler -> agent.newSessionHandler(req -> respond(handler, NewSessionResponse.class, req, null)), + () -> agent.newSessionHandler(req -> answerByDefault(AcpSchema.METHOD_SESSION_NEW, req, + NewSessionResponse.class, NewSessionResponse::withGeneratedId))); + bind(AcpSchema.METHOD_AUTHENTICATE, handler -> agent + .authenticateHandler(req -> respond(handler, AcpSchema.AuthenticateResponse.class, req, null))); + bind(AcpSchema.METHOD_LOGOUT, + handler -> agent.logoutHandler(req -> respond(handler, AcpSchema.LogoutResponse.class, req, null))); + bind(AcpSchema.METHOD_SESSION_LOAD, handler -> agent.loadSessionHandler( + req -> respond(handler, AcpSchema.LoadSessionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_PROMPT, handler -> agent.promptHandler( + (req, context) -> respond(handler, AcpSchema.PromptResponse.class, req, req.sessionId(), context))); + bind(AcpSchema.METHOD_SESSION_SET_MODE, handler -> agent.setSessionModeHandler( + req -> respond(handler, AcpSchema.SetSessionModeResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_LIST, handler -> agent + .listSessionsHandler(req -> respond(handler, AcpSchema.ListSessionsResponse.class, req, null))); + bind(AcpSchema.METHOD_SESSION_CLOSE, handler -> agent.closeSessionHandler( + req -> respond(handler, AcpSchema.CloseSessionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_DELETE, handler -> agent.deleteSessionHandler( + req -> respond(handler, AcpSchema.DeleteSessionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_RESUME, handler -> agent.resumeSessionHandler( + req -> respond(handler, AcpSchema.ResumeSessionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_FORK, handler -> agent.forkSessionHandler( + req -> respond(handler, AcpSchema.ForkSessionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, handler -> agent.setSessionConfigOptionHandler( + req -> respond(handler, AcpSchema.SetSessionConfigOptionResponse.class, req, req.sessionId()))); + bind(AcpSchema.METHOD_PROVIDERS_LIST, handler -> agent + .listProvidersHandler(req -> respond(handler, AcpSchema.ListProvidersResponse.class, req, null))); + bind(AcpSchema.METHOD_PROVIDERS_SET, handler -> agent + .setProviderHandler(req -> respond(handler, AcpSchema.SetProviderResponse.class, req, null))); + bind(AcpSchema.METHOD_PROVIDERS_DISABLE, handler -> agent + .disableProviderHandler(req -> respond(handler, AcpSchema.DisableProviderResponse.class, req, null))); + bind(AcpSchema.METHOD_SESSION_CANCEL, handler -> agent.cancelHandler( + notification -> invoke(handler, notification, notification.sessionId(), null))); + handlers.values() + .stream() + .filter(handler -> ExtensionMethods.isExtension(handler.getAcpMethod())) + .forEach(handler -> bindExtension(agent, handler)); + } - // NewSession handler - AcpHandlerMethod newSessionHandler = handlers.get("session/new"); - if (newSessionHandler != null) { - agentBuilder.newSessionHandler(req -> invokeHandler(newSessionHandler, req, null, null, null)); + /** + * Binds an {@link ExtRequest} or {@link ExtNotification} method, its params read as its + * params parameter's type ({@code Object}, the raw JSON value, when it takes none). + */ + private void bindExtension(AcpAgent.SyncAgentBuilder agent, AcpHandlerMethod handler) { + Method method = handler.getMethod(); + Type params = Object.class; + for (int i = 0; i < method.getParameterCount(); i++) { + if (!ExtensionParamsResolver.isConnectionType(method.getParameterTypes()[i])) { + params = method.getGenericParameterTypes()[i]; + } } - else { - // Default new session handler - agentBuilder.newSessionHandler(req -> new NewSessionResponse( - java.util.UUID.randomUUID().toString(), null, null)); + TypeRef paramsType = TypeRef.of(params); + if (method.isAnnotationPresent(ExtNotification.class)) { + bindExtNotification(agent, handler, paramsType); } - - // Logout handler - AcpHandlerMethod logoutHandler = handlers.get("logout"); - if (logoutHandler != null) { - agentBuilder.logoutHandler(req -> invokeHandler(logoutHandler, req, null, null, null)); + else { + bindExtRequest(agent, handler, paramsType); } + } - // LoadSession handler - AcpHandlerMethod loadSessionHandler = handlers.get("session/load"); - if (loadSessionHandler != null) { - agentBuilder.loadSessionHandler(req -> invokeHandler(loadSessionHandler, req, req.sessionId(), null, null)); - } + private void bindExtRequest(AcpAgent.SyncAgentBuilder agent, AcpHandlerMethod handler, + TypeRef paramsType) { + agent.extRequestHandler(handler.getAcpMethod(), paramsType, + params -> respond(handler, Object.class, params, null)); + } - // Prompt handler - AcpHandlerMethod promptHandler = handlers.get("session/prompt"); - if (promptHandler != null) { - agentBuilder.promptHandler((req, syncContext) -> { - NegotiatedCapabilities caps = syncContext.getClientCapabilities(); - return invokeHandler(promptHandler, req, req.sessionId(), syncContext, caps); - }); - } + private void bindExtNotification(AcpAgent.SyncAgentBuilder agent, AcpHandlerMethod handler, + TypeRef paramsType) { + agent.extNotificationHandler(handler.getAcpMethod(), paramsType, + params -> invoke(handler, params, null, null)); + } - // SetSessionMode handler - AcpHandlerMethod setModeHandler = handlers.get("session/set_mode"); - if (setModeHandler != null) { - agentBuilder.setSessionModeHandler(req -> invokeHandler(setModeHandler, req, req.sessionId(), null, null)); - } + /** Binds the handler method discovered for an ACP method, if there is one. */ + private void bind(String acpMethod, Consumer binding) { + bind(acpMethod, binding, () -> { + }); + } - // SetSessionModel handler - AcpHandlerMethod setModelHandler = handlers.get("session/set_model"); - if (setModelHandler != null) { - agentBuilder.setSessionModelHandler(req -> invokeHandler(setModelHandler, req, req.sessionId(), null, null)); + /** Binds the handler method discovered for an ACP method, or the default when there is none. */ + private void bind(String acpMethod, Consumer binding, Runnable defaultBinding) { + AcpHandlerMethod handler = handlers.get(acpMethod); + if (handler != null) { + binding.accept(handler); } - - // ListSessions handler - AcpHandlerMethod listSessionsHandler = handlers.get("session/list"); - if (listSessionsHandler != null) { - agentBuilder.listSessionsHandler(req -> invokeHandler(listSessionsHandler, req, null, null, null)); + else { + defaultBinding.run(); } + } - // CloseSession handler - AcpHandlerMethod closeSessionHandler = handlers.get("session/close"); - if (closeSessionHandler != null) { - agentBuilder.closeSessionHandler( - req -> invokeHandler(closeSessionHandler, req, req.sessionId(), null, null)); - } + /** {@link #respond(AcpHandlerMethod, Class, Object, String, SyncPromptContext)} for a method other than session/prompt. */ + private T respond(AcpHandlerMethod handler, Class responseType, Object request, + @Nullable String sessionId) { + return respond(handler, responseType, request, sessionId, null); + } - // DeleteSession handler - AcpHandlerMethod deleteSessionHandler = handlers.get("session/delete"); - if (deleteSessionHandler != null) { - agentBuilder.deleteSessionHandler( - req -> invokeHandler(deleteSessionHandler, req, req.sessionId(), null, null)); + /** + * Invokes the handler for a request, whose JSON-RPC response must carry a result. A + * handler that produces none (it returned null, returned void for a method other than + * session/prompt, or an interceptor vetoed the call) is answered with an error: an + * empty handler result would otherwise leave the request without any response. A result + * that is not the method's response type is answered with an error naming both types. + */ + private T respond(AcpHandlerMethod handler, Class responseType, Object request, + @Nullable String sessionId, @Nullable SyncPromptContext syncContext) { + Object result = invoke(handler, request, sessionId, syncContext); + if (result == null) { + throw new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, "The " + handler.getAcpMethod() + + " handler produced no response (it returned nothing, or an interceptor vetoed the call)"); } - - // ResumeSession handler - AcpHandlerMethod resumeSessionHandler = handlers.get("session/resume"); - if (resumeSessionHandler != null) { - agentBuilder.resumeSessionHandler( - req -> invokeHandler(resumeSessionHandler, req, req.sessionId(), null, null)); + if (!responseType.isInstance(result)) { + throw new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, "The " + handler.getAcpMethod() + + " handler produced a " + result.getClass().getName() + ", not a " + responseType.getName()); } + return responseType.cast(result); + } - // ForkSession handler - AcpHandlerMethod forkSessionHandler = handlers.get("session/fork"); - if (forkSessionHandler != null) { - agentBuilder.forkSessionHandler( - req -> invokeHandler(forkSessionHandler, req, req.sessionId(), null, null)); - } + /** + * Runs the interceptor chain, argument resolution, the handler and its return value + * handling (see {@link #intercept}). + * @return the handler's result, or null when it produced none or an interceptor vetoed + * the call + */ + private @Nullable Object invoke(AcpHandlerMethod handler, Object request, @Nullable String sessionId, + @Nullable SyncPromptContext syncContext) { + return intercept(handler.getAcpMethod(), request, sessionId, syncContext, context -> { + // Resolve arguments, invoke, and handle the return value + @Nullable Object[] args = resolveArguments(handler, context); + Object result = handler.invoke(args); + return new Outcome(result, handler.getReturnType()); + }); + } - // SetSessionConfigOption handler - AcpHandlerMethod setConfigOptionHandler = handlers.get("session/set_config_option"); - if (setConfigOptionHandler != null) { - agentBuilder.setSessionConfigOptionHandler( - req -> invokeHandler(setConfigOptionHandler, req, req.sessionId(), null, null)); + /** + * The SDK's own answer for an ACP method the agent declares no handler method for (the + * initialize response derived from the annotations, or a session/new with a random id), + * produced through the interceptor chain as a declared handler's would be. + */ + private T answerByDefault(String acpMethod, Object request, Class responseType, Supplier answer) { + Object result = intercept(acpMethod, request, null, null, context -> new Outcome(answer.get(), null)); + if (!responseType.isInstance(result)) { + throw new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, + "The " + acpMethod + " handler produced no response (an interceptor vetoed the call)"); } + return responseType.cast(result); + } - // ListProviders handler (unstable) - AcpHandlerMethod listProvidersHandler = handlers.get("providers/list"); - if (listProvidersHandler != null) { - agentBuilder.listProvidersHandler(req -> invokeHandler(listProvidersHandler, req, null, null, null)); - } + /** The call an interceptor chain wraps. */ + @FunctionalInterface + private interface Call { - // SetProvider handler (unstable) - AcpHandlerMethod setProviderHandler = handlers.get("providers/set"); - if (setProviderHandler != null) { - agentBuilder.setProviderHandler(req -> invokeHandler(setProviderHandler, req, null, null, null)); - } + Outcome call(AcpInvocationContext context) throws Exception; - // DisableProvider handler (unstable) - AcpHandlerMethod disableProviderHandler = handlers.get("providers/disable"); - if (disableProviderHandler != null) { - agentBuilder.disableProviderHandler(req -> invokeHandler(disableProviderHandler, req, null, null, null)); - } + } - // Cancel handler - AcpHandlerMethod cancelHandler = handlers.get("session/cancel"); - if (cancelHandler != null) { - agentBuilder.cancelHandler(notification -> { - invokeHandler(cancelHandler, notification, notification.sessionId(), null, null); - }); - } + /** + * What a call produced: its result, and the declared return type that the return value + * handlers convert it by, or null for a result that is already the response. + */ + private record Outcome(@Nullable Object result, @Nullable AcpMethodParameter returnType) { } - @SuppressWarnings("unchecked") - private T invokeHandler(AcpHandlerMethod handler, Object request, String sessionId, - SyncPromptContext syncContext, NegotiatedCapabilities capabilities) { + /** + * Runs the interceptor chain around {@code call}. The invocation sees this connection's + * agent and, once the client has sent initialize, the capabilities negotiated on it. A + * result with a declared return type passes through the return value handlers. + * @return the result, or null when there is none or an interceptor vetoed the call + */ + private @Nullable Object intercept(String acpMethod, Object request, @Nullable String sessionId, + @Nullable SyncPromptContext syncContext, Call call) { AcpInvocationContext context = AcpInvocationContext.builder() - .acpMethod(handler.getAcpMethod()) + .acpMethod(acpMethod) .request(request) .sessionId(sessionId) .syncPromptContext(syncContext) - .capabilities(capabilities) + .promptContext(syncContext != null ? syncContext.async() : null) + .capabilities(agent.getClientCapabilities()) + .agent(agent) .build(); InterceptorChain chain = new InterceptorChain(interceptors); - + @Nullable Throwable failure = null; try { - // Pre-invoke - if (!chain.applyPreInvoke(context)) { - return null; - } - - // Resolve arguments - Object[] args = resolveArguments(handler, context); - - // Invoke - Object result = handler.invoke(args); - - // Post-invoke - result = chain.applyPostInvoke(context, result); - - // Handle return value - return (T) returnValueHandlers.handleReturnValue(result, handler.getReturnType(), context); - - } - catch (Exception e) { - // On-error - Object replacement = chain.applyOnError(context, e); - if (replacement != null) { - return (T) replacement; + try { + return run(chain, context, call); } - if (e instanceof RuntimeException re) { - throw re; + catch (Exception e) { + return recover(chain, context, e); } - throw new RuntimeException(e); + } + catch (RuntimeException | Error e) { + failure = e; + throw e; } finally { - chain.triggerAfterCompletion(context, null); + chain.triggerAfterCompletion(context, failure); + } + } + + /** The interceptors' preInvoke and postInvoke around the call, and the return value handlers. */ + private @Nullable Object run(InterceptorChain chain, AcpInvocationContext context, Call call) throws Exception { + if (!chain.applyPreInvoke(context)) { + return null; + } + Outcome outcome = call.call(context); + Object result = chain.applyPostInvoke(context, outcome.result()); + AcpMethodParameter returnType = outcome.returnType(); + return (returnType != null) ? returnValueHandlers.handleReturnValue(result, returnType, context) : result; + } + + /** + * The replacement an interceptor's {@code onError} gives for a failed call; without one the + * failure is rethrown, a checked exception wrapped in a {@code RuntimeException}. + */ + private static Object recover(InterceptorChain chain, AcpInvocationContext context, Exception failure) { + Object replacement = chain.applyOnError(context, failure); + if (replacement != null) { + return replacement; + } + if (failure instanceof RuntimeException re) { + throw re; } + throw new RuntimeException(failure); } - private Object[] resolveArguments(AcpHandlerMethod handler, AcpInvocationContext context) { + private @Nullable Object[] resolveArguments(AcpHandlerMethod handler, AcpInvocationContext context) { AcpMethodParameter[] params = handler.getParameters(); - Object[] args = new Object[params.length]; + @Nullable Object[] args = new Object[params.length]; for (int i = 0; i < params.length; i++) { args[i] = argumentResolvers.resolveArgument(params[i], context); } return args; } + /** + * The handler annotations and the ACP method each one marks a handler for, in a stable order. + * @return the annotations, by annotation type + */ + static Map, String> handlerAnnotations() { + Map, String> annotations = new LinkedHashMap<>(); + annotations.put(Initialize.class, AcpSchema.METHOD_INITIALIZE); + annotations.put(Authenticate.class, AcpSchema.METHOD_AUTHENTICATE); + annotations.put(Logout.class, AcpSchema.METHOD_LOGOUT); + annotations.put(NewSession.class, AcpSchema.METHOD_SESSION_NEW); + annotations.put(LoadSession.class, AcpSchema.METHOD_SESSION_LOAD); + annotations.put(Prompt.class, AcpSchema.METHOD_SESSION_PROMPT); + annotations.put(SetSessionMode.class, AcpSchema.METHOD_SESSION_SET_MODE); + annotations.put(ListSessions.class, AcpSchema.METHOD_SESSION_LIST); + annotations.put(CloseSession.class, AcpSchema.METHOD_SESSION_CLOSE); + annotations.put(DeleteSession.class, AcpSchema.METHOD_SESSION_DELETE); + annotations.put(ResumeSession.class, AcpSchema.METHOD_SESSION_RESUME); + annotations.put(ForkSession.class, AcpSchema.METHOD_SESSION_FORK); + annotations.put(SetSessionConfigOption.class, AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION); + annotations.put(ListProviders.class, AcpSchema.METHOD_PROVIDERS_LIST); + annotations.put(SetProvider.class, AcpSchema.METHOD_PROVIDERS_SET); + annotations.put(DisableProvider.class, AcpSchema.METHOD_PROVIDERS_DISABLE); + annotations.put(Cancel.class, AcpSchema.METHOD_SESSION_CANCEL); + return Collections.unmodifiableMap(annotations); + } + + /** + * What a builder held when it built: the agents it builds share it, and a later change + * to the builder does not reach them. The composites are complete (custom entries first, + * then the defaults) and never change after construction, so connections share them. + */ + private record Definition(Map handlers, AgentAdvertisement advertisement, + ArgumentResolverComposite argumentResolvers, + ReturnValueHandlerComposite returnValueHandlers, List interceptors, + @Nullable Duration requestTimeout, Duration cancelGracePeriod, Duration maxPromptDuration, + @Nullable ExecutorService handlerExecutor) { + } + // ========== BUILDER ========== /** - * Builder for AcpAgentSupport. + * Configures an annotated agent and builds it: an {@link AcpAgentSupport} on one transport + * ({@link #build()}, {@link #run()}), or an {@link AcpAgentFactory} for a listener transport + * ({@link #buildFactory()}). Get one from {@link AcpAgentSupport#create(Object)} or + * {@link AcpAgentSupport#builder()}. + * + *

A builder can be built any number of times. Each {@link #build()} and + * {@link #buildFactory()} puts the default argument resolvers and return value handlers after + * the custom ones without changing the builder, and what is built is not affected by later + * changes to the builder. Every agent built from one builder calls the same handler bean (see + * {@link AcpAgentSupport}). A builder is not thread-safe: configure it on one thread. */ public static class Builder { private final Map handlers = new HashMap<>(); - private final ArgumentResolverComposite argumentResolvers = new ArgumentResolverComposite(); + private final List> agentClasses = new ArrayList<>(); + + private final List customArgumentResolvers = new ArrayList<>(); - private final ReturnValueHandlerComposite returnValueHandlers = new ReturnValueHandlerComposite(); + private final List customReturnValueHandlers = new ArrayList<>(); private final List interceptors = new ArrayList<>(); - private AcpAgentTransport transport; + private @Nullable AcpAgentTransport transport; + + /** Null for the SDK's default, which the agent builder applies. */ + private @Nullable Duration requestTimeout; + + private Duration cancelGracePeriod = PromptTimeouts.DEFAULT_CANCEL_GRACE_PERIOD; + + private Duration maxPromptDuration = Duration.ZERO; - private Duration requestTimeout = Duration.ofSeconds(30); + private @Nullable ExecutorService handlerExecutor; /** - * Register an agent instance. - * @param agentInstance the annotated agent instance + * Adds a handler bean: finds the handler methods of its class and superclasses now, and + * binds them to this instance. Add more than one bean to split an agent across classes: + * their handler methods combine, the first bean's {@code @AcpAgent} names the agent, and + * their auth methods and MCP transports are combined. + * @param agentInstance the handler bean, an instance of a class marked {@code @AcpAgent} + * (or of a subclass of one) * @return this builder + * @throws IllegalArgumentException if neither the bean's class nor a superclass is marked + * {@code @AcpAgent}, a method carries two handler annotations, two methods (in this bean or + * one added before) answer the same ACP method, an extension method is malformed (a name + * without the {@code _} prefix, more than one params parameter, or a session parameter), or + * an {@code @AcpAgent} auth method is malformed */ public Builder agent(Object agentInstance) { discoverHandlers(agentInstance.getClass(), () -> agentInstance); @@ -401,28 +647,36 @@ public Builder agent(Object agentInstance) { } /** - * Register an agent class (must have no-arg constructor). - * @param agentClass the annotated agent class + * Adds a handler bean created from {@code agentClass} with its no-argument constructor, as + * {@link #agent(Object)} does. The class is instantiated once, here, and that instance + * serves every request. + * @param agentClass the class marked {@code @AcpAgent} * @return this builder + * @throws IllegalArgumentException if the class cannot be instantiated, or for the reasons + * {@link #agent(Object)} gives */ public Builder agent(Class agentClass) { discoverHandlers(agentClass, () -> { try { return agentClass.getDeclaredConstructor().newInstance(); } - catch (Exception e) { - throw new RuntimeException("Cannot instantiate " + agentClass, e); + catch (ReflectiveOperationException e) { + throw new IllegalArgumentException("Cannot instantiate " + agentClass.getName(), e); } }); return this; } /** - * Register an agent class with factory. - * @param agentClass the annotated agent class - * @param factory supplier for agent instances - * @param the agent type + * Adds a handler bean that a framework creates, as {@link #agent(Object)} does. The handler + * methods are found on {@code agentClass} and its superclasses; {@code factory} is called + * once, here, and the object it returns serves every request. That object may be a subclass + * of {@code agentClass}, such as a proxy the framework generates, so its interceptors run. + * @param agentClass the class marked {@code @AcpAgent} + * @param factory gives the handler bean, an instance of {@code agentClass} + * @param the bean type * @return this builder + * @throws IllegalArgumentException for the reasons {@link #agent(Object)} gives */ public Builder agent(Class agentClass, Supplier factory) { discoverHandlers(agentClass, factory::get); @@ -430,7 +684,9 @@ public Builder agent(Class agentClass, Supplier factory) { } /** - * Set the transport. + * Sets the transport that {@link #build()} and {@link #run()} serve the agent on, such as a + * {@code StdioAcpAgentTransport}. Leave it unset for {@link #buildFactory()}, whose + * listener transport supplies one per connection. * @param transport the agent transport * @return this builder */ @@ -440,178 +696,378 @@ public Builder transport(AcpAgentTransport transport) { } /** - * Set the request timeout. - * @param timeout the timeout duration + * Sets the executor the agent's handler methods run on, for example + * {@code Executors.newVirtualThreadPerTaskExecutor()} or a framework's worker pool, as + * {@link AcpAgent.SyncAgentBuilder#handlerExecutor(ExecutorService)} does. Without it they + * run on the SDK's default: a virtual thread per call on JDK 21 and later, before that a + * shared pool of daemon threads. The executor must allow blocking; the SDK never shuts it + * down. + * @param executor the executor the handler methods run on + * @return this builder + * @throws IllegalArgumentException if {@code executor} is null + */ + public Builder handlerExecutor(ExecutorService executor) { + if (executor == null) { + throw new IllegalArgumentException("Executor must not be null"); + } + this.handlerExecutor = executor; + return this; + } + + /** + * Sets how long the agent waits for the client to answer a request the agent sends, such as + * a permission prompt or a file read. The default is the SDK's default request timeout, 60 + * seconds, the same as for a builder agent ({@code AcpAgent.sync(..)}) and a client. + * @param timeout the timeout, or {@code null} for the SDK's default * @return this builder */ - public Builder requestTimeout(Duration timeout) { + public Builder requestTimeout(@Nullable Duration timeout) { this.requestTimeout = timeout; return this; } /** - * Add an interceptor. + * Sets how long a {@code @Prompt} method has to return after {@code session/cancel} before + * the agent answers the prompt with stop reason {@code cancelled} itself and interrupts the + * method's thread. Default 60 seconds; {@link Duration#ZERO} for none. See + * {@link AcpAgent.SyncAgentBuilder#cancelGracePeriod(Duration)}. + * @param gracePeriod the grace period; zero for none, not negative + * @return this builder + * @throws IllegalArgumentException if {@code gracePeriod} is null or negative + */ + public Builder cancelGracePeriod(Duration gracePeriod) { + Assert.notNull(gracePeriod, "The cancelGracePeriod must not be null"); + Assert.isTrue(!gracePeriod.isNegative(), "The cancelGracePeriod must not be negative"); + this.cancelGracePeriod = gracePeriod; + return this; + } + + /** + * Sets how long a prompt turn may run before the agent answers it with error {@code -32800} + * (request cancelled). Default none ({@link Duration#ZERO}). See + * {@link AcpAgent.SyncAgentBuilder#maxPromptDuration(Duration)}. + * @param maxDuration the maximum prompt duration; zero for none, not negative + * @return this builder + * @throws IllegalArgumentException if {@code maxDuration} is null or negative + */ + public Builder maxPromptDuration(Duration maxDuration) { + Assert.notNull(maxDuration, "The maxPromptDuration must not be null"); + Assert.isTrue(!maxDuration.isNegative(), "The maxPromptDuration must not be negative"); + this.maxPromptDuration = maxDuration; + return this; + } + + /** + * Adds an interceptor that runs around every handler method call. Interceptors run in + * {@link AcpInterceptor#getOrder()} order, and those with the same order in the order they + * were added. * @param interceptor the interceptor * @return this builder + * @throws IllegalArgumentException if {@code interceptor} is null */ public Builder interceptor(AcpInterceptor interceptor) { + Assert.notNull(interceptor, "The interceptor must not be null"); this.interceptors.add(interceptor); return this; } /** - * Add a custom argument resolver. + * Adds an argument resolver, for a parameter type the built-in resolvers do not supply or + * to replace one of them: custom resolvers are asked before the built-in ones, in the order + * they were added. A parameter a custom resolver supports is not checked when the agent is + * built. * @param resolver the resolver * @return this builder + * @throws IllegalArgumentException if {@code resolver} is null */ public Builder argumentResolver(ArgumentResolver resolver) { - this.argumentResolvers.addResolver(resolver); + Assert.notNull(resolver, "The resolver must not be null"); + this.customArgumentResolvers.add(resolver); return this; } /** - * Add a custom return value handler. + * Adds a return value handler, for a return type the built-in handlers do not accept or to + * replace one of them: custom handlers are asked before the built-in ones, in the order + * they were added. A return type a custom handler supports is not checked when the agent is + * built. * @param handler the handler * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public Builder returnValueHandler(ReturnValueHandler handler) { - this.returnValueHandlers.addHandler(handler); + Assert.notNull(handler, "The handler must not be null"); + this.customReturnValueHandlers.add(handler); return this; } /** - * Build the AcpAgentSupport instance. - * @return the configured instance + * Builds an agent on the configured transport. It is not started: call + * {@link AcpAgentSupport#start()} or {@link AcpAgentSupport#run()}. May be called again, + * with the same or another transport, for another agent that calls the same handler bean. + * @return the agent + * @throws IllegalStateException if no transport or no handler bean is configured, or a + * handler method cannot be served: it takes a parameter that no resolver supplies or that + * does not suit its method, or returns a type that cannot give its method's response; there + * is no {@code @Prompt} method; a {@code @SetSessionMode} or + * {@code @SetSessionConfigOption} method has no {@code @NewSession} method to offer modes + * or config options; or an auth method has no {@code @Authenticate} method to serve it */ public AcpAgentSupport build() { + AcpAgentTransport transport = this.transport; if (transport == null) { - throw new IllegalStateException("Transport must be configured"); + throw new IllegalStateException("Transport must be configured: call transport(..) before build()," + + " or call buildFactory() for a listener transport"); } + return new AcpAgentSupport(definition(), transport); + } + + /** + * Builds the agent on the configured transport, starts it, and blocks until the transport + * ends (on stdio, once the client closes the agent's input). The one call a stdio agent's + * {@code main} method needs: + *

{@code
+		 * AcpAgentSupport.create(new MyAgent()).transport(new StdioAcpAgentTransport()).run();
+		 * }
+ * @throws IllegalStateException for the reasons {@link #build()} gives + */ + public void run() { + build().run(); + } - // Add default resolvers (custom ones added first take precedence) - addDefaultResolvers(); - addDefaultReturnValueHandlers(); - return new AcpAgentSupport(this); + /** + * Builds a factory for a listener transport, such as + * {@code StreamableHttpAcpAgentTransport} or {@code StreamableHttpAcpServlet}, that creates + * a fresh agent for each connection it accepts. Every agent calls the same handler bean, + * concurrently across connections, so its handler methods must be thread-safe (see + * {@link AcpAgentSupport}). The factory captures the builder as it is now. The listener + * supplies a transport per connection, so a builder with a {@link #transport} set is + * refused. + * @return a factory creating one agent per connection + * @throws IllegalStateException if a transport was set, or for the reasons {@link #build()} + * gives (other than the transport) + */ + public AcpAgentFactory buildFactory() { + if (this.transport != null) { + throw new IllegalStateException("buildFactory() serves a listener transport, which supplies a" + + " transport per connection, but transport(..) was set: drop transport(..), or call build()" + + " for an agent on that transport"); + } + Definition definition = definition(); + return AcpAgentFactory.sync(connection -> new AcpAgentSupport(definition, connection).getAgent()); } - @SuppressWarnings("removal") // discovers the deprecated-for-removal @SetSessionModel - private void discoverHandlers(Class agentClass, Supplier beanSupplier) { - if (!agentClass.isAnnotationPresent( - com.agentclientprotocol.sdk.annotation.AcpAgent.class)) { - throw new IllegalArgumentException( - "Class must be annotated with @AcpAgent: " + agentClass.getName()); + /** + * The builder's configuration now, with the defaults after the custom entries. + * @throws IllegalStateException if an agent auth method has no {@code @Authenticate} + * handler to serve it + */ + private Definition definition() { + if (agentClasses.isEmpty()) { + throw new IllegalStateException("No @AcpAgent bean was given: use AcpAgentSupport.create(bean)," + + " or builder().agent(..) before building"); } + ArgumentResolverComposite argumentResolvers = new ArgumentResolverComposite() + .addResolvers(customArgumentResolvers) + .addResolvers(defaultResolvers()); + ReturnValueHandlerComposite returnValueHandlers = new ReturnValueHandlerComposite() + .addHandlers(customReturnValueHandlers) + .addHandlers(defaultReturnValueHandlers()); + Map handlers = Map.copyOf(this.handlers); + HandlerSignatures.check(handlers, HANDLER_ANNOTATIONS, argumentResolvers, customArgumentResolvers, + returnValueHandlers, customReturnValueHandlers); + HandlerSignatures.checkSessionSetup(handlers, agentClasses); + HandlerSignatures.checkPrompt(handlers, agentClasses.get(0).getName()); + AgentAdvertisement advertisement = AgentAdvertisement.of(List.copyOf(agentClasses), handlers, + HANDLER_ANNOTATIONS); + return new Definition(handlers, advertisement, argumentResolvers, returnValueHandlers, + List.copyOf(interceptors), requestTimeout, cancelGracePeriod, maxPromptDuration, + handlerExecutor); + } - for (Method method : agentClass.getDeclaredMethods()) { - if (method.isAnnotationPresent(Initialize.class)) { - handlers.put("initialize", new AcpHandlerMethod(beanSupplier, method, "initialize")); - log.debug("Discovered @Initialize handler: {}", method.getName()); - } - if (method.isAnnotationPresent(Logout.class)) { - handlers.put("logout", new AcpHandlerMethod(beanSupplier, method, "logout")); - log.debug("Discovered @Logout handler: {}", method.getName()); - } - if (method.isAnnotationPresent(NewSession.class)) { - handlers.put("session/new", new AcpHandlerMethod(beanSupplier, method, "session/new")); - log.debug("Discovered @NewSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(LoadSession.class)) { - handlers.put("session/load", new AcpHandlerMethod(beanSupplier, method, "session/load")); - log.debug("Discovered @LoadSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(Prompt.class)) { - handlers.put("session/prompt", new AcpHandlerMethod(beanSupplier, method, "session/prompt")); - log.debug("Discovered @Prompt handler: {}", method.getName()); - } - if (method.isAnnotationPresent(SetSessionMode.class)) { - handlers.put("session/set_mode", new AcpHandlerMethod(beanSupplier, method, "session/set_mode")); - log.debug("Discovered @SetSessionMode handler: {}", method.getName()); - } - if (method.isAnnotationPresent(SetSessionModel.class)) { - handlers.put("session/set_model", new AcpHandlerMethod(beanSupplier, method, "session/set_model")); - log.debug("Discovered @SetSessionModel handler: {}", method.getName()); - } - if (method.isAnnotationPresent(ListSessions.class)) { - handlers.put("session/list", new AcpHandlerMethod(beanSupplier, method, "session/list")); - log.debug("Discovered @ListSessions handler: {}", method.getName()); - } - if (method.isAnnotationPresent(CloseSession.class)) { - handlers.put("session/close", new AcpHandlerMethod(beanSupplier, method, "session/close")); - log.debug("Discovered @CloseSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(DeleteSession.class)) { - handlers.put("session/delete", new AcpHandlerMethod(beanSupplier, method, "session/delete")); - log.debug("Discovered @DeleteSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(ResumeSession.class)) { - handlers.put("session/resume", new AcpHandlerMethod(beanSupplier, method, "session/resume")); - log.debug("Discovered @ResumeSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(ForkSession.class)) { - handlers.put("session/fork", new AcpHandlerMethod(beanSupplier, method, "session/fork")); - log.debug("Discovered @ForkSession handler: {}", method.getName()); - } - if (method.isAnnotationPresent(SetSessionConfigOption.class)) { - handlers.put("session/set_config_option", - new AcpHandlerMethod(beanSupplier, method, "session/set_config_option")); - log.debug("Discovered @SetSessionConfigOption handler: {}", method.getName()); - } - if (method.isAnnotationPresent(ListProviders.class)) { - handlers.put("providers/list", new AcpHandlerMethod(beanSupplier, method, "providers/list")); - log.debug("Discovered @ListProviders handler: {}", method.getName()); + /** + * Discovers the handler methods of {@code agentClass} and its superclasses, and binds + * them to the one instance the factory gives. The {@code @AcpAgent} annotation may be on + * a superclass, as it is for a proxy a framework generates (Spring CGLIB, Quarkus ArC, + * Micronaut AOP), whose subclass carries no annotations: handlers are then found on the + * user's class and invoked on the proxy, so its interceptors run. A method overridden in + * a subclass is one handler, invoked virtually, and bridge and synthetic methods are not + * handlers. + */ + private void discoverHandlers(Class agentClass, Supplier instanceFactory) { + Class annotatedClass = annotatedAgentClass(agentClass); + if (annotatedClass == null) { + throw new IllegalArgumentException("Class must be annotated with @AcpAgent: " + agentClass.getName() + + " (neither it nor a superclass is)"); + } + AgentAdvertisement.validate(annotatedClass); + agentClasses.add(annotatedClass); + // One instance for every handler and request: handlers share the agent's state + Object agentInstance = instanceFactory.get(); + Set seen = new HashSet<>(); + for (Class type = agentClass; type != null && type != Object.class; type = type.getSuperclass()) { + discoverDeclaredHandlers(type, agentInstance, seen); + } + } + + /** + * Registers the handlers {@code type} declares, other than those an annotated override + * below it already registered ({@code seen}, the signatures registered so far). An + * unannotated override, such as a proxy's, leaves the annotations to the method it + * overrides. A generic override's bridge stands for the erased method it overrides. + */ + private void discoverDeclaredHandlers(Class type, Object agentInstance, Set seen) { + Set registeredHere = new HashSet<>(); + for (Method method : type.getDeclaredMethods()) { + if (!method.isBridge() && !method.isSynthetic() && !seen.contains(signature(method)) + && discoverHandler(method, agentInstance)) { + registeredHere.add(method.getName()); + seen.add(signature(method)); } - if (method.isAnnotationPresent(SetProvider.class)) { - handlers.put("providers/set", new AcpHandlerMethod(beanSupplier, method, "providers/set")); - log.debug("Discovered @SetProvider handler: {}", method.getName()); + } + for (Method method : type.getDeclaredMethods()) { + if (method.isBridge() && registeredHere.contains(method.getName())) { + seen.add(signature(method)); } - if (method.isAnnotationPresent(DisableProvider.class)) { - handlers.put("providers/disable", - new AcpHandlerMethod(beanSupplier, method, "providers/disable")); - log.debug("Discovered @DisableProvider handler: {}", method.getName()); + } + } + + /** + * Registers {@code method} for the handler annotation it carries. + * @return whether it carries one + * @throws IllegalArgumentException if it carries more than one, or another method + * already handles its ACP method + */ + private boolean discoverHandler(Method method, Object agentInstance) { + Map marks = new LinkedHashMap<>(); + HANDLER_ANNOTATIONS.forEach((annotation, acpMethod) -> { + if (method.isAnnotationPresent(annotation)) { + marks.put(acpMethod, "@" + annotation.getSimpleName()); } - if (method.isAnnotationPresent(Cancel.class)) { - handlers.put("session/cancel", new AcpHandlerMethod(beanSupplier, method, "session/cancel")); - log.debug("Discovered @Cancel handler: {}", method.getName()); + }); + String extensionMethod = extensionMethod(method); + if (extensionMethod != null) { + marks.put(extensionMethod, method.isAnnotationPresent(ExtNotification.class) ? "@ExtNotification" + : "@ExtRequest"); + } + if (marks.isEmpty()) { + return false; + } + if (marks.size() > 1) { + throw new IllegalArgumentException(describe(method) + " is annotated " + String.join(" and ", marks.values()) + + "; a method handles one ACP method, so split it into one method per annotation"); + } + Map.Entry mark = marks.entrySet().iterator().next(); + AcpHandlerMethod existing = handlers.get(mark.getKey()); + if (existing != null) { + throw new IllegalArgumentException(describe(existing.getMethod()) + " and " + describe(method) + + " are both " + mark.getValue() + " methods; keep one " + mark.getValue() + " method"); + } + handlers.put(mark.getKey(), new AcpHandlerMethod(agentInstance, method, mark.getKey())); + log.debug("Discovered {} handler: {}", mark.getValue(), method.getName()); + return true; + } + + private static String describe(Method method) { + return method.getDeclaringClass().getName() + "." + method.getName(); + } + + /** A method's name and parameter types, which an override shares. */ + private static String signature(Method method) { + return method.getName() + Arrays.toString(method.getParameterTypes()); + } + + /** The first class from {@code type} up that is annotated {@code @AcpAgent}, or null. */ + private static @Nullable Class annotatedAgentClass(Class type) { + for (Class candidate = type; candidate != null; candidate = candidate.getSuperclass()) { + if (candidate.isAnnotationPresent(com.agentclientprotocol.sdk.annotation.AcpAgent.class)) { + return candidate; } } + return null; + } + + /** + * The extension method an {@link ExtRequest} or {@link ExtNotification} method + * handles, or null when it has neither annotation. + * @throws IllegalArgumentException if the name does not start with {@code _}, or the + * method takes more than one parameter for the params (its other parameters may only + * take its connection's capabilities or agent) + */ + private static @Nullable String extensionMethod(Method method) { + ExtRequest request = method.getAnnotation(ExtRequest.class); + ExtNotification notification = method.getAnnotation(ExtNotification.class); + String name = (request != null) ? request.value() : (notification != null) ? notification.value() : null; + if (name == null) { + return null; + } + ExtensionMethods.requireExtension(name); + rejectSessionParameters(method); + long paramsParameters = Arrays.stream(method.getParameterTypes()) + .filter(type -> !ExtensionParamsResolver.isConnectionType(type)) + .count(); + if (paramsParameters > 1) { + throw new IllegalArgumentException("Extension handler " + method.getName() + + " must take at most one parameter, which receives the params of " + name + + ", besides NegotiatedCapabilities, AcpSyncAgent or AcpAsyncAgent"); + } + return name; + } + + /** + * Rejects an extension handler's {@code @SessionId}, {@code @ConfigId} or + * {@code @ConfigValue} parameter: an extension method has no session, and the + * parameter would otherwise be taken for the params. + */ + private static void rejectSessionParameters(Method method) { + for (java.lang.reflect.Parameter parameter : method.getParameters()) { + List.of(SessionId.class, ConfigId.class, ConfigValue.class) + .stream() + .filter(parameter::isAnnotationPresent) + .findFirst() + .ifPresent(annotation -> { + throw new IllegalArgumentException("Extension handler " + describe(method) + " has a @" + + annotation.getSimpleName() + " parameter, but an extension method has no session;" + + " read what it needs from its params instead"); + }); + } + } + + private static List defaultResolvers() { + // Built-in resolvers (order matters - first match wins), after the custom ones + return List.of( + new ExtensionParamsResolver(), + new ConfigOptionResolver(), + new InitializeRequestResolver(), + new AuthenticateRequestResolver(), + new LogoutRequestResolver(), + new NewSessionRequestResolver(), + new LoadSessionRequestResolver(), + new PromptRequestResolver(), + new SetSessionModeRequestResolver(), + new ListSessionsRequestResolver(), + new CloseSessionRequestResolver(), + new DeleteSessionRequestResolver(), + new ResumeSessionRequestResolver(), + new ForkSessionRequestResolver(), + new SetSessionConfigOptionRequestResolver(), + new ListProvidersRequestResolver(), + new SetProviderRequestResolver(), + new DisableProviderRequestResolver(), + new CancelNotificationResolver(), + new PromptContextResolver(), + new AgentResolver(), + new SessionIdResolver(), + new CapabilitiesResolver()); } - @SuppressWarnings("removal") // registers the deprecated-for-removal SetSessionModelRequestResolver - private void addDefaultResolvers() { - // Built-in resolvers (order matters - first match wins) - // Custom resolvers added via builder go first - argumentResolvers.addResolver(new InitializeRequestResolver()); - argumentResolvers.addResolver(new LogoutRequestResolver()); - argumentResolvers.addResolver(new NewSessionRequestResolver()); - argumentResolvers.addResolver(new LoadSessionRequestResolver()); - argumentResolvers.addResolver(new PromptRequestResolver()); - argumentResolvers.addResolver(new SetSessionModeRequestResolver()); - argumentResolvers.addResolver(new SetSessionModelRequestResolver()); - argumentResolvers.addResolver(new ListSessionsRequestResolver()); - argumentResolvers.addResolver(new CloseSessionRequestResolver()); - argumentResolvers.addResolver(new DeleteSessionRequestResolver()); - argumentResolvers.addResolver(new ResumeSessionRequestResolver()); - argumentResolvers.addResolver(new ForkSessionRequestResolver()); - argumentResolvers.addResolver(new SetSessionConfigOptionRequestResolver()); - argumentResolvers.addResolver(new ListProvidersRequestResolver()); - argumentResolvers.addResolver(new SetProviderRequestResolver()); - argumentResolvers.addResolver(new DisableProviderRequestResolver()); - argumentResolvers.addResolver(new CancelNotificationResolver()); - argumentResolvers.addResolver(new PromptContextResolver()); - argumentResolvers.addResolver(new SessionIdResolver()); - argumentResolvers.addResolver(new CapabilitiesResolver()); - } - - private void addDefaultReturnValueHandlers() { - // Built-in handlers (order matters - first match wins) - // Custom handlers added via builder go first - returnValueHandlers.addHandler(new DirectResponseHandler()); - returnValueHandlers.addHandler(new StringToPromptResponseHandler()); - returnValueHandlers.addHandler(new VoidHandler()); - - // Async handlers (Reactor is available since acp-core depends on it) - returnValueHandlers.addHandler(new MonoHandler()); + private static List defaultReturnValueHandlers() { + // Built-in handlers (order matters - first match wins), after the custom ones. + // Async values (Mono, CompletionStage, Publisher) are awaited; extension results are + // any value, so their handler is last, after the async values and void. + return List.of(new DirectResponseHandler(), new StringToPromptResponseHandler(), new VoidHandler(), + new AsyncValueHandler(), new ExtensionResultHandler()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpHandlerMethod.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpHandlerMethod.java index 3b97ab0c..1e721f45 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpHandlerMethod.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpHandlerMethod.java @@ -8,12 +8,23 @@ import java.lang.reflect.Method; import java.util.function.Supplier; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import org.jspecify.annotations.Nullable; + /** - * Encapsulates a handler method and its target bean. - * Supports both eager instance and lazy factory-based construction. + * One handler method that {@link AcpAgentSupport} found: the bean it is called on, the + * {@link Method}, and the ACP method it answers, such as {@code session/prompt} or an extension + * method name. The builder creates one for each handler method when a bean is given to it, and the + * runtime calls {@link #invoke} for each request. Applications do not need this class, and no + * extension point receives it: an interceptor or argument resolver learns what is called from + * {@link com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext#getAcpMethod() + * AcpInvocationContext.getAcpMethod()} and {@link AcpMethodParameter#getMethod()}. + * + *

The constructor makes the method accessible ({@link Method#setAccessible(boolean)}), so + * handler methods of any visibility can be called; it fails if the method's module does not open + * its package to the SDK. Instances do not change after construction. * * @author Mark Pollack - * @since 1.0.0 */ public final class AcpHandlerMethod { @@ -28,20 +39,22 @@ public final class AcpHandlerMethod { private final AcpMethodParameter returnType; /** - * Create a handler method with an eager bean instance. - * @param bean the target bean instance - * @param method the method to invoke - * @param acpMethod the ACP method name (e.g., "initialize", "session/prompt") + * Creates a handler method that calls {@code method} on {@code bean}. + * @param bean the object the method is called on + * @param method the handler method, made accessible here + * @param acpMethod the ACP method it answers, such as {@code "initialize"} or + * {@code "session/prompt"} */ public AcpHandlerMethod(Object bean, Method method, String acpMethod) { this(() -> bean, method, acpMethod); } /** - * Create a handler method with a lazy bean supplier. - * @param beanSupplier supplier for the target bean - * @param method the method to invoke - * @param acpMethod the ACP method name + * Creates a handler method that calls {@code method} on the object {@code beanSupplier} gives. + * The supplier is asked on every call, so it may return a different object each time. + * @param beanSupplier gives the object the method is called on + * @param method the handler method, made accessible here + * @param acpMethod the ACP method it answers */ public AcpHandlerMethod(Supplier beanSupplier, Method method, String acpMethod) { this.beanSupplier = beanSupplier; @@ -60,7 +73,7 @@ public AcpHandlerMethod(Supplier beanSupplier, Method method, String acp } /** - * Get the target bean instance. + * Returns the object the method is called on, asking the supplier if one was given. * @return the bean */ public Object getBean() { @@ -68,7 +81,7 @@ public Object getBean() { } /** - * Get the method to invoke. + * Returns the handler method. * @return the method */ public Method getMethod() { @@ -76,7 +89,8 @@ public Method getMethod() { } /** - * Get the ACP method name (e.g., "initialize", "session/prompt"). + * Returns the ACP method this handler answers, such as {@code "initialize"}, + * {@code "session/prompt"} or an extension method name starting with {@code _}. * @return the ACP method name */ public String getAcpMethod() { @@ -84,28 +98,33 @@ public String getAcpMethod() { } /** - * Get the pre-computed parameter metadata. - * @return array of parameter metadata + * Returns the descriptions of the method's parameters, in order, made once at construction. The + * array is shared: do not change it. + * @return the parameter descriptions */ public AcpMethodParameter[] getParameters() { return parameters; } /** - * Get the return type metadata. - * @return the return type parameter + * Returns the description of the method's return type. + * @return the return type description, for which {@link AcpMethodParameter#isReturnType()} is + * true */ public AcpMethodParameter getReturnType() { return returnType; } /** - * Invoke the method with the given arguments. - * @param args the arguments - * @return the return value - * @throws Exception if invocation fails + * Calls the handler method on the bean with {@code args}, which must match its parameters in + * number and type. + * @param args the arguments, one for each parameter + * @return what the method returned, or null for a {@code void} method + * @throws Exception if the handler method throws: its exception, as it threw it (not wrapped in + * an {@code InvocationTargetException}); an {@link Error} it throws is rethrown as is + * @throws IllegalArgumentException if {@code args} do not match the method's parameters */ - public Object invoke(Object[] args) throws Exception { + public @Nullable Object invoke(@Nullable Object[] args) throws Exception { try { return method.invoke(getBean(), args); } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpInvocationContext.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpInvocationContext.java deleted file mode 100644 index 93885a05..00000000 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpInvocationContext.java +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.support; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import com.agentclientprotocol.sdk.agent.PromptContext; -import com.agentclientprotocol.sdk.agent.SyncPromptContext; -import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; - -/** - * Context for a single method invocation, holding request data and - * runtime information needed by argument resolvers and interceptors. - * - * @author Mark Pollack - * @since 1.0.0 - */ -public final class AcpInvocationContext { - - private final String acpMethod; - - private final Object request; - - private final String sessionId; - - private final PromptContext promptContext; - - private final SyncPromptContext syncPromptContext; - - private final NegotiatedCapabilities capabilities; - - private final Map attributes = new HashMap<>(); - - private AcpInvocationContext(Builder builder) { - this.acpMethod = builder.acpMethod; - this.request = builder.request; - this.sessionId = builder.sessionId; - this.promptContext = builder.promptContext; - this.syncPromptContext = builder.syncPromptContext; - this.capabilities = builder.capabilities; - } - - /** - * Create a new builder. - * @return a new builder - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Get the ACP method name (e.g., "initialize", "session/prompt"). - * @return the method name - */ - public String getAcpMethod() { - return acpMethod; - } - - /** - * Get the request object. - * @return the request - */ - public Object getRequest() { - return request; - } - - /** - * Get the request cast to a specific type. - * @param type the expected type - * @param the type - * @return the typed request - * @throws ClassCastException if the request is not of the expected type - */ - @SuppressWarnings("unchecked") - public T getRequest(Class type) { - return (T) request; - } - - /** - * Get the session ID if available. - * @return optional session ID - */ - public Optional getSessionId() { - return Optional.ofNullable(sessionId); - } - - /** - * Get the async prompt context if available. - * Only present for prompt handlers. - * @return optional prompt context - */ - public Optional getPromptContext() { - return Optional.ofNullable(promptContext); - } - - /** - * Get the sync prompt context if available. - * Only present for prompt handlers. - * @return optional sync prompt context - */ - public Optional getSyncPromptContext() { - return Optional.ofNullable(syncPromptContext); - } - - /** - * Get the negotiated capabilities. - * @return optional capabilities - */ - public Optional getCapabilities() { - return Optional.ofNullable(capabilities); - } - - /** - * Set a custom attribute. - * @param name attribute name - * @param value attribute value - */ - public void setAttribute(String name, Object value) { - attributes.put(name, value); - } - - /** - * Get a custom attribute. - * @param name attribute name - * @return optional attribute value - */ - public Optional getAttribute(String name) { - return Optional.ofNullable(attributes.get(name)); - } - - /** - * Get a typed custom attribute. - * @param name attribute name - * @param type expected type - * @param the type - * @return optional typed attribute value - */ - @SuppressWarnings("unchecked") - public Optional getAttribute(String name, Class type) { - Object value = attributes.get(name); - if (value != null && type.isInstance(value)) { - return Optional.of((T) value); - } - return Optional.empty(); - } - - /** - * Builder for AcpInvocationContext. - */ - public static class Builder { - - private String acpMethod; - - private Object request; - - private String sessionId; - - private PromptContext promptContext; - - private SyncPromptContext syncPromptContext; - - private NegotiatedCapabilities capabilities; - - public Builder acpMethod(String acpMethod) { - this.acpMethod = acpMethod; - return this; - } - - public Builder request(Object request) { - this.request = request; - return this; - } - - public Builder sessionId(String sessionId) { - this.sessionId = sessionId; - return this; - } - - public Builder promptContext(PromptContext promptContext) { - this.promptContext = promptContext; - return this; - } - - public Builder syncPromptContext(SyncPromptContext syncPromptContext) { - this.syncPromptContext = syncPromptContext; - return this; - } - - public Builder capabilities(NegotiatedCapabilities capabilities) { - this.capabilities = capabilities; - return this; - } - - public AcpInvocationContext build() { - return new AcpInvocationContext(this); - } - - } - -} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpMethodParameter.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpMethodParameter.java deleted file mode 100644 index 091a100d..00000000 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AcpMethodParameter.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.support; - -import java.lang.annotation.Annotation; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.lang.reflect.Type; -import java.util.Objects; - -/** - * Metadata about a method parameter. Implements equals/hashCode - * for use as cache key in argument resolver lookup. - * - *

Pre-computes and caches parameter metadata for performance. - * - * @author Mark Pollack - * @since 1.0.0 - */ -public final class AcpMethodParameter { - - private final Method method; - - private final int index; - - private final Parameter parameter; - - // Lazy-initialized caches - private volatile Annotation[] annotations; - - private volatile Class parameterType; - - private volatile Type genericType; - - /** - * Create a new AcpMethodParameter for a method parameter. - * @param method the method - * @param index the parameter index (0-based) - */ - public AcpMethodParameter(Method method, int index) { - this.method = method; - this.index = index; - this.parameter = (index >= 0) ? method.getParameters()[index] : null; - } - - /** - * Create an AcpMethodParameter representing the return type. - * @param method the method - * @return a parameter representing the return type - */ - public static AcpMethodParameter forReturnType(Method method) { - return new AcpMethodParameter(method, -1); - } - - /** - * Get the method this parameter belongs to. - * @return the method - */ - public Method getMethod() { - return method; - } - - /** - * Get the parameter index (0-based), or -1 for return type. - * @return the parameter index - */ - public int getIndex() { - return index; - } - - /** - * Get the parameter name (requires -parameters compiler flag). - * @return the parameter name, or null for return type - */ - public String getName() { - return parameter != null ? parameter.getName() : null; - } - - /** - * Get the parameter type, or return type if index is -1. - * @return the type - */ - public Class getParameterType() { - if (parameterType == null) { - parameterType = (index >= 0) - ? parameter.getType() - : method.getReturnType(); - } - return parameterType; - } - - /** - * Get the generic type, preserving type parameters. - * @return the generic type - */ - public Type getGenericType() { - if (genericType == null) { - genericType = (index >= 0) - ? parameter.getParameterizedType() - : method.getGenericReturnType(); - } - return genericType; - } - - /** - * Get all annotations on this parameter. - * @return the annotations, or empty array for return type - */ - public Annotation[] getAnnotations() { - if (annotations == null) { - annotations = (parameter != null) - ? parameter.getAnnotations() - : new Annotation[0]; - } - return annotations; - } - - /** - * Get a specific annotation if present. - * @param annotationType the annotation type - * @param the annotation type - * @return the annotation, or null if not present - */ - public A getAnnotation(Class annotationType) { - for (Annotation ann : getAnnotations()) { - if (annotationType.isInstance(ann)) { - return annotationType.cast(ann); - } - } - return null; - } - - /** - * Check if this parameter has the specified annotation. - * @param annotationType the annotation type - * @return true if the annotation is present - */ - public boolean hasAnnotation(Class annotationType) { - return getAnnotation(annotationType) != null; - } - - /** - * Check if this represents a return type (index == -1). - * @return true if this is a return type - */ - public boolean isReturnType() { - return index == -1; - } - - // Critical: equals/hashCode for caching - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof AcpMethodParameter that)) { - return false; - } - return index == that.index && method.equals(that.method); - } - - @Override - public int hashCode() { - return Objects.hash(method, index); - } - - @Override - public String toString() { - if (isReturnType()) { - return "return type of " + method.getName(); - } - return "parameter " + index + " (" + getName() + ") of " + method.getName(); - } - -} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AgentAdvertisement.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AgentAdvertisement.java new file mode 100644 index 00000000..703bc8ae --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/AgentAdvertisement.java @@ -0,0 +1,442 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.AuthMethod; +import com.agentclientprotocol.sdk.annotation.Authenticate; +import com.agentclientprotocol.sdk.annotation.Cancel; +import com.agentclientprotocol.sdk.annotation.CloseSession; +import com.agentclientprotocol.sdk.annotation.DeleteSession; +import com.agentclientprotocol.sdk.annotation.DisableProvider; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.ForkSession; +import com.agentclientprotocol.sdk.annotation.Initialize; +import com.agentclientprotocol.sdk.annotation.ListProviders; +import com.agentclientprotocol.sdk.annotation.ListSessions; +import com.agentclientprotocol.sdk.annotation.LoadSession; +import com.agentclientprotocol.sdk.annotation.Logout; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.ResumeSession; +import com.agentclientprotocol.sdk.annotation.SetProvider; +import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.annotation.SetSessionMode; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentAuthCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.Implementation; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.McpCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.SessionCapabilities; +import org.jspecify.annotations.Nullable; + +/** + * What an annotated agent advertises in its {@code initialize} response, derived from its + * classes once, when the builder builds: the capabilities its handler annotations imply + * ({@link #mappings()}), its {@link AuthMethod}s, the MCP transports and prompt content its + * {@link AcpAgent} and {@link Prompt} attributes declare, and its {@code agentInfo}. + * + *

+ * Per connection, {@link #derive(InitializeRequest)} turns it into the + * response to one client: its protocol version negotiated, and the terminal auth methods left + * out unless the client supports them. {@link #merge} lays an {@link Initialize} method's + * response over that derived one. + *

+ */ +final class AgentAdvertisement { + + /** The protocol versions this SDK speaks. */ + private static final Set SUPPORTED_PROTOCOL_VERSIONS = Set.of(AcpSchema.LATEST_PROTOCOL_VERSION); + + /** The version sent when neither {@code @AcpAgent} nor the jar manifest gives one. */ + static final String UNKNOWN_VERSION = "unknown"; + + /** An empty capability object, {@code {}}, which advertises a session method. */ + private static final Map SUPPORTED = Map.of(); + + /** What {@code agentCapabilities.sessionCapabilities} carries. */ + private static final Set SESSION_CAPABILITIES = EnumSet.of(Advertises.LIST_SESSIONS, + Advertises.CLOSE_SESSION, Advertises.RESUME_SESSION, Advertises.DELETE_SESSION, Advertises.FORK_SESSION, + Advertises.ADDITIONAL_DIRECTORIES); + + /** + * What a handler annotation, or an {@link AcpAgent} attribute, advertises in the initialize + * response. + */ + enum Advertises { + + /** Nothing: ACP requires every agent to support the method. */ + BASELINE, + + /** Nothing: the method is the initialize exchange itself. */ + INITIALIZE, + + /** {@code agentCapabilities.loadSession}. */ + LOAD_SESSION, + + /** {@code agentCapabilities.sessionCapabilities.list}. */ + LIST_SESSIONS, + + /** {@code agentCapabilities.sessionCapabilities.resume}. */ + RESUME_SESSION, + + /** {@code agentCapabilities.sessionCapabilities.close}. */ + CLOSE_SESSION, + + /** {@code agentCapabilities.sessionCapabilities.delete}. */ + DELETE_SESSION, + + /** {@code agentCapabilities.sessionCapabilities.fork} (unstable). */ + FORK_SESSION, + + /** {@code agentCapabilities.sessionCapabilities.additionalDirectories}, from {@link AcpAgent}. */ + ADDITIONAL_DIRECTORIES, + + /** {@code agentCapabilities.providers} (unstable). */ + PROVIDERS, + + /** {@code agentCapabilities.auth.logout}. */ + LOGOUT, + + /** Nothing itself: {@code authMethods} come from {@link AcpAgent#authMethods()}. */ + AUTH_METHODS, + + /** + * Nothing: what the method sets is offered per session, in the modes or config + * options of the session/new, session/load and session/resume responses. + */ + PER_SESSION, + + /** Nothing: extension methods are agreed outside the protocol (or in {@code _meta}). */ + EXTENSION + + } + + private static final Map, Advertises> MAPPINGS = createMappings(); + + private final Set advertised; + + private final List authMethods; + + private final boolean mcpHttp; + + private final boolean mcpSse; + + private final @Nullable Prompt prompt; + + private final Implementation agentInfo; + + private AgentAdvertisement(Set advertised, List authMethods, boolean mcpHttp, + boolean mcpSse, @Nullable Prompt prompt, Implementation agentInfo) { + this.advertised = advertised; + this.authMethods = authMethods; + this.mcpHttp = mcpHttp; + this.mcpSse = mcpSse; + this.prompt = prompt; + this.agentInfo = agentInfo; + } + + /** + * Every handler annotation and what it advertises; an annotation without an entry fails + * {@code HandlerAnnotationCoverageTest}. + * @return the mappings, by annotation type + */ + static Map, Advertises> mappings() { + return MAPPINGS; + } + + private static Map, Advertises> createMappings() { + Map, Advertises> mappings = new LinkedHashMap<>(); + mappings.put(Initialize.class, Advertises.INITIALIZE); + mappings.put(Authenticate.class, Advertises.AUTH_METHODS); + mappings.put(Logout.class, Advertises.LOGOUT); + mappings.put(NewSession.class, Advertises.BASELINE); + mappings.put(LoadSession.class, Advertises.LOAD_SESSION); + mappings.put(Prompt.class, Advertises.BASELINE); + mappings.put(Cancel.class, Advertises.BASELINE); + mappings.put(SetSessionMode.class, Advertises.PER_SESSION); + mappings.put(SetSessionConfigOption.class, Advertises.PER_SESSION); + mappings.put(ListSessions.class, Advertises.LIST_SESSIONS); + mappings.put(CloseSession.class, Advertises.CLOSE_SESSION); + mappings.put(DeleteSession.class, Advertises.DELETE_SESSION); + mappings.put(ResumeSession.class, Advertises.RESUME_SESSION); + mappings.put(ForkSession.class, Advertises.FORK_SESSION); + mappings.put(ListProviders.class, Advertises.PROVIDERS); + mappings.put(SetProvider.class, Advertises.PROVIDERS); + mappings.put(DisableProvider.class, Advertises.PROVIDERS); + mappings.put(ExtRequest.class, Advertises.EXTENSION); + mappings.put(ExtNotification.class, Advertises.EXTENSION); + return Collections.unmodifiableMap(mappings); + } + + /** + * Checks an agent class's {@link AcpAgent#authMethods()}: unique ids, and each terminal + * method's environment written {@code NAME=value}. + * @param agentClass the agent class + * @throws IllegalArgumentException if a method is malformed + */ + static void validate(Class agentClass) { + AcpAgent agent = agentClass.getAnnotation(AcpAgent.class); + if (agent == null) { + return; + } + Set ids = new LinkedHashSet<>(); + for (AuthMethod method : agent.authMethods()) { + if (!ids.add(method.id())) { + throw new IllegalArgumentException( + "Duplicate auth method id '" + method.id() + "' on " + agentClass.getName()); + } + for (String entry : method.env()) { + if (entry.indexOf('=') <= 0) { + throw new IllegalArgumentException("Auth method '" + method.id() + "' on " + agentClass.getName() + + " has env entry '" + entry + "', which is not NAME=value"); + } + } + } + } + + /** + * Derives the advertisement of the registered agent classes and their handlers. When + * several classes are registered, their auth methods and MCP transports are combined, and + * the first class names the agent. + * @param agentClasses the registered {@code @AcpAgent} classes, in registration order + * @param handlers the handlers, by ACP method + * @param handlerAnnotations the handler annotations and the ACP method each marks + * @return the advertisement + * @throws IllegalStateException if an agent auth method is declared without an + * {@link Authenticate} handler to serve it + */ + static AgentAdvertisement of(List> agentClasses, Map handlers, + Map, String> handlerAnnotations) { + Set advertised = new LinkedHashSet<>(); + handlerAnnotations.forEach((annotation, acpMethod) -> { + if (handlers.containsKey(acpMethod)) { + advertised.add(MAPPINGS.get(annotation)); + } + }); + Map authMethods = new LinkedHashMap<>(); + boolean mcpHttp = false; + boolean mcpSse = false; + for (Class agentClass : agentClasses) { + AcpAgent agent = agentClass.getAnnotation(AcpAgent.class); + for (AuthMethod method : agent.authMethods()) { + authMethods.put(method.id(), method); + } + mcpHttp |= agent.mcpHttp(); + mcpSse |= agent.mcpSse(); + } + agentClasses.stream() + .filter(agentClass -> agentClass.getAnnotation(AcpAgent.class).additionalDirectories()) + .findAny() + .ifPresent(agentClass -> advertised.add(Advertises.ADDITIONAL_DIRECTORIES)); + if (!handlers.containsKey(AcpSchema.METHOD_AUTHENTICATE)) { + authMethods.values() + .stream() + .filter(method -> method.type() == AuthMethod.Type.AGENT) + .findFirst() + .ifPresent(method -> { + throw new IllegalStateException("Auth method '" + method.id() + + "' is an agent method, which the client passes to authenticate, but the agent has no" + + " @Authenticate handler; add one, or declare the method AuthMethod.Type.TERMINAL"); + }); + } + AcpHandlerMethod promptHandler = handlers.get(AcpSchema.METHOD_SESSION_PROMPT); + Prompt prompt = (promptHandler != null) ? promptHandler.getMethod().getAnnotation(Prompt.class) : null; + return new AgentAdvertisement(Collections.unmodifiableSet(advertised), List.copyOf(authMethods.values()), + mcpHttp, mcpSse, prompt, agentInfo(agentClasses)); + } + + private static Implementation agentInfo(List> agentClasses) { + if (agentClasses.isEmpty()) { + return new Implementation("acp-agent", UNKNOWN_VERSION); + } + Class agentClass = agentClasses.get(0); + AcpAgent agent = agentClass.getAnnotation(AcpAgent.class); + String name = agent.name().isBlank() ? agentClass.getSimpleName() : agent.name(); + String version = agent.version(); + if (version.isBlank()) { + Package pkg = agentClass.getPackage(); + String manifestVersion = (pkg != null) ? pkg.getImplementationVersion() : null; + version = (manifestVersion != null && !manifestVersion.isBlank()) ? manifestVersion : UNKNOWN_VERSION; + } + return new Implementation(name, version, agent.title().isBlank() ? null : agent.title()); + } + + /** + * The response derived for one client's initialize request. + * @param request the client's request + * @return the derived response + */ + InitializeResponse derive(InitializeRequest request) { + AcpSchema.ClientCapabilities client = request.clientCapabilities(); + boolean terminalAuth = client != null && client.auth() != null + && Boolean.TRUE.equals(client.auth().terminal()); + List methods = new ArrayList<>(); + for (AuthMethod method : this.authMethods) { + if (method.type() == AuthMethod.Type.TERMINAL && !terminalAuth) { + continue; + } + methods.add(toSchema(method)); + } + return new InitializeResponse(negotiate(request.protocolVersion()), capabilities(), + methods.isEmpty() ? null : List.copyOf(methods), this.agentInfo, null); + } + + /** + * The protocol version to answer: the client's when this SDK speaks it, otherwise the + * latest this SDK speaks (ACP v1, Initialization, version negotiation). + */ + static int negotiate(@Nullable Integer requested) { + return (requested != null && SUPPORTED_PROTOCOL_VERSIONS.contains(requested)) ? requested + : AcpSchema.LATEST_PROTOCOL_VERSION; + } + + private AgentCapabilities capabilities() { + boolean anySession = SESSION_CAPABILITIES.stream().anyMatch(advertised::contains); + SessionCapabilities sessions = anySession ? new SessionCapabilities(supported(Advertises.LIST_SESSIONS), + supported(Advertises.CLOSE_SESSION), supported(Advertises.RESUME_SESSION), + supported(Advertises.DELETE_SESSION), supported(Advertises.ADDITIONAL_DIRECTORIES), + supported(Advertises.FORK_SESSION)) : null; + Prompt prompt = this.prompt; + return AgentCapabilities.builder() + .loadSession(advertised.contains(Advertises.LOAD_SESSION)) + .sessionCapabilities(sessions) + .mcpCapabilities(new McpCapabilities(this.mcpHttp, this.mcpSse)) + .promptCapabilities((prompt != null) + ? new PromptCapabilities(prompt.audio(), prompt.embeddedContext(), prompt.image()) + : new PromptCapabilities()) + .auth(advertised.contains(Advertises.LOGOUT) ? AgentAuthCapabilities.withLogout() : null) + .providers(advertised.contains(Advertises.PROVIDERS) ? new AcpSchema.ProvidersCapabilities(null) : null) + .build(); + } + + private @Nullable Object supported(Advertises capability) { + return advertised.contains(capability) ? SUPPORTED : null; + } + + private static AcpSchema.AuthMethod toSchema(AuthMethod method) { + String description = method.description().isEmpty() ? null : method.description(); + if (method.type() == AuthMethod.Type.AGENT) { + return new AcpSchema.AuthMethodAgent(method.id(), method.name(), description); + } + Map env = new LinkedHashMap<>(); + for (String entry : method.env()) { + int equals = entry.indexOf('='); + env.put(entry.substring(0, equals), entry.substring(equals + 1)); + } + return new AcpSchema.AuthMethodTerminal(method.id(), method.name(), description, + (method.args().length == 0) ? null : List.of(method.args()), env.isEmpty() ? null : Map.copyOf(env), + null); + } + + /** + * Lays the response of an {@link Initialize} method over the derived one: a capability + * is advertised when either advertises it; the returned auth methods follow the derived + * ones, replacing any with the same id; the protocol version, and the agent info and + * {@code _meta} when not null, are the returned ones. + * @param derived the derived response + * @param returned the response the {@code @Initialize} method returned + * @return the response sent to the client + */ + static InitializeResponse merge(InitializeResponse derived, InitializeResponse returned) { + Map methods = new LinkedHashMap<>(); + for (List list : List.of(nullToEmpty(derived.authMethods()), + nullToEmpty(returned.authMethods()))) { + for (AcpSchema.AuthMethod method : list) { + methods.remove(method.id()); + methods.put(method.id(), method); + } + } + return new InitializeResponse( + (returned.protocolVersion() != null) ? returned.protocolVersion() : derived.protocolVersion(), + merge(derived.agentCapabilities(), returned.agentCapabilities()), + methods.isEmpty() ? null : List.copyOf(methods.values()), + (returned.agentInfo() != null) ? returned.agentInfo() : derived.agentInfo(), + (returned.meta() != null) ? returned.meta() : derived.meta()); + } + + private static List nullToEmpty(@Nullable List list) { + return (list != null) ? list : List.of(); + } + + private static @Nullable AgentCapabilities merge(@Nullable AgentCapabilities derived, + @Nullable AgentCapabilities returned) { + if (derived == null || returned == null) { + return (derived != null) ? derived : returned; + } + return new AgentCapabilities(or(derived.loadSession(), returned.loadSession()), + merge(derived.sessionCapabilities(), returned.sessionCapabilities()), + merge(derived.mcpCapabilities(), returned.mcpCapabilities()), + merge(derived.promptCapabilities(), returned.promptCapabilities()), + merge(derived.auth(), returned.auth()), either(returned.providers(), derived.providers()), + either(returned.meta(), derived.meta())); + } + + private static @Nullable SessionCapabilities merge(@Nullable SessionCapabilities derived, + @Nullable SessionCapabilities returned) { + if (derived == null || returned == null) { + return (derived != null) ? derived : returned; + } + return new SessionCapabilities(either(returned.list(), derived.list()), either(returned.close(), derived.close()), + either(returned.resume(), derived.resume()), either(returned.delete(), derived.delete()), + either(returned.additionalDirectories(), derived.additionalDirectories()), + either(returned.fork(), derived.fork()), either(returned.meta(), derived.meta())); + } + + private static @Nullable McpCapabilities merge(@Nullable McpCapabilities derived, + @Nullable McpCapabilities returned) { + if (derived == null || returned == null) { + return (derived != null) ? derived : returned; + } + return new McpCapabilities(or(derived.http(), returned.http()), or(derived.sse(), returned.sse()), + either(returned.meta(), derived.meta())); + } + + private static @Nullable PromptCapabilities merge(@Nullable PromptCapabilities derived, + @Nullable PromptCapabilities returned) { + if (derived == null || returned == null) { + return (derived != null) ? derived : returned; + } + return new PromptCapabilities(or(derived.audio(), returned.audio()), + or(derived.embeddedContext(), returned.embeddedContext()), or(derived.image(), returned.image()), + either(returned.meta(), derived.meta())); + } + + private static @Nullable AgentAuthCapabilities merge(@Nullable AgentAuthCapabilities derived, + @Nullable AgentAuthCapabilities returned) { + if (derived == null || returned == null) { + return (derived != null) ? derived : returned; + } + return new AgentAuthCapabilities(either(returned.logout(), derived.logout()), + either(returned.meta(), derived.meta())); + } + + private static @Nullable Boolean or(@Nullable Boolean derived, @Nullable Boolean returned) { + if (derived == null && returned == null) { + return null; + } + return Boolean.TRUE.equals(derived) || Boolean.TRUE.equals(returned); + } + + /** The first value when not null, otherwise the second. */ + private static @Nullable T either(@Nullable T first, @Nullable T second) { + return (first != null) ? first : second; + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/HandlerSignatures.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/HandlerSignatures.java new file mode 100644 index 00000000..0ee23111 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/HandlerSignatures.java @@ -0,0 +1,343 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlerComposite; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolverComposite; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ConfigId; +import com.agentclientprotocol.sdk.annotation.ConfigValue; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.SessionId; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; + +/** + * Checks, when an agent is built, that each handler method can be called: every parameter has a + * resolver and suits the method (its request type, a session id only where there is a session, + * a prompt context only in a prompt method, config values only in a config option method), and + * the return type gives the method's response. A mistake fails the build with a message naming + * the class, the method and the fix, instead of failing every request at run time. + * + *

+ * Parameters a custom {@link ArgumentResolver} supports, and return types a custom + * {@link ReturnValueHandler} supports, are the application's to make work and are not checked. + *

+ */ +final class HandlerSignatures { + + /** + * What a protocol method passes its handler and expects back. + * @param request the request (or notification) type + * @param response the response type, or null for a notification + * @param session whether the method belongs to a session, so a {@link SessionId} is set + */ + record Shape(Class request, @Nullable Class response, boolean session) { + } + + private static final Map SHAPES = shapes(); + + private HandlerSignatures() { + } + + private static Map shapes() { + Map shapes = new LinkedHashMap<>(); + shapes.put(AcpSchema.METHOD_INITIALIZE, + new Shape(AcpSchema.InitializeRequest.class, AcpSchema.InitializeResponse.class, false)); + shapes.put(AcpSchema.METHOD_AUTHENTICATE, + new Shape(AcpSchema.AuthenticateRequest.class, AcpSchema.AuthenticateResponse.class, false)); + shapes.put(AcpSchema.METHOD_LOGOUT, + new Shape(AcpSchema.LogoutRequest.class, AcpSchema.LogoutResponse.class, false)); + shapes.put(AcpSchema.METHOD_SESSION_NEW, + new Shape(AcpSchema.NewSessionRequest.class, AcpSchema.NewSessionResponse.class, false)); + shapes.put(AcpSchema.METHOD_SESSION_LOAD, + new Shape(AcpSchema.LoadSessionRequest.class, AcpSchema.LoadSessionResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_PROMPT, + new Shape(AcpSchema.PromptRequest.class, AcpSchema.PromptResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_SET_MODE, + new Shape(AcpSchema.SetSessionModeRequest.class, AcpSchema.SetSessionModeResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_LIST, + new Shape(AcpSchema.ListSessionsRequest.class, AcpSchema.ListSessionsResponse.class, false)); + shapes.put(AcpSchema.METHOD_SESSION_CLOSE, + new Shape(AcpSchema.CloseSessionRequest.class, AcpSchema.CloseSessionResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_DELETE, + new Shape(AcpSchema.DeleteSessionRequest.class, AcpSchema.DeleteSessionResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_RESUME, + new Shape(AcpSchema.ResumeSessionRequest.class, AcpSchema.ResumeSessionResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_FORK, + new Shape(AcpSchema.ForkSessionRequest.class, AcpSchema.ForkSessionResponse.class, true)); + shapes.put(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, new Shape(AcpSchema.SetSessionConfigOptionRequest.class, + AcpSchema.SetSessionConfigOptionResponse.class, true)); + shapes.put(AcpSchema.METHOD_PROVIDERS_LIST, + new Shape(AcpSchema.ListProvidersRequest.class, AcpSchema.ListProvidersResponse.class, false)); + shapes.put(AcpSchema.METHOD_PROVIDERS_SET, + new Shape(AcpSchema.SetProviderRequest.class, AcpSchema.SetProviderResponse.class, false)); + shapes.put(AcpSchema.METHOD_PROVIDERS_DISABLE, + new Shape(AcpSchema.DisableProviderRequest.class, AcpSchema.DisableProviderResponse.class, false)); + shapes.put(AcpSchema.METHOD_SESSION_CANCEL, new Shape(AcpSchema.CancelNotification.class, null, true)); + return shapes; + } + + /** + * The protocol method of every handler annotation and what it passes and expects. + * @return the shapes, by ACP method + */ + static Map all() { + return SHAPES; + } + + /** + * Checks every handler of an agent being built. + * @throws IllegalStateException naming the first misuse found + */ + static void check(Map handlers, + Map, String> handlerAnnotations, ArgumentResolverComposite resolvers, + List customResolvers, ReturnValueHandlerComposite returnHandlers, + List customReturnHandlers) { + Map annotationNames = handlerAnnotations.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getValue, entry -> "@" + entry.getKey().getSimpleName())); + handlers.forEach((acpMethod, handler) -> { + String annotation = annotationNames.getOrDefault(acpMethod, + handler.getMethod().isAnnotationPresent(ExtNotification.class) ? "@ExtNotification" + : "@ExtRequest"); + for (AcpMethodParameter parameter : handler.getParameters()) { + if (customResolvers.stream().noneMatch(resolver -> resolver.supportsParameter(parameter))) { + checkParameter(handler, annotation, parameter, resolvers); + } + } + AcpMethodParameter returnType = handler.getReturnType(); + if (customReturnHandlers.stream().noneMatch(custom -> custom.supportsReturnType(returnType))) { + checkReturnType(handler, annotation, returnType, returnHandlers); + } + }); + } + + /** + * Checks that an agent setting modes or config options has a {@code @NewSession} method to + * offer them: the default {@code session/new} answers with none, so a client would never see + * any to set. Likewise an agent that declares {@code @AcpAgent(additionalDirectories = true)}: + * the default {@code session/new} would drop the directories, which ACP forbids. + * @param agentClasses the agent's classes, whose {@code @AcpAgent} attributes are read + * @throws IllegalStateException if it has none + */ + static void checkSessionSetup(Map handlers, List> agentClasses) { + if (handlers.containsKey(AcpSchema.METHOD_SESSION_NEW)) { + return; + } + checkModeSetters(handlers); + agentClasses.stream() + .filter(agentClass -> agentClass.getAnnotation(AcpAgent.class).additionalDirectories()) + .findFirst() + .ifPresent(agentClass -> { + throw new IllegalStateException(agentClass.getName() + " declares @AcpAgent(additionalDirectories =" + + " true), which advertises that the agent uses the additional directories a client sends," + + " but it has no @NewSession method to receive them; add one, or remove the attribute"); + }); + } + + /** + * Checks that the agent has a {@code @Prompt} method: every agent must answer + * {@code session/prompt}. + * @param agentClass the agent class, for the message + * @throws IllegalStateException if it has none + */ + static void checkPrompt(Map handlers, String agentClass) { + if (!handlers.containsKey(AcpSchema.METHOD_SESSION_PROMPT)) { + throw new IllegalStateException(agentClass + " has no @Prompt method, and every agent must answer" + + " session/prompt: add a @Prompt method"); + } + } + + private static void checkModeSetters(Map handlers) { + Map setters = Map.of(AcpSchema.METHOD_SESSION_SET_MODE, "@SetSessionMode", + AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, "@SetSessionConfigOption"); + setters.forEach((acpMethod, annotation) -> { + AcpHandlerMethod setter = handlers.get(acpMethod); + if (setter != null) { + Method method = setter.getMethod(); + throw new IllegalStateException(method.getDeclaringClass().getName() + "." + method.getName() + + " is a " + annotation + " method, but the agent has no @NewSession method, and the default" + + " session/new offers no modes or config options, so a client never sees any to set." + + " Add a @NewSession method that returns them"); + } + }); + } + + private static void checkParameter(AcpHandlerMethod handler, String annotation, AcpMethodParameter parameter, + ArgumentResolverComposite resolvers) { + if (!resolvers.supportsParameter(parameter)) { + throw misuse(handler, "parameter " + (parameter.getIndex() + 1) + " of type " + + parameter.getParameterType().getName() + ", which no argument resolver supplies. Take only the" + + " parameters " + annotation + " lists, or register an ArgumentResolver for the type with" + + " argumentResolver(..)"); + } + if (!ExtensionMethods.isExtension(handler.getAcpMethod())) { + checkRequestType(handler, annotation, parameter); + checkSessionId(handler, parameter); + checkConfigParameter(handler, parameter); + checkPromptContext(handler, parameter); + } + } + + private static void checkRequestType(AcpHandlerMethod handler, String annotation, AcpMethodParameter parameter) { + Class type = parameter.getParameterType(); + Shape shape = shape(handler.getAcpMethod()); + if (type != shape.request() && SHAPES.values().stream().anyMatch(other -> other.request() == type)) { + throw misuse(handler, "parameter " + (parameter.getIndex() + 1) + " of type " + type.getSimpleName() + + ", the request of another method. A " + annotation + " method receives a " + + shape.request().getSimpleName()); + } + } + + private static void checkSessionId(AcpHandlerMethod handler, AcpMethodParameter parameter) { + if (parameter.hasAnnotation(SessionId.class) && !shape(handler.getAcpMethod()).session()) { + throw misuse(handler, "a @SessionId parameter, but " + handler.getAcpMethod() + + " belongs to no session, so there is no session id to give it. Remove the parameter"); + } + } + + private static void checkConfigParameter(AcpHandlerMethod handler, AcpMethodParameter parameter) { + boolean id = parameter.hasAnnotation(ConfigId.class); + if ((id || parameter.hasAnnotation(ConfigValue.class)) + && !AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION.equals(handler.getAcpMethod())) { + throw misuse(handler, "a " + (id ? "@ConfigId" : "@ConfigValue") + + " parameter, which only a @SetSessionConfigOption method receives. Remove the parameter"); + } + } + + private static void checkPromptContext(AcpHandlerMethod handler, AcpMethodParameter parameter) { + Class type = parameter.getParameterType(); + if ((PromptContext.class.isAssignableFrom(type) || SyncPromptContext.class.isAssignableFrom(type)) + && !AcpSchema.METHOD_SESSION_PROMPT.equals(handler.getAcpMethod())) { + throw misuse(handler, "a " + type.getSimpleName() + " parameter, which only a @Prompt method" + + " receives. Outside a prompt, take an AcpSyncAgent or AcpAsyncAgent parameter instead"); + } + } + + private static void checkReturnType(AcpHandlerMethod handler, String annotation, AcpMethodParameter returnType, + ReturnValueHandlerComposite returnHandlers) { + String acpMethod = handler.getAcpMethod(); + if (ExtensionMethods.isExtension(acpMethod)) { + boolean notification = handler.getMethod().isAnnotationPresent(ExtNotification.class); + boolean isVoid = isVoid(returnType.getParameterType()); + if (isVoid && !notification) { + throw misuse(handler, "return type void, but an @ExtRequest is answered with what its method" + + " returns. Return a result (an empty Map when there is nothing to return)"); + } + if (!isVoid && notification) { + throw misuse(handler, "return type " + returnType.getGenericType().getTypeName() + + ", but a notification gets no answer, so the value would be dropped. Return void, or" + + " use @ExtRequest if the client expects an answer"); + } + return; + } + Class response = shape(acpMethod).response(); + if (response != null) { + checkResponse(handler, annotation, returnType, returnHandlers, response); + } + } + + private static void checkResponse(AcpHandlerMethod handler, String annotation, AcpMethodParameter returnType, + ReturnValueHandlerComposite returnHandlers, Class response) { + Class type = returnType.getParameterType(); + boolean prompt = AcpSchema.METHOD_SESSION_PROMPT.equals(handler.getAcpMethod()); + String expected = expected(annotation, response, prompt); + if (prompt && isVoidOrString(type)) { + return; + } + if (isVoid(type)) { + throw misuse(handler, "return type void, but " + handler.getAcpMethod() + " needs a response" + expected); + } + if (!returnHandlers.supportsReturnType(returnType)) { + throw misuse(handler, "return type " + type.getName() + ", which no return value handler accepts" + + expected + ", or register a ReturnValueHandler with returnValueHandler(..)"); + } + if (!gives(returnType, response, prompt)) { + throw misuse(handler, "return type " + returnType.getGenericType().getTypeName() + + ", which does not give a " + response.getSimpleName() + expected); + } + } + + /** + * Whether the return type can produce the response: it is related to it, or is a + * {@code Mono} (or other async type) of a related type or of an unknown type; a prompt + * method's may also produce a {@code String}. + */ + private static boolean gives(AcpMethodParameter returnType, Class response, boolean prompt) { + Class type = returnType.getParameterType(); + Class produced = isAsync(type) ? valueType(returnType.getGenericType()) : type; + return produced == null || related(produced, response) || (prompt && produced == String.class); + } + + private static String expected(String annotation, Class response, boolean prompt) { + return ". A " + annotation + " method returns a " + response.getSimpleName() + " or a Mono of one" + + (prompt ? " (or String or void)" : ""); + } + + /** A prompt method may return these, which end the turn. */ + private static boolean isVoidOrString(Class type) { + return isVoid(type) || type == String.class; + } + + private static boolean isVoid(Class type) { + return type == void.class || type == Void.class; + } + + /** Whether a value of one type can be the other: the same type, a supertype or a subtype. */ + private static boolean related(Class produced, Class response) { + return produced.isAssignableFrom(response) || response.isAssignableFrom(produced); + } + + /** Whether the type is one whose value arrives later, which the runtime waits for. */ + static boolean isAsync(Class type) { + return Publisher.class.isAssignableFrom(type) || java.util.concurrent.CompletionStage.class.isAssignableFrom(type); + } + + /** The value type of {@code Mono} and the like, when it is a class; null otherwise. */ + private static @Nullable Class valueType(Type generic) { + if (generic instanceof ParameterizedType parameterized && parameterized.getActualTypeArguments().length == 1) { + Type argument = parameterized.getActualTypeArguments()[0]; + if (argument instanceof Class type) { + return type; + } + if (argument instanceof ParameterizedType nested && nested.getRawType() instanceof Class raw) { + return raw; + } + } + return null; + } + + private static Shape shape(String acpMethod) { + Shape shape = SHAPES.get(acpMethod); + if (shape == null) { + throw new IllegalStateException("No shape declared for " + acpMethod); + } + return shape; + } + + private static IllegalStateException misuse(AcpHandlerMethod handler, String problem) { + Method method = handler.getMethod(); + return new IllegalStateException(method.getDeclaringClass().getName() + "." + method.getName() + " (handling " + + handler.getAcpMethod() + ") has " + problem); + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/AsyncValueHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/AsyncValueHandler.java new file mode 100644 index 00000000..228518dd --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/AsyncValueHandler.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.handler; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Handles a value that a handler method returns later: a {@link Mono}, a {@link CompletionStage} + * (such as a {@code CompletableFuture}) or another Reactive Streams {@link Publisher} of at most + * one value. The runtime waits for the value on the handler's thread, and the value then means what + * returning it directly means: the method's response, a prompt method's {@code String} (sent to the + * client as an agent message chunk before the turn ends, as {@link StringToPromptResponseHandler} + * does), or an extension request's result. It is one of the built-in {@link ReturnValueHandler}s + * that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers + * by default, so an application never creates or registers it: the method declares the return type. + * + *

An empty {@code Mono} or {@code Publisher}, or a stage that completes with {@code null}, + * produces no response, which for a request is answered with an internal error ({@code -32603}). A + * {@code Publisher} that emits more than one value fails the call with an internal error too, and a + * value that fails fails the call as an exception from the method would. Building the agent checks + * the value type where it can read it: a {@code Mono} or a {@code Mono} + * from a {@code @Prompt} method is rejected. The wait has no time limit of its own. For a prompt + * method, once {@code session/cancel} was received the agent sends stop reason {@code cancelled}, + * whatever value or failure arrives. + * + *

There is one built-in return value handler for each kind of return value: + * {@link DirectResponseHandler}, {@link StringToPromptResponseHandler}, {@link VoidHandler}, this + * one and {@link ExtensionResultHandler}, asked in that order after any custom handler. They are + * stateless and thread-safe. + * + * @author Mark Pollack + */ +public class AsyncValueHandler implements ReturnValueHandler { + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + Class type = returnType.getParameterType(); + return Publisher.class.isAssignableFrom(type) || CompletionStage.class.isAssignableFrom(type); + } + + @Override + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + if (returnValue == null) { + return null; + } + Object value = await(returnValue); + if (value instanceof String text && AcpSchema.METHOD_SESSION_PROMPT.equals(context.getAcpMethod())) { + return StringToPromptResponseHandler.endTurnWith(text, context); + } + return value; + } + + private static @Nullable Object await(Object async) { + if (async instanceof CompletionStage stage) { + return Mono.fromCompletionStage(stage).block(); + } + if (async instanceof Mono mono) { + return mono.block(); + } + return Flux.from((Publisher) async).singleOrEmpty().block(); + } + + /** + * Returns the value type of an async return type, such as {@code PromptResponse} for + * {@code Mono}. + * @param returnType a handler method's return type + * @return the first type argument when it is a class; {@code Object} otherwise, also for a raw + * type, a wildcard, a type variable or a parameterized argument such as {@code List} + */ + public static Class getValueType(AcpMethodParameter returnType) { + Type genericType = returnType.getGenericType(); + if (genericType instanceof ParameterizedType pt) { + Type[] typeArgs = pt.getActualTypeArguments(); + if (typeArgs.length > 0 && typeArgs[0] instanceof Class c) { + return c; + } + } + return Object.class; + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/DirectResponseHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/DirectResponseHandler.java index b3d23c60..2ab4d229 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/DirectResponseHandler.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/DirectResponseHandler.java @@ -4,8 +4,11 @@ package com.agentclientprotocol.sdk.agent.support.handler; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthenticateResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.CloseSessionResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.DeleteSessionResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.DisableProviderResponse; @@ -21,44 +24,45 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.SetProviderResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeResponse; -import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModelResponse; +import org.jspecify.annotations.Nullable; /** - * Handles direct protocol response types that need no conversion. + * Handles a return value that is already an ACP response, such as an {@link InitializeResponse}, + * {@link NewSessionResponse}, {@link LoadSessionResponse}, {@link PromptResponse} or + * {@link SetSessionModeResponse}: it passes the value through unchanged, as the response sent to + * the client. It applies when the declared return type is the response type of any ACP method that + * a handler annotation serves, the other session, auth and provider responses included. It is one + * of the built-in {@link ReturnValueHandler}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: the method declares the return type. * - *

Supports {@link InitializeResponse}, {@link NewSessionResponse}, - * {@link LoadSessionResponse}, {@link PromptResponse}, {@link SetSessionModeResponse}, - * and {@link SetSessionModelResponse}. + *

It works as {@link AsyncValueHandler} does, for a value that needs no waiting, and is asked + * first. Building the agent rejects a method declared to return the response of another method, + * such as a {@code NewSessionResponse} from a {@code @Prompt} method, and a {@code null} value + * produces no response, which is answered with an internal error ({@code -32603}). An + * {@code @ExtRequest} method that returns one of these types has it sent as its result. * * @author Mark Pollack - * @since 1.0.0 */ public class DirectResponseHandler implements ReturnValueHandler { + /** The response types of the ACP methods an annotated handler can serve. */ + private static final List> RESPONSE_TYPES = List.of(InitializeResponse.class, AuthenticateResponse.class, + LogoutResponse.class, NewSessionResponse.class, LoadSessionResponse.class, PromptResponse.class, + SetSessionModeResponse.class, ListSessionsResponse.class, CloseSessionResponse.class, + DeleteSessionResponse.class, ResumeSessionResponse.class, ForkSessionResponse.class, + SetSessionConfigOptionResponse.class, ListProvidersResponse.class, SetProviderResponse.class, + DisableProviderResponse.class); + @Override - @SuppressWarnings("removal") // recognizes the deprecated-for-removal SetSessionModelResponse public boolean supportsReturnType(AcpMethodParameter returnType) { Class type = returnType.getParameterType(); - return InitializeResponse.class.isAssignableFrom(type) - || LogoutResponse.class.isAssignableFrom(type) - || NewSessionResponse.class.isAssignableFrom(type) - || LoadSessionResponse.class.isAssignableFrom(type) - || PromptResponse.class.isAssignableFrom(type) - || SetSessionModeResponse.class.isAssignableFrom(type) - || SetSessionModelResponse.class.isAssignableFrom(type) - || ListSessionsResponse.class.isAssignableFrom(type) - || CloseSessionResponse.class.isAssignableFrom(type) - || DeleteSessionResponse.class.isAssignableFrom(type) - || ResumeSessionResponse.class.isAssignableFrom(type) - || ForkSessionResponse.class.isAssignableFrom(type) - || SetSessionConfigOptionResponse.class.isAssignableFrom(type) - || ListProvidersResponse.class.isAssignableFrom(type) - || SetProviderResponse.class.isAssignableFrom(type) - || DisableProviderResponse.class.isAssignableFrom(type); + return RESPONSE_TYPES.stream().anyMatch(responseType -> responseType.isAssignableFrom(type)); } @Override - public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { // Direct passthrough - no conversion needed return returnValue; } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ExtensionResultHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ExtensionResultHandler.java new file mode 100644 index 00000000..caf1af6b --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ExtensionResultHandler.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.handler; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import org.jspecify.annotations.Nullable; + +/** + * Handles the return value of an {@link ExtRequest @ExtRequest} method: it passes the value through + * unchanged, and the JSON mapper writes it as the result of the extension request. A record, a + * {@code Map} or any other value the mapper can write works. It is one of the built-in + * {@link ReturnValueHandler}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: the + * method declares the return type. + * + *

It works as {@link AsyncValueHandler} does, for extension requests, and is asked last, so it + * receives only what the other built-in handlers leave: a {@code Mono} or {@code CompletionStage} + * is awaited first, and a {@code String} or an ACP response passes through the handler for it, with + * the same result. A {@code null} result is answered with an internal error ({@code -32603}), so + * return an empty map when there is nothing to return. Building the agent rejects a {@code void} + * {@code @ExtRequest} method, and an {@code @ExtNotification} method that returns a value. + * + * @author Mark Pollack + */ +public class ExtensionResultHandler implements ReturnValueHandler { + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + return returnType.getMethod().isAnnotationPresent(ExtRequest.class); + } + + @Override + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + return returnValue; + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/MonoHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/MonoHandler.java deleted file mode 100644 index e8c22cfe..00000000 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/MonoHandler.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.support.handler; - -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; - -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; - -import reactor.core.publisher.Mono; - -/** - * Handles {@link Mono} return types for async handlers. - * - *

The Mono is unwrapped (blocked) to get the actual response value. - * For true reactive operation, use the reactive agent API directly. - * - * @author Mark Pollack - * @since 1.0.0 - */ -public class MonoHandler implements ReturnValueHandler { - - @Override - public boolean supportsReturnType(AcpMethodParameter returnType) { - return Mono.class.isAssignableFrom(returnType.getParameterType()); - } - - @Override - public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { - if (returnValue == null) { - return null; - } - - @SuppressWarnings("unchecked") - Mono mono = (Mono) returnValue; - - // Block to get the actual response - // Note: For fully reactive operation, use the reactive agent API - return mono.block(); - } - - /** - * Get the generic type parameter of the Mono (e.g., PromptResponse from Mono<PromptResponse>). - * @param returnType the return type parameter - * @return the generic type, or Object if not determinable - */ - public static Class getMonoGenericType(AcpMethodParameter returnType) { - Type genericType = returnType.getGenericType(); - if (genericType instanceof ParameterizedType pt) { - Type[] typeArgs = pt.getActualTypeArguments(); - if (typeArgs.length > 0 && typeArgs[0] instanceof Class c) { - return c; - } - } - return Object.class; - } - -} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandler.java index 65611056..1aa49dcd 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandler.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandler.java @@ -4,37 +4,58 @@ package com.agentclientprotocol.sdk.agent.support.handler; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import org.jspecify.annotations.Nullable; /** - * Strategy interface for handling method return values and converting - * them to protocol responses. + * Turns what a handler method returned into the response of its ACP method. Implement one for a + * return type the built-in handlers do not accept, such as another library's async type, and + * register it with + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport.Builder#returnValueHandler + * AcpAgentSupport.Builder.returnValueHandler(..)}. The built-in handlers accept the method's + * response itself, {@code void} and {@code String} from a {@code @Prompt} method, a {@code Mono}, + * {@code CompletionStage} or single-value {@code Publisher} of those, and an extension method's + * result. * - *

Implementations of this interface are responsible for converting - * handler method return values to the appropriate protocol response types. + *

For each call, the runtime uses the first handler whose {@link #supportsReturnType} returns + * {@code true}: custom handlers in the order they were added, then the built-in ones, so a custom + * handler can also replace a built-in one. A return type that a custom handler supports is not + * checked when the agent is built. A handler for an async type can wait for the value and pass it + * to a built-in handler, such as {@link StringToPromptResponseHandler} for a {@code String} from a + * {@code @Prompt} method. + * + *

Implementations must be thread-safe: one instance serves every call, from every session and + * connection, concurrently. {@link #handleReturnValue} runs on the call's handler thread and may + * block there, as the built-in handler does to wait for a {@code Mono}. * * @author Mark Pollack - * @since 1.0.0 * @see ReturnValueHandlerComposite */ public interface ReturnValueHandler { /** - * Determine whether this handler supports the given return type. - * @param returnType the return type metadata - * @return true if this handler can process the return type + * Returns whether this handler accepts the return type. Decide from the type only: it is asked + * when the agent is built and on every call. + * @param returnType the handler method's return type + * @return true if {@link #handleReturnValue} can turn values of it into the response */ boolean supportsReturnType(AcpMethodParameter returnType); /** - * Handle the return value and convert to protocol response. - * @param returnValue the value returned by the handler method - * @param returnType the return type metadata - * @param context the invocation context - * @return the protocol response - * @throws ReturnValueHandlingException if handling fails + * Turns {@code returnValue} into the response sent to the client. For a request, the result + * must be the ACP method's response type, such as a {@code PromptResponse}; another type, or + * null, is answered with an internal error ({@code -32603}). For a notification, the result is + * ignored. + * @param returnValue what the method returned, after the interceptors' {@code postInvoke}; null + * for a {@code void} method + * @param returnType the handler method's return type + * @param context the call's context + * @return the response + * @throws ReturnValueHandlingException if the value cannot be turned into a response; the call + * fails with an internal error ({@code -32603}), and the exception is logged at the agent */ - Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context); + @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context); } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlerComposite.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlerComposite.java index 5517f3f1..a44aeddf 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlerComposite.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlerComposite.java @@ -7,26 +7,31 @@ import java.util.ArrayList; import java.util.List; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import org.jspecify.annotations.Nullable; /** - * Composite that chains multiple return value handlers. + * A {@link ReturnValueHandler} that asks a list of handlers in order and uses the first that + * supports a return type. The annotation runtime builds one each time an agent is built, with the + * custom handlers first and the built-in ones after, and turns every handler method's result into + * its response through it. Applications register handlers on {@code AcpAgentSupport.Builder} and do + * not need this class. * - *

NOTE: Unlike argument resolvers, NO caching is used here. - * Return types vary more and caching provides less benefit. + *

Unlike {@link com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolverComposite}, + * it remembers nothing: it asks the handlers again on every call. Handling is thread-safe once the + * handlers are added; adding handlers is not, so add them all before the first use. * * @author Mark Pollack - * @since 1.0.0 */ public class ReturnValueHandlerComposite implements ReturnValueHandler { private final List handlers = new ArrayList<>(); /** - * Add a handler to the chain. - * @param handler the handler to add - * @return this composite for chaining + * Adds a handler, asked after the ones added before. + * @param handler the handler, not null + * @return this composite */ public ReturnValueHandlerComposite addHandler(ReturnValueHandler handler) { this.handlers.add(handler); @@ -34,9 +39,9 @@ public ReturnValueHandlerComposite addHandler(ReturnValueHandler handler) { } /** - * Add multiple handlers to the chain. - * @param handlers the handlers to add - * @return this composite for chaining + * Adds handlers, in list order, asked after the ones added before. + * @param handlers the handlers, none of them null + * @return this composite */ public ReturnValueHandlerComposite addHandlers(List handlers) { this.handlers.addAll(handlers); @@ -44,8 +49,8 @@ public ReturnValueHandlerComposite addHandlers(List handlers } /** - * Get the list of registered handlers. - * @return unmodifiable list of handlers + * Returns the handlers in the order they are asked. + * @return an unmodifiable copy of the handlers */ public List getHandlers() { return List.copyOf(handlers); @@ -56,8 +61,20 @@ public boolean supportsReturnType(AcpMethodParameter returnType) { return findHandler(returnType) != null; } + /** + * {@inheritDoc} + * + *

Delegates to the first handler that supports the return type. + * @param returnValue {@inheritDoc} + * @param returnType {@inheritDoc} + * @param context {@inheritDoc} + * @return {@inheritDoc} + * @throws ReturnValueHandlingException if no handler supports the return type, or the handler + * throws it + */ @Override - public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { ReturnValueHandler handler = findHandler(returnType); if (handler == null) { throw new ReturnValueHandlingException( @@ -66,7 +83,7 @@ public Object handleReturnValue(Object returnValue, AcpMethodParameter returnTyp return handler.handleReturnValue(returnValue, returnType, context); } - private ReturnValueHandler findHandler(AcpMethodParameter returnType) { + private @Nullable ReturnValueHandler findHandler(AcpMethodParameter returnType) { for (ReturnValueHandler handler : handlers) { if (handler.supportsReturnType(returnType)) { return handler; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlingException.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlingException.java index 55211d13..81f7676a 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlingException.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/ReturnValueHandlingException.java @@ -5,25 +5,29 @@ package com.agentclientprotocol.sdk.agent.support.handler; /** - * Exception thrown when return value handling fails. + * Thrown when what a handler method returned cannot be turned into its response: no return value + * handler accepts the type, or the value does not suit the method, such as a {@code String} + * returned outside a prompt turn. The call fails, and the client receives an internal error + * ({@code -32603}) with the message "Internal error"; this exception is logged at the agent. Building + * the agent rejects most unsuitable return types up front, so at run time it usually comes from a + * custom {@link ReturnValueHandler}, which throws it for the same purpose. * * @author Mark Pollack - * @since 1.0.0 */ public class ReturnValueHandlingException extends RuntimeException { /** - * Create a new exception with a message. - * @param message the error message + * Creates the exception. + * @param message what could not be handled, sent to the client */ public ReturnValueHandlingException(String message) { super(message); } /** - * Create a new exception with a message and cause. - * @param message the error message - * @param cause the underlying cause + * Creates the exception with the failure that caused it. + * @param message what could not be handled, sent to the client + * @param cause the underlying failure */ public ReturnValueHandlingException(String message, Throwable cause) { super(message, cause); diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/StringToPromptResponseHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/StringToPromptResponseHandler.java index 9022035a..aa514713 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/StringToPromptResponseHandler.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/StringToPromptResponseHandler.java @@ -4,32 +4,65 @@ package com.agentclientprotocol.sdk.agent.support.handler; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import org.jspecify.annotations.Nullable; /** - * Converts String return values to {@link PromptResponse} using - * {@link PromptResponse#text(String)}. + * Handles a {@code String} that a {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} + * method returns: it sends the text to the client as one {@code agent_message_chunk} session update + * of the prompt's session, through the turn's {@link SyncPromptContext}, then ends the turn with + * {@link PromptResponse#endTurn()}. A prompt response carries no content, so the update is how the + * text reaches the client. A {@code null} or empty string sends nothing and still ends the turn. It + * is one of the built-in {@link ReturnValueHandler}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: the method declares the return type. * - *

This handler only applies to prompt handlers (session/prompt method). + *

It works as {@link AsyncValueHandler} does, for a declared {@code String} return type; a + * {@code Mono} or {@code CompletionStage} of {@code String} reaches the same conversion through + * {@code AsyncValueHandler}. After {@code session/cancel} the agent sends stop reason + * {@code cancelled} instead of {@code end_turn}, as ACP requires: the SDK answers + * {@code cancelled} once the cancel was received. If the agent has already answered the prompt, because the cancel grace period or the + * maximum prompt duration passed, the text is dropped. Outside a prompt method the string passes + * through unchanged: an {@code @ExtRequest} method's string is its result, a notification method's + * is ignored, and building the agent rejects a {@code String} return type on any other request + * method. With a context that has no prompt context, as a context built by hand can be, it throws a + * {@link ReturnValueHandlingException} for a non-empty string. * * @author Mark Pollack - * @since 1.0.0 */ public class StringToPromptResponseHandler implements ReturnValueHandler { + /** + * Sends {@code text}, unless null or empty, to the client as an agent message chunk of the + * prompt's session, and ends the turn. + * @param text the text a prompt handler produced + * @param context the prompt's invocation context + * @return {@link PromptResponse#endTurn()} + */ + static PromptResponse endTurnWith(@Nullable String text, AcpInvocationContext context) { + if (text != null && !text.isEmpty()) { + SyncPromptContext prompt = context.getSyncPromptContext() + .orElseThrow(() -> new ReturnValueHandlingException( + "No prompt context to send the returned text to the client")); + prompt.sendMessage(text); + } + return PromptResponse.endTurn(); + } + @Override public boolean supportsReturnType(AcpMethodParameter returnType) { return String.class.equals(returnType.getParameterType()); } @Override - public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { // Only convert to PromptResponse for prompt handlers if ("session/prompt".equals(context.getAcpMethod())) { - String text = (String) returnValue; - return text != null ? PromptResponse.text(text) : PromptResponse.endTurn(); + return endTurnWith((String) returnValue, context); } // For other methods, return as-is (may cause error if unexpected) return returnValue; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/VoidHandler.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/VoidHandler.java index 10566a10..e63ed760 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/VoidHandler.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/VoidHandler.java @@ -4,16 +4,27 @@ package com.agentclientprotocol.sdk.agent.support.handler; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import org.jspecify.annotations.Nullable; /** - * Handles void return types for prompt handlers by returning - * {@link PromptResponse#endTurn()}. + * Handles a {@code void} or {@code Void} return type: for a + * {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} method it ends the turn with + * {@link PromptResponse#endTurn()}, and for a notification method ({@code @Cancel}, + * {@code @ExtNotification}) it produces nothing, as a notification gets no answer. It is one of the + * built-in {@link ReturnValueHandler}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: the method declares the return type. + * + *

It works as {@link AsyncValueHandler} does, for a method that returns nothing. A request + * method other than a {@code @Prompt} one cannot be {@code void}: building the agent rejects it, + * because a request must get a result. After {@code session/cancel} the agent sends stop reason + * {@code cancelled} instead of {@code end_turn}, as ACP requires: the SDK answers + * {@code cancelled} once the cancel was received. * * @author Mark Pollack - * @since 1.0.0 */ public class VoidHandler implements ReturnValueHandler { @@ -24,7 +35,8 @@ public boolean supportsReturnType(AcpMethodParameter returnType) { } @Override - public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, AcpInvocationContext context) { + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { // Only convert to PromptResponse for prompt handlers if ("session/prompt".equals(context.getAcpMethod())) { return PromptResponse.endTurn(); diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/package-info.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/package-info.java new file mode 100644 index 00000000..a002b3e9 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/handler/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Return-value handlers that turn a handler method's result into an ACP response. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.support.handler; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/AcpInterceptor.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/AcpInterceptor.java index 8fcaf34c..33d96da5 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/AcpInterceptor.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/AcpInterceptor.java @@ -4,79 +4,170 @@ package com.agentclientprotocol.sdk.agent.support.interceptor; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import org.jspecify.annotations.Nullable; /** - * Interceptor interface for ACP handler invocations. + * Runs code around every call of an annotated agent's handler methods, for concerns that cut across + * them: logging, metrics, tracing, an access check, or a fallback answer when a method fails. + * Register one with + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport.Builder#interceptor + * AcpAgentSupport.Builder.interceptor(..)}; the SDK's Spring Boot, Quarkus and Micronaut modules + * also register the application's interceptor beans. Every method has a default that does nothing, + * so implement only the ones you need. * - *

Interceptors allow cross-cutting concerns to be applied to handler - * invocations. Common uses include logging, metrics, authentication, - * and error handling. - * - *

The lifecycle methods are called in this order: + *

For each call, interceptors run in {@link #getOrder()} order, lowest first, and those with the + * same order in the order they were added: *

    - *
  1. {@link #preInvoke} - before handler invocation (all interceptors)
  2. - *
  3. Handler method execution
  4. - *
  5. {@link #postInvoke} - after successful invocation (reverse order)
  6. - *
  7. OR {@link #onError} - if an exception occurred (reverse order)
  8. - *
  9. {@link #afterCompletion} - always called for cleanup (reverse order)
  10. + *
  11. {@link #preInvoke} on each, in order. One that returns {@code false} stops the call: the + * handler method is not called, a request is answered with an internal error ({@code -32603}), + * and a notification is dropped.
  12. + *
  13. The argument resolvers, then the handler method.
  14. + *
  15. {@link #postInvoke} on each, in reverse order, with the value the method returned, before a + * return value handler turns it into the response.
  16. + *
  17. If {@code preInvoke}, an argument resolver, the method or the return value handler threw: + * {@link #onError}, in reverse order, on each interceptor whose {@code preInvoke} returned + * {@code true}, until one returns a replacement result or throws.
  18. + *
  19. {@link #afterCompletion}, in reverse order, on each interceptor whose {@code preInvoke} + * returned {@code true}, once, however the call ended, with the exception it failed with, if + * any.
  20. *
* + *

Interceptors see every handler method call, extension methods and the {@code session/cancel} + * notification included, and the answers the SDK gives itself: the {@code initialize} answer + * derived from the annotations, and the default {@code session/new} answer given when the agent + * has no {@code @NewSession} method. The {@link AcpInvocationContext} names the ACP method and carries the request, the + * session id, and attributes that pass state from one step of a call to a later one. + * + *

Example: log how long each call takes. + *

{@code
+ * class TimingInterceptor implements AcpInterceptor {
+ *
+ *     private static final System.Logger log = System.getLogger("acp.timing");
+ *
+ *     @Override
+ *     public boolean preInvoke(AcpInvocationContext context) {
+ *         context.setAttribute("start", System.nanoTime());
+ *         return true;
+ *     }
+ *
+ *     @Override
+ *     public void afterCompletion(AcpInvocationContext context, Throwable ex) {
+ *         long start = context.getAttribute("start", Long.class).orElseThrow();
+ *         long millis = (System.nanoTime() - start) / 1_000_000;
+ *         log.log(System.Logger.Level.INFO, "{0} took {1} ms", context.getAcpMethod(), millis);
+ *     }
+ * }
+ * }
+ * + *

Implementations must be thread-safe: one instance serves every call, from every session and + * connection, concurrently. The steps of one call run on that call's handler thread, so they may + * block; keep per-call state in the context's attributes, not in fields. An exception thrown from + * {@code postInvoke} or {@code afterCompletion} is logged and ignored; one thrown from + * {@code preInvoke} fails the call like an exception from the handler method, and one thrown from + * {@code onError} replaces the failure (see {@link #onError}). + * * @author Mark Pollack - * @since 1.0.0 * @see InterceptorChain */ public interface AcpInterceptor { /** - * Default order value for interceptors (middle priority). + * The order {@link #getOrder()} returns by default, {@value}. */ int DEFAULT_ORDER = 0; /** - * Get the order of this interceptor. Lower values execute first. - * @return the order value + * Returns this interceptor's place among the others: lower values run {@code preInvoke} earlier + * and {@code postInvoke}, {@code onError} and {@code afterCompletion} later. Interceptors with + * the same order run in the order they were added. It is read for every call, so return a + * constant. + * @implSpec Returns {@link #DEFAULT_ORDER}. + * @return the order, lowest first */ default int getOrder() { return DEFAULT_ORDER; } /** - * Called before handler invocation. - * @param context the invocation context - * @return true to continue, false to abort invocation + * Called before the handler method's arguments are resolved. Return {@code false} to stop the + * call: the handler method is not called, a request is answered with an internal error + * ({@code -32603}) that says the call was vetoed, and a notification is dropped. To reject a + * request with a specific error instead, throw an {@code AcpProtocolException} with that code: + * the call fails with it unless an {@link #onError} returns a replacement. + * @implSpec Returns {@code true}. + * @param context the call's context + * @return {@code true} to go on, {@code false} to stop the call */ default boolean preInvoke(AcpInvocationContext context) { return true; } /** - * Called after successful handler invocation. - * @param context the invocation context - * @param result the handler result - * @return the (possibly modified) result + * Called after the handler method returned, with the value it returned, before a return value + * handler turns it into the response: the response itself, or what the method declared, such as + * a {@code Mono}, a {@code String} from a {@code @Prompt} method, or null for a {@code void} + * method. Return that value or a replacement for it; the next interceptor, then the return + * value handler, receives what you return. Not called when the method threw or a + * {@code preInvoke} stopped the call. An exception thrown here is logged and ignored, and the + * next interceptor gets the value unchanged. + * @implSpec Returns {@code result} unchanged. + * @param context the call's context + * @param result what the handler method returned, or what the previous interceptor returned + * @return the value to use from now on */ - default Object postInvoke(AcpInvocationContext context, Object result) { + default @Nullable Object postInvoke(AcpInvocationContext context, @Nullable Object result) { return result; } /** - * Called when handler throws an exception. - * @param context the invocation context - * @param ex the exception - * @return replacement result (non-null to suppress exception), or null to propagate + * Called when {@code preInvoke}, an argument resolver, the handler method or the return value + * handler threw. Return null to leave the failure as it is: if every interceptor does, a + * request's client receives the error the exception maps to ({@code AcpProtocolException} keeps + * its code, an {@code AcpError} from a call to the client passes that error on, anything else + * is an internal error, {@code -32603}). Return a replacement to answer + * with it instead; the remaining interceptors' {@code onError} are then not called. A + * replacement is used as the result as it is, without a return value handler, so for a request + * it must be the method's response, such as a {@code PromptResponse}; another type is answered + * with an internal error. For a notification, a replacement only ends the failure. + * + *

To answer with a specific JSON-RPC error instead, throw an {@code AcpProtocolException} + * with that code: it becomes the failure the client receives, with its code, message and data, + * and the remaining interceptors' {@code onError} are not called. Any other exception thrown + * here is taken as a fault in the interceptor and answered as an internal error + * ({@code -32603}). Either way the original failure is attached to the thrown exception as + * suppressed, for the log. + * + *

It is called only on the interceptors whose {@code preInvoke} returned {@code true}, the + * same ones that get {@link #afterCompletion}: not on one whose {@code preInvoke} threw or + * returned {@code false}, nor on those after it, which never saw the call. + * @implSpec Returns null. + * @param context the call's context + * @param ex what was thrown, as the step threw it + * @return a replacement result, or null to keep the failure */ - default Object onError(AcpInvocationContext context, Throwable ex) { + default @Nullable Object onError(AcpInvocationContext context, Throwable ex) { return null; } /** - * Called after invocation completes (success or failure). - * Always called for interceptors that had their preInvoke succeed. - * Use for resource cleanup. - * @param context the invocation context + * Called once at the end of every call in which this interceptor's {@code preInvoke} returned + * {@code true}, however the call ended: answered, failed, or stopped by a later interceptor. + * Use it to release what {@code preInvoke} took, such as a timer or a logging context, and to + * record how the call ended, such as a span's status or a failure metric. {@code ex} is what + * the call failed with: what a step threw (a checked exception wrapped in a + * {@code RuntimeException}), or, when an {@link #onError} threw, the + * {@code AcpProtocolException} it threw or the {@code IllegalStateException} that carries + * anything else it threw. It + * is null when the call produced a result, when an {@code onError} returned a replacement, and + * when a {@code preInvoke} returned {@code false}. It does not receive the result; save it as + * a context attribute in {@link #postInvoke} if you need it. An exception thrown here is + * logged and ignored. + * @implSpec Does nothing. + * @param context the call's context + * @param ex the exception the call failed with, or null if it did not fail */ - default void afterCompletion(AcpInvocationContext context) { + default void afterCompletion(AcpInvocationContext context, @Nullable Throwable ex) { // Default: no-op } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/InterceptorChain.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/InterceptorChain.java index 48a66ebe..1d208a65 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/InterceptorChain.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/InterceptorChain.java @@ -8,20 +8,24 @@ import java.util.Comparator; import java.util.List; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Manages interceptor execution with proper lifecycle tracking. - * Ensures afterCompletion is only called for interceptors that started. + * Runs a list of {@link AcpInterceptor}s around one handler method call, in their order, and + * records which ones started so that each gets its {@code afterCompletion} exactly once. The + * annotation runtime creates one for every call; applications do not need it. Its methods carry out + * the order that {@link AcpInterceptor} describes, which is where an interceptor's author reads the + * contract. * - *

Tracks the index of the last successfully invoked interceptor, - * ensuring proper cleanup even when exceptions occur. + *

A chain records how far {@code preInvoke} got, so it serves one call only: create a new one + * for each call. It is not thread-safe. * * @author Mark Pollack - * @since 1.0.0 */ public class InterceptorChain { @@ -32,9 +36,10 @@ public class InterceptorChain { private int interceptorIndex = -1; /** - * Create an interceptor chain with the given interceptors. - * Interceptors are sorted by order (lower values first). - * @param interceptors the interceptors + * Creates a chain over a copy of {@code interceptors}, sorted by + * {@link AcpInterceptor#getOrder()}, lowest first; interceptors with the same order keep their + * order in the list. + * @param interceptors the interceptors, in the order they were added */ public InterceptorChain(List interceptors) { this.interceptors = new ArrayList<>(interceptors); @@ -42,36 +47,33 @@ public InterceptorChain(List interceptors) { } /** - * Apply preInvoke to all interceptors. - * @param context the invocation context - * @return true to continue, false to abort + * Calls {@code preInvoke} on each interceptor in order, stopping at the first that returns + * {@code false}. An exception from {@code preInvoke} reaches the caller. However the call then + * ends, the caller must call {@link #triggerAfterCompletion} once, which covers the + * interceptors whose {@code preInvoke} returned {@code true}. + * @param context the call's context + * @return {@code true} if every interceptor returned {@code true}, {@code false} if one stopped + * the call */ public boolean applyPreInvoke(AcpInvocationContext context) { for (int i = 0; i < interceptors.size(); i++) { - AcpInterceptor interceptor = interceptors.get(i); - try { - if (!interceptor.preInvoke(context)) { - // Short-circuit: trigger cleanup for completed interceptors - triggerAfterCompletion(context, null); - return false; - } - this.interceptorIndex = i; - } - catch (Exception e) { - triggerAfterCompletion(context, e); - throw e; + if (!interceptors.get(i).preInvoke(context)) { + return false; } + this.interceptorIndex = i; } return true; } /** - * Apply postInvoke in reverse order. - * @param context the invocation context - * @param result the handler result - * @return the (possibly modified) result + * Calls {@code postInvoke} on each interceptor in reverse order, passing each the value the + * previous one returned. An exception from an interceptor is logged, and the value goes on + * unchanged. + * @param context the call's context + * @param result what the handler method returned + * @return the value the last interceptor returned */ - public Object applyPostInvoke(AcpInvocationContext context, Object result) { + public @Nullable Object applyPostInvoke(AcpInvocationContext context, @Nullable Object result) { for (int i = interceptors.size() - 1; i >= 0; i--) { try { result = interceptors.get(i).postInvoke(context, result); @@ -85,37 +87,59 @@ public Object applyPostInvoke(AcpInvocationContext context, Object result) { } /** - * Apply onError in reverse order until one handles it. - * @param context the invocation context - * @param ex the exception - * @return replacement result, or null to propagate exception + * Calls {@code onError}, in reverse order, on each interceptor whose {@code preInvoke} + * returned {@code true}, the same interceptors {@link #triggerAfterCompletion} reaches, until + * one returns a replacement. What an interceptor throws ends the walk (see + * {@link AcpInterceptor#onError}): an {@code AcpProtocolException} is rethrown as the call's + * answer, and anything else as an {@link IllegalStateException} whose cause it is, a fault in + * the interceptor; either carries {@code ex} as suppressed. + * @param context the call's context + * @param ex what the call threw + * @return the first replacement, or null if no interceptor gave one + * @throws AcpProtocolException if an interceptor threw one + * @throws IllegalStateException if an interceptor threw anything else */ - public Object applyOnError(AcpInvocationContext context, Throwable ex) { - for (int i = interceptors.size() - 1; i >= 0; i--) { + public @Nullable Object applyOnError(AcpInvocationContext context, Throwable ex) { + for (int i = this.interceptorIndex; i >= 0; i--) { + Object replacement; try { - Object replacement = interceptors.get(i).onError(context, ex); - if (replacement != null) { - return replacement; - } + replacement = interceptors.get(i).onError(context, ex); } - catch (Exception e) { - log.warn("Interceptor onError threw exception", e); - // Continue with other interceptors + catch (AcpProtocolException answer) { + throw withSuppressed(answer, ex); + } + catch (RuntimeException fault) { + throw withSuppressed(new IllegalStateException( + "An interceptor's onError failed for " + context.getAcpMethod(), fault), ex); + } + if (replacement != null) { + return replacement; } } return null; } + @SuppressWarnings("ReferenceEquality") // the same exception instance, rethrown, cannot suppress itself + private static RuntimeException withSuppressed(RuntimeException thrown, Throwable failure) { + if (thrown != failure) { + thrown.addSuppressed(failure); + } + return thrown; + } + /** - * Trigger afterCompletion for all started interceptors. - * Called in finally block - must not throw. - * @param context the invocation context - * @param ex the exception (may be null) + * Calls {@code afterCompletion}, in reverse order, on each interceptor whose {@code preInvoke} + * returned {@code true}, and resets the chain, so a second call does nothing. Never throws: + * what an interceptor throws is logged. + * @param context the call's context + * @param ex the exception the call failed with, or null; it is passed to each interceptor */ - public void triggerAfterCompletion(AcpInvocationContext context, Throwable ex) { - for (int i = this.interceptorIndex; i >= 0; i--) { + public void triggerAfterCompletion(AcpInvocationContext context, @Nullable Throwable ex) { + int started = this.interceptorIndex; + this.interceptorIndex = -1; + for (int i = started; i >= 0; i--) { try { - interceptors.get(i).afterCompletion(context); + interceptors.get(i).afterCompletion(context, ex); } catch (Throwable t) { log.warn("Interceptor afterCompletion threw exception", t); diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/package-info.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/package-info.java new file mode 100644 index 00000000..41610b25 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/interceptor/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Interceptors that run around annotated handler method invocations. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.support.interceptor; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpInvocationContext.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpInvocationContext.java new file mode 100644 index 00000000..e458a27c --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpInvocationContext.java @@ -0,0 +1,297 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.invocation; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import org.jspecify.annotations.Nullable; + +/** + * What one call of an annotated agent's handler method is about: the ACP method, the request, the + * session id, the prompt turn's context, and the connection's capabilities and agent. The + * annotation runtime creates one for each call and passes it to every + * {@link com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor interceptor}, + * {@link com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver argument resolver} and + * {@link com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler return value handler} + * of that call. Read it to decide what to do, and use its attributes to pass state between the + * steps of one call, such as a start time from {@code preInvoke} to {@code afterCompletion}. + * + *

What is present depends on the ACP method. {@link #getSessionId()} is present for the methods + * of a session: {@code session/load}, {@code session/prompt}, {@code session/set_mode}, + * {@code session/set_config_option}, {@code session/close}, {@code session/delete}, + * {@code session/resume}, {@code session/fork} and {@code session/cancel}. The prompt contexts are + * present in {@code session/prompt} only. In a context the runtime creates, {@link #getAgent()} is + * always present and {@link #getCapabilities()} is present once the client has sent + * {@code initialize}; under {@code AcpAgentSupport.Builder.buildFactory()} they are those of the + * connection the request arrived on. + * + *

A context belongs to one call and is used on that call's handler thread; it is not + * thread-safe. Its {@link Builder} lets a test of a custom resolver, handler or interceptor create + * one. + * + * @author Mark Pollack + */ +public final class AcpInvocationContext { + + private final String acpMethod; + + private final Object request; + + private final @Nullable String sessionId; + + private final @Nullable PromptContext promptContext; + + private final @Nullable SyncPromptContext syncPromptContext; + + private final @Nullable NegotiatedCapabilities capabilities; + + private final @Nullable AcpSyncAgent agent; + + private final Map attributes = new HashMap<>(); + + private AcpInvocationContext(Builder builder) { + this.acpMethod = Objects.requireNonNull(builder.acpMethod, "acpMethod is required"); + this.request = Objects.requireNonNull(builder.request, "request is required"); + this.sessionId = builder.sessionId; + this.promptContext = builder.promptContext; + this.syncPromptContext = builder.syncPromptContext; + this.capabilities = builder.capabilities; + this.agent = builder.agent; + } + + /** + * Starts a builder, for tests of a custom resolver, return value handler or interceptor; the + * runtime builds the contexts it passes itself. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the ACP method being called, such as {@code "session/prompt"}, or the name of an + * extension method, which starts with {@code _}. + * @return the ACP method name + */ + public String getAcpMethod() { + return acpMethod; + } + + /** + * Returns the request: the ACP method's request record, such as a {@code PromptRequest}; the + * {@code CancelNotification} for {@code session/cancel}; or, for an extension method, its + * params, read as the type of the method's params parameter (the raw JSON value, as maps, + * lists, strings and numbers, when it takes none). + * @return the request + */ + public Object getRequest() { + return request; + } + + /** + * Returns the request as a {@code type}. The cast is not checked in this method: a request of + * another type fails with a {@link ClassCastException} where the result is used as a {@code T}. + * Check {@link #getAcpMethod()}, or {@code type.isInstance(getRequest())}, first. + * @param type the request type + * @param the request type + * @return the request, cast to {@code type} without a check + */ + @SuppressWarnings("unchecked") + public T getRequest(Class type) { + return (T) request; + } + + /** + * Returns the session id of a session method's request (see the class comment for the methods). + * @return the session id, or empty for {@code initialize}, {@code authenticate}, + * {@code logout}, {@code session/new}, {@code session/list}, the provider methods and extension + * methods + */ + public Optional getSessionId() { + return Optional.ofNullable(sessionId); + } + + /** + * Returns the prompt turn's context with calls that return a {@code Mono}. It is the same turn + * as {@link #getSyncPromptContext()}. + * @return the prompt context, present only in {@code session/prompt} + */ + public Optional getPromptContext() { + return Optional.ofNullable(promptContext); + } + + /** + * Returns the prompt turn's context with blocking calls, such as {@code sendMessage} and + * {@code readFile}. + * @return the prompt context, present only in {@code session/prompt} + */ + public Optional getSyncPromptContext() { + return Optional.ofNullable(syncPromptContext); + } + + /** + * Returns the capabilities negotiated on this call's connection: what the client offered in its + * {@code initialize} request. + * @return the capabilities, present for every call once the client has sent {@code initialize}, + * the {@code initialize} call included + */ + public Optional getCapabilities() { + return Optional.ofNullable(capabilities); + } + + /** + * Returns the agent serving this call's connection; under + * {@code AcpAgentSupport.Builder.buildFactory()}, the agent of the connection the request + * arrived on. Use it to send session updates and requests to that client. + * @return the agent, present in every context the runtime creates + */ + public Optional getAgent() { + return Optional.ofNullable(agent); + } + + /** + * Stores {@code value} under {@code name} for the rest of this call, replacing what was stored + * there. Attributes live as long as the context, one call; no other call sees them. + * @param name the attribute name + * @param value the value + */ + public void setAttribute(String name, Object value) { + attributes.put(name, value); + } + + /** + * Returns the value stored under {@code name} in this call. + * @param name the attribute name + * @return the value, or empty if none is stored + */ + public Optional getAttribute(String name) { + return Optional.ofNullable(attributes.get(name)); + } + + /** + * Returns the value stored under {@code name} in this call, if it is a {@code type}. + * @param name the attribute name + * @param type the expected type + * @param the expected type + * @return the value, or empty if none is stored or it is of another type + */ + @SuppressWarnings("unchecked") + public Optional getAttribute(String name, Class type) { + Object value = attributes.get(name); + if (type.isInstance(value)) { + return Optional.of((T) value); + } + return Optional.empty(); + } + + /** + * Builds an {@link AcpInvocationContext}, for tests. The ACP method and the request are + * required; everything else is absent unless set. + */ + public static class Builder { + + private @Nullable String acpMethod; + + private @Nullable Object request; + + private @Nullable String sessionId; + + private @Nullable PromptContext promptContext; + + private @Nullable SyncPromptContext syncPromptContext; + + private @Nullable NegotiatedCapabilities capabilities; + + private @Nullable AcpSyncAgent agent; + + /** + * Sets the ACP method; required. + * @param acpMethod the ACP method name, such as {@code "session/prompt"} + * @return this builder + */ + public Builder acpMethod(String acpMethod) { + this.acpMethod = acpMethod; + return this; + } + + /** + * Sets the request; required. + * @param request the request, such as a {@code PromptRequest} + * @return this builder + */ + public Builder request(Object request) { + this.request = request; + return this; + } + + /** + * Sets the session id. + * @param sessionId the session id, or null for none + * @return this builder + */ + public Builder sessionId(@Nullable String sessionId) { + this.sessionId = sessionId; + return this; + } + + /** + * Sets the prompt turn's context with calls that return a {@code Mono}. + * @param promptContext the context, or null for none + * @return this builder + */ + public Builder promptContext(@Nullable PromptContext promptContext) { + this.promptContext = promptContext; + return this; + } + + /** + * Sets the prompt turn's context with blocking calls. + * @param syncPromptContext the context, or null for none + * @return this builder + */ + public Builder syncPromptContext(@Nullable SyncPromptContext syncPromptContext) { + this.syncPromptContext = syncPromptContext; + return this; + } + + /** + * Sets the capabilities negotiated on the connection. + * @param capabilities the capabilities, or null for none + * @return this builder + */ + public Builder capabilities(@Nullable NegotiatedCapabilities capabilities) { + this.capabilities = capabilities; + return this; + } + + /** + * Sets the agent serving the connection. + * @param agent the agent, or null for none + * @return this builder + */ + public Builder agent(@Nullable AcpSyncAgent agent) { + this.agent = agent; + return this; + } + + /** + * Builds the context. + * @return the context, with no attributes + * @throws NullPointerException if the ACP method or the request is not set + */ + public AcpInvocationContext build() { + return new AcpInvocationContext(this); + } + + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpMethodParameter.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpMethodParameter.java new file mode 100644 index 00000000..1fc381dd --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/AcpMethodParameter.java @@ -0,0 +1,182 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.invocation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +/** + * Describes one parameter of a handler method, or its return type: its type, generic type, + * annotations and position. The annotation runtime creates one for each parameter and for the + * return type when it finds a handler method. Argument resolvers and return value handlers receive + * it, decide from it whether they handle that parameter or return type ({@code supportsParameter}, + * {@code supportsReturnType}), and then supply the value or the response. + * + *

Two descriptions are equal when they describe the same position (a parameter index, or the + * return type) of the same method; the composite argument resolver keeps its choice of resolver by + * this key. The values are read once, at construction. + * + * @author Mark Pollack + */ +public final class AcpMethodParameter { + + private final Method method; + + private final int index; + + /** The reflected parameter; null for a return type. */ + private final @Nullable Parameter parameter; + + private final Annotation[] annotations; + + private final Class parameterType; + + private final Type genericType; + + /** + * Describes parameter {@code index} of {@code method}. + * @param method the handler method + * @param index the parameter's position, from 0, which must be one of the method's parameters; + * -1 describes the return type, as {@link #forReturnType} does + */ + public AcpMethodParameter(Method method, int index) { + this.method = method; + this.index = index; + Parameter reflected = (index >= 0) ? method.getParameters()[index] : null; + this.parameter = reflected; + this.parameterType = (reflected != null) ? reflected.getType() : method.getReturnType(); + this.genericType = (reflected != null) ? reflected.getParameterizedType() : method.getGenericReturnType(); + this.annotations = (reflected != null) ? reflected.getAnnotations() : new Annotation[0]; + } + + /** + * Describes the return type of {@code method}: its {@link #getParameterType()} is the return + * type ({@code void.class} for {@code void}), its annotations are empty and its name is null. + * @param method the handler method + * @return the description of the return type + */ + public static AcpMethodParameter forReturnType(Method method) { + return new AcpMethodParameter(method, -1); + } + + /** + * Returns the handler method this parameter belongs to. + * @return the method + */ + public Method getMethod() { + return method; + } + + /** + * Returns the parameter's position. + * @return the position, from 0, or -1 for the return type + */ + public int getIndex() { + return index; + } + + /** + * Returns the parameter's name as compiled: the source name if the class was compiled with + * {@code javac -parameters}, otherwise a generated name such as {@code arg0}. Decide on the + * type or an annotation, not on the name. + * @return the name, or null for the return type + */ + public @Nullable String getName() { + return parameter != null ? parameter.getName() : null; + } + + /** + * Returns the parameter's class, or the method's return class for the return type. For a + * generic type this is the raw class, such as {@code Mono} for {@code Mono}. + * @return the class + */ + public Class getParameterType() { + return parameterType; + } + + /** + * Returns the declared type with its type arguments, such as {@code Mono}; for + * a type without arguments, the same class as {@link #getParameterType()}. + * @return the declared type + */ + public Type getGenericType() { + return genericType; + } + + /** + * Returns the parameter's annotations, such as {@code @SessionId}. The array is shared: do not + * change it. + * @return the annotations; empty for the return type (the method's own annotations are on + * {@link #getMethod()}) + */ + public Annotation[] getAnnotations() { + return annotations; + } + + /** + * Returns the parameter's annotation of the given type. + * @param annotationType the annotation type + * @param the annotation type + * @return the annotation, or null if the parameter has none of that type (always null for the + * return type) + */ + public @Nullable A getAnnotation(Class annotationType) { + for (Annotation ann : getAnnotations()) { + if (annotationType.isInstance(ann)) { + return annotationType.cast(ann); + } + } + return null; + } + + /** + * Returns whether the parameter carries an annotation of the given type, as a resolver checks + * for {@code @SessionId}. + * @param annotationType the annotation type + * @return true if the parameter has it + */ + public boolean hasAnnotation(Class annotationType) { + return getAnnotation(annotationType) != null; + } + + /** + * Returns whether this describes the method's return type rather than a parameter. + * @return true for the return type + */ + public boolean isReturnType() { + return index == -1; + } + + // Critical: equals/hashCode for caching + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AcpMethodParameter that)) { + return false; + } + return index == that.index && method.equals(that.method); + } + + @Override + public int hashCode() { + return Objects.hash(method, index); + } + + @Override + public String toString() { + if (isReturnType()) { + return "return type of " + method.getName(); + } + return "parameter " + index + " (" + getName() + ") of " + method.getName(); + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/package-info.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/package-info.java new file mode 100644 index 00000000..58e9828e --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/invocation/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The invocation model shared by argument resolvers, return-value handlers and interceptors: the + * per-call {@link com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext} and the + * {@link com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter} metadata they inspect. + * + *

+ * It sits beneath the {@code resolver}, {@code handler} and {@code interceptor} packages and depends on + * none of them, nor on {@code AcpAgentSupport}, so extension points can be written against it without + * a package cycle. + *

+ * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.support.invocation; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/package-info.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/package-info.java new file mode 100644 index 00000000..4556ad04 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Annotation-driven agents: AcpAgentSupport discovers @AcpAgent handler methods and serves them through an ACP agent. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.support; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AgentResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AgentResolver.java new file mode 100644 index 00000000..c673ee35 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AgentResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.resolver; + +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; + +/** + * Supplies the agent serving the connection that the request or notification arrived on: an + * {@link AcpSyncAgent} parameter receives it, and an {@link AcpAsyncAgent} parameter the async + * agent it wraps ({@code AcpSyncAgent.async()}). Any handler method can take one, extension methods + * included, to send session updates and requests to that client outside a prompt turn, such as a + * {@code ConfigOptionUpdate} after a config change. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter. + * + *

It works as {@link PromptContextResolver} does, for the agent, with no restriction on the + * method: every call has one. When one handler bean serves many connections (an agent factory from + * {@code AcpAgentSupport.Builder.buildFactory()}), each call receives the agent of its own + * connection, so keep it for that call, not in a field. The parameter's type must be exactly + * {@code AcpSyncAgent} or {@code AcpAsyncAgent}. {@link #resolveArgument} throws an + * {@link ArgumentResolutionException} only for a context without an agent, as a context built by + * hand can be. + * + * @author Mark Pollack + * @since 0.80.0 + */ +public class AgentResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + Class type = parameter.getParameterType(); + return type == AcpSyncAgent.class || type == AcpAsyncAgent.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + AcpSyncAgent agent = context.getAgent() + .orElseThrow(() -> new ArgumentResolutionException("No agent in the current context")); + return parameter.getParameterType() == AcpAsyncAgent.class ? agent.async() : agent; + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolutionException.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolutionException.java index 6b8a32b5..7c040e2c 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolutionException.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolutionException.java @@ -5,25 +5,29 @@ package com.agentclientprotocol.sdk.agent.support.resolver; /** - * Exception thrown when argument resolution fails. + * Thrown when a handler-method parameter cannot be supplied for a call: no resolver supports it, or + * the resolver lacks what it needs, such as a session id in a method without a session. The call + * fails, and the client receives an internal error ({@code -32603}) with the message + * "Internal error"; this exception is logged at the agent. Building the agent rejects most such parameters + * up front, so at run time it usually comes from a custom {@link ArgumentResolver}. To answer with + * another error code, a resolver throws an {@code AcpProtocolException} instead. * * @author Mark Pollack - * @since 1.0.0 */ public class ArgumentResolutionException extends RuntimeException { /** - * Create a new exception with a message. - * @param message the error message + * Creates the exception. + * @param message what could not be supplied, sent to the client */ public ArgumentResolutionException(String message) { super(message); } /** - * Create a new exception with a message and cause. - * @param message the error message - * @param cause the underlying cause + * Creates the exception with the failure that caused it. + * @param message what could not be supplied, sent to the client + * @param cause the underlying failure */ public ArgumentResolutionException(String message, Throwable cause) { super(message, cause); diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolver.java index 3664cd2b..a5fa356f 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolver.java @@ -4,39 +4,78 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import org.jspecify.annotations.Nullable; /** - * Strategy interface for resolving method arguments during handler invocation. + * Supplies the value of one kind of handler-method parameter, so that a handler method can declare + * the parameter and receive it on each call. Implement one for a type the built-in resolvers do not + * supply, such as a service of the application, and register it with + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport.Builder#argumentResolver + * AcpAgentSupport.Builder.argumentResolver(..)}. The built-in resolvers supply the parameters that + * {@code @AcpAgent} and each handler annotation list: the request, the {@code @SessionId}, the + * prompt contexts, {@code @ConfigId} and {@code @ConfigValue}, the connection's capabilities and + * agent, and an extension method's params. * - *

Implementations of this interface are responsible for providing values - * for specific types of method parameters. The framework maintains a chain - * of resolvers, and the first resolver that supports a parameter is used. + *

For each parameter, the runtime asks the resolvers in turn and uses the first whose + * {@link #supportsParameter} returns {@code true}: custom resolvers in the order they were added, + * then the built-in ones, so a custom resolver can also replace a built-in one. The choice is made + * once per parameter and kept ({@link ArgumentResolverComposite}); {@link #resolveArgument} is then + * called on every call of the method. A parameter that a custom resolver supports is not checked + * when the agent is built, so making it work is the resolver's job. * - *

Custom resolvers can be registered to support application-specific - * parameter types. + *

Example: give handler methods the application's {@code Clock}. + *

{@code
+ * class ClockResolver implements ArgumentResolver {
+ *
+ *     private final Clock clock;
+ *
+ *     ClockResolver(Clock clock) {
+ *         this.clock = clock;
+ *     }
+ *
+ *     @Override
+ *     public boolean supportsParameter(AcpMethodParameter parameter) {
+ *         return parameter.getParameterType() == Clock.class;
+ *     }
+ *
+ *     @Override
+ *     public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) {
+ *         return clock;
+ *     }
+ * }
+ * }
+ * + *

Implementations must be thread-safe: one instance serves every call, from every session and + * connection, concurrently. To fail a call, throw an {@link ArgumentResolutionException}, which the + * client receives as an internal error ({@code -32603}), or an {@code AcpProtocolException} with + * the error the client should see, such as invalid params ({@code -32602}). * * @author Mark Pollack - * @since 1.0.0 * @see ArgumentResolverComposite */ public interface ArgumentResolver { /** - * Determine whether this resolver supports the given parameter. - * @param parameter the method parameter to check - * @return true if this resolver can resolve the parameter + * Returns whether this resolver supplies {@code parameter}. Decide from the parameter's type + * and annotations only: it may be asked more than once for a parameter, when the agent is built + * and at the first call, and must give the same answer each time. + * @param parameter the handler method parameter + * @return true if {@link #resolveArgument} can supply it */ boolean supportsParameter(AcpMethodParameter parameter); /** - * Resolve the argument value for the given parameter. - * @param parameter the method parameter - * @param context the invocation context - * @return the resolved argument value (may be null) - * @throws ArgumentResolutionException if resolution fails + * Returns the value of {@code parameter} for this call. Called on every call of the handler + * method, on that call's handler thread, for a parameter {@link #supportsParameter} accepted. + * @param parameter the handler method parameter + * @param context the call's context: the request, session id, prompt context and connection + * @return the value, or null to pass null (which fails the call if the parameter is a + * primitive) + * @throws ArgumentResolutionException if the value cannot be supplied; the call fails with an + * internal error ({@code -32603}), and the exception is logged at the agent */ - Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context); + @Nullable Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context); } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolverComposite.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolverComposite.java index 8951e05e..3b98aec5 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolverComposite.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ArgumentResolverComposite.java @@ -6,47 +6,40 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import org.jspecify.annotations.Nullable; /** - * Composite that chains multiple argument resolvers. - * Caches resolver selection per parameter for performance. + * An {@link ArgumentResolver} that asks a list of resolvers in order and uses the first that + * supports a parameter. The annotation runtime builds one each time an agent is built, with the + * custom resolvers first and the built-in ones after, and resolves every handler parameter through + * it. Applications register resolvers on {@code AcpAgentSupport.Builder} and do not need this + * class. * - *

Resolver lookup is cached using {@link AcpMethodParameter} as the - * cache key. The first resolver that supports a parameter is cached and - * reused for subsequent invocations. + *

It remembers, for each {@link AcpMethodParameter}, which resolver supports it, or that none + * does, so it asks the resolvers once per parameter. Resolvers added after a parameter was looked + * up do not change the answer for it; call {@link #clearCache()} then. Looking up and resolving are + * thread-safe; adding resolvers is not, so add them all before the first use. * * @author Mark Pollack - * @since 1.0.0 */ public class ArgumentResolverComposite implements ArgumentResolver { private final List resolvers = new ArrayList<>(); - // Cache: parameter -> resolver - private final ConcurrentMap resolverCache = new ConcurrentHashMap<>(256); - - // Sentinel value for "no resolver found" - private static final ArgumentResolver NO_RESOLVER = new ArgumentResolver() { - @Override - public boolean supportsParameter(AcpMethodParameter parameter) { - return false; - } - - @Override - public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { - throw new UnsupportedOperationException(); - } - }; + // Cache: parameter -> resolver, empty when no resolver supports the parameter + private final ConcurrentMap> resolverCache = new ConcurrentHashMap<>( + 256); /** - * Add a resolver to the chain. - * @param resolver the resolver to add - * @return this composite for chaining + * Adds a resolver, asked after the ones added before. + * @param resolver the resolver, not null + * @return this composite */ public ArgumentResolverComposite addResolver(ArgumentResolver resolver) { this.resolvers.add(resolver); @@ -54,9 +47,9 @@ public ArgumentResolverComposite addResolver(ArgumentResolver resolver) { } /** - * Add multiple resolvers to the chain. - * @param resolvers the resolvers to add - * @return this composite for chaining + * Adds resolvers, in list order, asked after the ones added before. + * @param resolvers the resolvers, none of them null + * @return this composite */ public ArgumentResolverComposite addResolvers(List resolvers) { this.resolvers.addAll(resolvers); @@ -64,28 +57,45 @@ public ArgumentResolverComposite addResolvers(List resolvers) } /** - * Get the list of registered resolvers. - * @return unmodifiable list of resolvers + * Returns the resolvers in the order they are asked. + * @return an unmodifiable copy of the resolvers */ public List getResolvers() { return List.copyOf(resolvers); } /** - * Clear the resolver cache. Call this if resolvers are modified after - * the composite has been used. + * Forgets which resolver supports each parameter, so the next lookup asks the resolvers again. + * Needed only after adding resolvers to a composite already in use. */ public void clearCache() { resolverCache.clear(); } + /** + * {@inheritDoc} + * + *

True if one of its resolvers supports the parameter. The answer is kept. + * @param parameter {@inheritDoc} + * @return {@inheritDoc} + */ @Override public boolean supportsParameter(AcpMethodParameter parameter) { return getResolver(parameter) != null; } + /** + * {@inheritDoc} + * + *

Delegates to the first resolver that supports the parameter. + * @param parameter {@inheritDoc} + * @param context {@inheritDoc} + * @return {@inheritDoc} + * @throws ArgumentResolutionException if no resolver supports the parameter, or the resolver + * throws it + */ @Override - public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + public @Nullable Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { ArgumentResolver resolver = getResolver(parameter); if (resolver == null) { throw new ArgumentResolutionException( @@ -95,24 +105,11 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return resolver.resolveArgument(parameter, context); } - private ArgumentResolver getResolver(AcpMethodParameter parameter) { - // Check cache first - ArgumentResolver resolver = resolverCache.get(parameter); - if (resolver != null) { - return resolver == NO_RESOLVER ? null : resolver; - } - - // Find matching resolver - for (ArgumentResolver r : resolvers) { - if (r.supportsParameter(parameter)) { - resolverCache.put(parameter, r); - return r; - } - } - - // Cache the miss to avoid repeated lookups - resolverCache.put(parameter, NO_RESOLVER); - return null; + private @Nullable ArgumentResolver getResolver(AcpMethodParameter parameter) { + // The first resolver that supports the parameter; a miss is cached too + return resolverCache + .computeIfAbsent(parameter, p -> resolvers.stream().filter(r -> r.supportsParameter(p)).findFirst()) + .orElse(null); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AuthenticateRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AuthenticateRequestResolver.java new file mode 100644 index 00000000..64777bf4 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/AuthenticateRequestResolver.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.resolver; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthenticateRequest; + +/** + * Supplies the {@link AuthenticateRequest} that the client sent in {@code authenticate} to an + * {@link com.agentclientprotocol.sdk.annotation.Authenticate @Authenticate} method that declares a + * parameter of that type: the id of the auth method the client chose from those the agent + * advertised. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code AuthenticateRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. + * + * @author Mark Pollack + * @since 0.80.0 + */ +public class AuthenticateRequestResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return AuthenticateRequest.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + Object request = context.getRequest(); + if (request instanceof AuthenticateRequest) { + return request; + } + throw new ArgumentResolutionException( + "Expected AuthenticateRequest but got: " + request.getClass().getName()); + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CancelNotificationResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CancelNotificationResolver.java index 8c03025a..ff6ee35e 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CancelNotificationResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CancelNotificationResolver.java @@ -4,15 +4,26 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.CancelNotification; /** - * Resolves {@link CancelNotification} parameters in cancel handlers. + * Supplies the {@link CancelNotification} that the client sent in {@code session/cancel} to a + * {@link com.agentclientprotocol.sdk.annotation.Cancel @Cancel} method that declares a parameter of + * that type: the id of the session whose prompt turn to stop. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter, or leaves it out when the method does not need the notification. + * + *

It works as {@link PromptRequestResolver} does, for {@code CancelNotification}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. The {@code @Cancel} method also runs when the client closes + * a session with {@code session/close}: the agent then passes it a {@code CancelNotification} that + * carries only the session id. * * @author Mark Pollack - * @since 1.0.0 */ public class CancelNotificationResolver implements ArgumentResolver { @@ -28,7 +39,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException("Expected CancelNotification but got: " - + (request != null ? request.getClass().getName() : "null")); + + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CapabilitiesResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CapabilitiesResolver.java index c4a7cf19..20f91e83 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CapabilitiesResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CapabilitiesResolver.java @@ -4,15 +4,26 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; /** - * Resolves {@link NegotiatedCapabilities} parameters. + * Supplies the capabilities negotiated on the connection that the request or notification arrived + * on to a {@link NegotiatedCapabilities} parameter: what the client offered in its + * {@code initialize} request. Any handler method can take one, extension methods included, to check + * that the client supports a feature before offering it or calling it. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter. + * + *

It works as {@link PromptContextResolver} does, for the capabilities, with no restriction on + * the method. The agent records them from the client's {@code initialize} request before the + * {@code @Initialize} method runs, so that method can take them too. A call that arrives before the + * client has sent {@code initialize} has none: {@link #resolveArgument} then throws an + * {@link ArgumentResolutionException}, and the client receives an internal error ({@code -32603}). * * @author Mark Pollack - * @since 1.0.0 */ public class CapabilitiesResolver implements ArgumentResolver { @@ -25,7 +36,7 @@ public boolean supportsParameter(AcpMethodParameter parameter) { public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { return context.getCapabilities() .orElseThrow(() -> new ArgumentResolutionException( - "NegotiatedCapabilities not available in current context")); + "NegotiatedCapabilities are not available: the client has not sent initialize")); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CloseSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CloseSessionRequestResolver.java index 6c338e06..0f29f83f 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CloseSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/CloseSessionRequestResolver.java @@ -4,15 +4,24 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.CloseSessionRequest; /** - * Resolves {@link CloseSessionRequest} parameters in close session handlers. + * Supplies the {@link CloseSessionRequest} that the client sent in {@code session/close} to a + * {@link com.agentclientprotocol.sdk.annotation.CloseSession @CloseSession} method that declares a + * parameter of that type: the id of the session to close. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter, or leaves it out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code CloseSessionRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class CloseSessionRequestResolver implements ArgumentResolver { @@ -28,7 +37,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected CloseSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected CloseSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ConfigOptionResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ConfigOptionResolver.java new file mode 100644 index 00000000..e7fcb54d --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ConfigOptionResolver.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.resolver; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.ConfigId; +import com.agentclientprotocol.sdk.annotation.ConfigValue; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionRequest; +import org.jspecify.annotations.Nullable; + +/** + * Supplies the parts of a {@code session/set_config_option} request to the parameters of a + * {@link com.agentclientprotocol.sdk.annotation.SetSessionConfigOption @SetSessionConfigOption} + * method: a {@link ConfigId @ConfigId} {@code String} receives the option id, and a + * {@link ConfigValue @ConfigValue} parameter the new value, typed. The method can take them instead + * of reading {@link SetSessionConfigOptionRequest#configId()} and {@code value()} from the request. + * It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter. + * + *

A {@code @ConfigValue} parameter may be a {@code String} for a select option, a + * {@code boolean} or {@code Boolean} for a boolean option, or an {@code Object} for either, which + * then receives a {@code String} or a {@code Boolean}. When the client sends a value of the other + * kind, the call is answered with invalid params ({@code -32602}) naming the option, and the method + * is not called. A {@code @ConfigId} parameter must be a {@code String}. For a parameter of another + * type than these, this resolver does not apply, and building the agent fails. + * + *

It works as {@link PromptContextResolver} does otherwise: building the agent rejects these + * parameters on any method but the {@code @SetSessionConfigOption} one, registering it rejects them + * on an extension method, and {@link #resolveArgument} throws an + * {@link ArgumentResolutionException} for a context whose request is not a + * {@code SetSessionConfigOptionRequest}. + * + * @author Mark Pollack + * @since 0.80.0 + */ +public class ConfigOptionResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + Class type = parameter.getParameterType(); + if (parameter.hasAnnotation(ConfigId.class)) { + return type == String.class; + } + if (parameter.hasAnnotation(ConfigValue.class)) { + return type == String.class || type == boolean.class || type == Boolean.class || type == Object.class; + } + return false; + } + + @Override + public @Nullable Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + if (!(context.getRequest() instanceof SetSessionConfigOptionRequest request)) { + throw new ArgumentResolutionException("@ConfigId and @ConfigValue parameters are only available in a" + + " @SetSessionConfigOption method, not for " + context.getAcpMethod()); + } + if (parameter.hasAnnotation(ConfigId.class)) { + return request.configId(); + } + return value(parameter.getParameterType(), request); + } + + /** The request's value as {@code type}, or an invalid params error when it is of the other kind. */ + private static Object value(Class type, SetSessionConfigOptionRequest request) { + Object value = request.value(); + if (type == Object.class) { + return value; + } + boolean select = type == String.class; + if (select ? value instanceof String : value instanceof Boolean) { + return value; + } + throw invalid(request, select ? "a select value (a string)" : "a boolean value"); + } + + private static AcpProtocolException invalid(SetSessionConfigOptionRequest request, String expected) { + return new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "Config option '" + request.configId() + + "' takes " + expected + ", not " + request.value()); + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DeleteSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DeleteSessionRequestResolver.java index b9a140a8..0d789562 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DeleteSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DeleteSessionRequestResolver.java @@ -4,15 +4,24 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.DeleteSessionRequest; /** - * Resolves {@link DeleteSessionRequest} parameters in delete session handlers. + * Supplies the {@link DeleteSessionRequest} that the client sent in {@code session/delete} to a + * {@link com.agentclientprotocol.sdk.annotation.DeleteSession @DeleteSession} method that declares + * a parameter of that type: the id of the session to delete. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter, or leaves it out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code DeleteSessionRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class DeleteSessionRequestResolver implements ArgumentResolver { @@ -28,7 +37,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected DeleteSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected DeleteSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DisableProviderRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DisableProviderRequestResolver.java index d9d80de2..e63cac49 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DisableProviderRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/DisableProviderRequestResolver.java @@ -4,8 +4,9 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.spec.AcpSchema.DisableProviderRequest; /** @@ -14,6 +15,7 @@ * @author Mark Pollack * @since 0.13.0 */ +@UnstableAcpApi public class DisableProviderRequestResolver implements ArgumentResolver { @Override @@ -28,7 +30,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException("Expected DisableProviderRequest but got: " - + (request != null ? request.getClass().getName() : "null")); + + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ExtensionParamsResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ExtensionParamsResolver.java new file mode 100644 index 00000000..31880cd4 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ExtensionParamsResolver.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support.resolver; + +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; + +/** + * Supplies the params of an extension method to its handler: the parameter of an + * {@link ExtRequest @ExtRequest} or {@link ExtNotification @ExtNotification} method that is not of + * a {@linkplain #isConnectionType connection type} receives the request's or notification's params, + * already read by the JSON mapper as that parameter's declared type, such as a record or a + * {@code Map}. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, the first of them to be asked, so an application never creates or registers it: the + * method declares the parameter. + * + *

It takes every parameter of an extension method that is not of a connection type, whatever its + * type or annotations, so a parameter declared as a request type such as {@code PromptRequest}, or + * as a prompt context, is read from the params too. Registering the agent rejects an extension + * method with more than one such parameter, or with a {@code @SessionId}, {@code @ConfigId} or + * {@code @ConfigValue} parameter, with an {@code IllegalArgumentException}. Params that cannot be + * read as the declared type are answered with invalid params ({@code -32602}) before the method is + * called. The connection-type parameters go to {@link CapabilitiesResolver} and + * {@link AgentResolver}. + * + *

It works as {@link PromptContextResolver} does otherwise, except that {@link #resolveArgument} + * never throws: it passes the call's request, which for an extension method is its params. + * + * @author Mark Pollack + */ +public class ExtensionParamsResolver implements ArgumentResolver { + + /** The types an extension handler's parameters may have besides its params. */ + private static final List> CONNECTION_TYPES = List.of(NegotiatedCapabilities.class, + AcpSyncAgent.class, AcpAsyncAgent.class); + + /** + * Returns whether a parameter of this type receives the call's connection rather than an + * extension method's params: true for {@link NegotiatedCapabilities}, {@link AcpSyncAgent} and + * {@link AcpAsyncAgent}, the types {@link CapabilitiesResolver} and {@link AgentResolver} + * supply. The runtime uses it to find an extension method's params parameter; applications do + * not need it. + * @param type the parameter type + * @return true for exactly those three types, false for any other type, a subtype included + */ + public static boolean isConnectionType(Class type) { + return CONNECTION_TYPES.contains(type); + } + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return (parameter.getMethod().isAnnotationPresent(ExtRequest.class) + || parameter.getMethod().isAnnotationPresent(ExtNotification.class)) + && !isConnectionType(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return context.getRequest(); + } + +} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ForkSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ForkSessionRequestResolver.java index 457df44b..7276c298 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ForkSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ForkSessionRequestResolver.java @@ -4,8 +4,9 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.spec.AcpSchema.ForkSessionRequest; /** @@ -14,6 +15,7 @@ * @author Mark Pollack * @since 0.12.0 */ +@UnstableAcpApi public class ForkSessionRequestResolver implements ArgumentResolver { @Override @@ -28,7 +30,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected ForkSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected ForkSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/InitializeRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/InitializeRequestResolver.java index b56f1f5a..02510f55 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/InitializeRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/InitializeRequestResolver.java @@ -4,15 +4,26 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; /** - * Resolves {@link InitializeRequest} parameters in initialize handlers. + * Supplies the {@link InitializeRequest} that the client sent in {@code initialize} to an + * {@link com.agentclientprotocol.sdk.annotation.Initialize @Initialize} method that declares a + * parameter of that type: the protocol version, the client's capabilities and its name and version. + * It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code InitializeRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. Only an agent with an {@code @Initialize} method uses it: + * without one, the agent answers {@code initialize} from its annotations and calls no method. * * @author Mark Pollack - * @since 1.0.0 */ public class InitializeRequestResolver implements ArgumentResolver { @@ -28,7 +39,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected InitializeRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected InitializeRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListProvidersRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListProvidersRequestResolver.java index 3481fb4e..53629a32 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListProvidersRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListProvidersRequestResolver.java @@ -4,8 +4,9 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.spec.AcpSchema.ListProvidersRequest; /** @@ -14,6 +15,7 @@ * @author Mark Pollack * @since 0.13.0 */ +@UnstableAcpApi public class ListProvidersRequestResolver implements ArgumentResolver { @Override @@ -28,7 +30,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected ListProvidersRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected ListProvidersRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListSessionsRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListSessionsRequestResolver.java index 80c050ac..fe7c7159 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListSessionsRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ListSessionsRequestResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.ListSessionsRequest; /** - * Resolves {@link ListSessionsRequest} parameters in list sessions handlers. + * Supplies the {@link ListSessionsRequest} that the client sent in {@code session/list} to a + * {@link com.agentclientprotocol.sdk.annotation.ListSessions @ListSessions} method that declares a + * parameter of that type: the working directory to filter by and the cursor of the page to return, + * both optional. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code ListSessionsRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class ListSessionsRequestResolver implements ArgumentResolver { @@ -28,7 +38,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected ListSessionsRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected ListSessionsRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LoadSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LoadSessionRequestResolver.java index 964f61ce..456b3361 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LoadSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LoadSessionRequestResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.LoadSessionRequest; /** - * Resolves {@link LoadSessionRequest} parameters in load session handlers. + * Supplies the {@link LoadSessionRequest} that the client sent in {@code session/load} to a + * {@link com.agentclientprotocol.sdk.annotation.LoadSession @LoadSession} method that declares a + * parameter of that type: the session id, the working directory, the MCP servers and any additional + * directories. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code LoadSessionRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class LoadSessionRequestResolver implements ArgumentResolver { @@ -28,7 +38,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected LoadSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected LoadSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LogoutRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LogoutRequestResolver.java index 906cf345..6829dab7 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LogoutRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/LogoutRequestResolver.java @@ -4,15 +4,24 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.LogoutRequest; /** - * Resolves {@link LogoutRequest} parameters in logout handlers. + * Supplies the {@link LogoutRequest} that the client sent in {@code logout} to a + * {@link com.agentclientprotocol.sdk.annotation.Logout @Logout} method that declares a parameter of + * that type: a request that carries only {@code _meta}. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter, or leaves it out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code LogoutRequest}: building the agent + * rejects the parameter on a method of another ACP method, a custom resolver for the type replaces + * this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for a + * context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class LogoutRequestResolver implements ArgumentResolver { @@ -28,7 +37,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected LogoutRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected LogoutRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/NewSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/NewSessionRequestResolver.java index d9870b33..d37d2866 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/NewSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/NewSessionRequestResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; /** - * Resolves {@link NewSessionRequest} parameters in new session handlers. + * Supplies the {@link NewSessionRequest} that the client sent in {@code session/new} to a + * {@link com.agentclientprotocol.sdk.annotation.NewSession @NewSession} method that declares a + * parameter of that type: the working directory, the MCP servers and any additional directories. It + * is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code NewSessionRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class NewSessionRequestResolver implements ArgumentResolver { @@ -28,7 +38,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected NewSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected NewSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptContextResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptContextResolver.java index 5fbf1054..5e1ff0cd 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptContextResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptContextResolver.java @@ -6,17 +6,32 @@ import com.agentclientprotocol.sdk.agent.PromptContext; import com.agentclientprotocol.sdk.agent.SyncPromptContext; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; /** - * Resolves {@link PromptContext} and {@link SyncPromptContext} parameters. + * Supplies the prompt context to a {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} + * method: a {@link SyncPromptContext} parameter receives the turn's context with blocking calls, + * and a {@link PromptContext} parameter the same turn's context with calls that return a + * {@code Mono} (the one {@code SyncPromptContext.async()} returns). Through it the method sends + * session updates for the turn, calls the client (files, terminals, permission) and learns that the + * turn was cancelled. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter. * - *

The prompt context provides access to all agent capabilities including - * file operations, terminal execution, and permission requests. + *

Only a prompt turn has a prompt context. Building the agent rejects a prompt context parameter + * on any other handler method, with an {@code IllegalStateException} that names the method and + * suggests an {@code AcpSyncAgent} or {@code AcpAsyncAgent} parameter instead. An extension method + * is not checked: {@link ExtensionParamsResolver}, asked first, takes such a parameter for the + * params. With a context that has no prompt context, as a context built by hand can be, + * {@link #resolveArgument} throws an {@link ArgumentResolutionException}. + * + *

The context resolvers fill a parameter from the call's {@link AcpInvocationContext}, not from + * the request type: this one, {@link SessionIdResolver}, {@link AgentResolver}, + * {@link CapabilitiesResolver}, {@link ConfigOptionResolver} and {@link ExtensionParamsResolver}. + * They are stateless and thread-safe. * * @author Mark Pollack - * @since 1.0.0 */ public class PromptContextResolver implements ArgumentResolver { diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptRequestResolver.java index f435a1e0..7067ea44 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/PromptRequestResolver.java @@ -4,15 +4,32 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; /** - * Resolves {@link PromptRequest} parameters in prompt handlers. + * Supplies the {@link PromptRequest} that the client sent in {@code session/prompt} to a + * {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} method that declares a parameter of + * that type: the session id and the content blocks of the user's prompt. It is one of the built-in + * {@link ArgumentResolver}s that {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport + * AcpAgentSupport} registers by default, so an application never creates or registers it: it + * declares the parameter, or leaves it out when the method does not need the request. + * + *

It supplies a parameter whose type is {@code PromptRequest}, whatever its annotations, and + * passes the request the call received, unchanged. A built agent never asks it for another request: + * building the agent rejects a {@code PromptRequest} parameter on a method that handles another ACP + * method (an {@code IllegalStateException} naming the method), and the parameters of an extension + * method go to {@link ExtensionParamsResolver}, which is asked first. A custom resolver that + * supports {@code PromptRequest} is asked before this one and replaces it. With a context whose + * request is not a {@code PromptRequest}, as a context built by hand can be, + * {@link #resolveArgument} throws an {@link ArgumentResolutionException}. + * + *

There is one request resolver for each request or notification a handler annotation receives; + * they all work like this one and differ only in the type they supply. They are stateless and + * thread-safe. * * @author Mark Pollack - * @since 1.0.0 */ public class PromptRequestResolver implements ArgumentResolver { @@ -28,7 +45,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected PromptRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected PromptRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ResumeSessionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ResumeSessionRequestResolver.java index 4ffe9030..643e5987 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ResumeSessionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/ResumeSessionRequestResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.ResumeSessionRequest; /** - * Resolves {@link ResumeSessionRequest} parameters in resume session handlers. + * Supplies the {@link ResumeSessionRequest} that the client sent in {@code session/resume} to a + * {@link com.agentclientprotocol.sdk.annotation.ResumeSession @ResumeSession} method that declares + * a parameter of that type: the session id, the working directory, the MCP servers and any + * additional directories. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code ResumeSessionRequest}: building the + * agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class ResumeSessionRequestResolver implements ArgumentResolver { @@ -28,7 +38,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected ResumeSessionRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected ResumeSessionRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SessionIdResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SessionIdResolver.java index 414d0420..ac8b3dfa 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SessionIdResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SessionIdResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.annotation.SessionId; /** - * Resolves String parameters annotated with {@link SessionId}. + * Supplies the session id to a {@code String} parameter annotated {@link SessionId @SessionId}: the + * id of the session that the request or notification being handled names, the same value as its + * {@code sessionId()}. A handler method uses it when it needs only the id, or does not take the + * request. It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter. + * + *

It works as {@link PromptContextResolver} does, for the session id: building the agent rejects + * a {@code @SessionId} parameter on a method whose ACP method names no session ({@code @SessionId} + * lists them), and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for a + * context without a session id. It supplies only a {@code String} parameter: for a + * {@code @SessionId} parameter of another type no resolver applies, and building the agent fails. * * @author Mark Pollack - * @since 1.0.0 */ public class SessionIdResolver implements ArgumentResolver { diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetProviderRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetProviderRequestResolver.java index 7b78f768..2c678940 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetProviderRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetProviderRequestResolver.java @@ -4,8 +4,9 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.spec.AcpSchema.SetProviderRequest; /** @@ -14,6 +15,7 @@ * @author Mark Pollack * @since 0.13.0 */ +@UnstableAcpApi public class SetProviderRequestResolver implements ArgumentResolver { @Override @@ -28,7 +30,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException( - "Expected SetProviderRequest but got: " + (request != null ? request.getClass().getName() : "null")); + "Expected SetProviderRequest but got: " + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionConfigOptionRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionConfigOptionRequestResolver.java index b6b65836..bc7c6bba 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionConfigOptionRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionConfigOptionRequestResolver.java @@ -4,12 +4,26 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionRequest; /** - * Resolves {@link SetSessionConfigOptionRequest} parameters in config option handlers. + * Supplies the {@link SetSessionConfigOptionRequest} that the client sent in + * {@code session/set_config_option} to a + * {@link com.agentclientprotocol.sdk.annotation.SetSessionConfigOption @SetSessionConfigOption} + * method that declares a parameter of that type: the session id, the option id and the new value. + * It is one of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code SetSessionConfigOptionRequest}: + * building the agent rejects the parameter on a method of another ACP method, a custom resolver for + * the type replaces this one, and {@link #resolveArgument} throws an + * {@link ArgumentResolutionException} for a context that holds another request. To receive only the + * option id or a typed value, take a {@code @ConfigId} or {@code @ConfigValue} parameter instead, + * which {@link ConfigOptionResolver} supplies. * * @author Mark Pollack * @since 0.12.0 @@ -29,7 +43,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext } throw new ArgumentResolutionException( "Expected SetSessionConfigOptionRequest but got: " - + (request != null ? request.getClass().getName() : "null")); + + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModeRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModeRequestResolver.java index 464bb237..8107ca1c 100644 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModeRequestResolver.java +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModeRequestResolver.java @@ -4,15 +4,25 @@ package com.agentclientprotocol.sdk.agent.support.resolver; -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeRequest; /** - * Resolves {@link SetSessionModeRequest} parameters in set session mode handlers. + * Supplies the {@link SetSessionModeRequest} that the client sent in {@code session/set_mode} to a + * {@link com.agentclientprotocol.sdk.annotation.SetSessionMode @SetSessionMode} method that + * declares a parameter of that type: the session id and the id of the mode to switch to. It is one + * of the built-in {@link ArgumentResolver}s that + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport AcpAgentSupport} registers by + * default, so an application never creates or registers it: it declares the parameter, or leaves it + * out when the method does not need the request. + * + *

It works as {@link PromptRequestResolver} does, for {@code SetSessionModeRequest}: building + * the agent rejects the parameter on a method of another ACP method, a custom resolver for the type + * replaces this one, and {@link #resolveArgument} throws an {@link ArgumentResolutionException} for + * a context that holds another request. * * @author Mark Pollack - * @since 1.0.0 */ public class SetSessionModeRequestResolver implements ArgumentResolver { @@ -28,7 +38,7 @@ public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext return request; } throw new ArgumentResolutionException("Expected SetSessionModeRequest but got: " - + (request != null ? request.getClass().getName() : "null")); + + request.getClass().getName()); } } diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModelRequestResolver.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModelRequestResolver.java deleted file mode 100644 index f42b3c8d..00000000 --- a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/SetSessionModelRequestResolver.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.support.resolver; - -import com.agentclientprotocol.sdk.agent.support.AcpInvocationContext; -import com.agentclientprotocol.sdk.agent.support.AcpMethodParameter; -import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModelRequest; - -/** - * Resolves {@link SetSessionModelRequest} parameters in set session model handlers. - * - * @author Mark Pollack - * @since 1.0.0 - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Slated for removal along with the rest of the session-model API. - */ -@Deprecated(forRemoval = true) -@SuppressWarnings("removal") -public class SetSessionModelRequestResolver implements ArgumentResolver { - - @Override - public boolean supportsParameter(AcpMethodParameter parameter) { - return SetSessionModelRequest.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { - Object request = context.getRequest(); - if (request instanceof SetSessionModelRequest) { - return request; - } - throw new ArgumentResolutionException("Expected SetSessionModelRequest but got: " - + (request != null ? request.getClass().getName() : "null")); - } - -} diff --git a/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/package-info.java b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/package-info.java new file mode 100644 index 00000000..728e4161 --- /dev/null +++ b/acp-agent-support/src/main/java/com/agentclientprotocol/sdk/agent/support/resolver/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Argument resolvers that supply a handler method's parameters from the invocation context. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.support.resolver; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderReuseTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderReuseTest.java new file mode 100644 index 00000000..f369ae96 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderReuseTest.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * One {@link AcpAgentSupport.Builder} builds any number of agents: {@code build()} composes + * the default resolvers and return value handlers at build time without changing the + * builder, and {@code buildFactory()} serves one agent per connection of a listener + * transport from the same handler bean. + */ +class AcpAgentSupportBuilderReuseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @AcpAgent + static class CwdAgent { + + final AtomicInteger sessions = new AtomicInteger(); + + @NewSession + NewSessionResponse newSession(NewSessionRequest request) { + sessions.incrementAndGet(); + return new NewSessionResponse(request.cwd(), null, null); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + private static String sessionIdFrom(AcpAgentSupport.Builder builder) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = builder.transport(pair.agentTransport()).requestTimeout(TIMEOUT).build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + return client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + + @Test + void buildingTwiceGivesIdenticalAgents() { + CwdAgent bean = new CwdAgent(); + AcpAgentSupport.Builder builder = AcpAgentSupport.create(bean); + + assertThat(sessionIdFrom(builder)).isEqualTo("/workspace"); + assertThat(sessionIdFrom(builder)).isEqualTo("/workspace"); + assertThat(bean.sessions).hasValue(2); + } + + /** + * Custom resolvers take precedence over the defaults. Before, the first build appended the + * defaults to the builder's own list, so a resolver added after it came after the + * defaults and never ran. + */ + @Test + void aCustomResolverAddedAfterABuildStillTakesPrecedence() { + AcpAgentSupport.Builder builder = AcpAgentSupport.create(new CwdAgent()); + assertThat(sessionIdFrom(builder)).isEqualTo("/workspace"); + + builder.argumentResolver(new ArgumentResolver() { + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return parameter.getParameterType() == NewSessionRequest.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return new NewSessionRequest("/custom", List.of()); + } + }); + + assertThat(sessionIdFrom(builder)).isEqualTo("/custom"); + } + + /** One bean behind every connection; prompts on two connections run at the same time. */ + @AcpAgent + static class ConcurrentPromptAgent { + + final CountDownLatch bothPrompting = new CountDownLatch(2); + + final Set sessions = ConcurrentHashMap.newKeySet(); + + @NewSession + NewSessionResponse newSession(NewSessionRequest request) { + sessions.add(request.cwd()); + return new NewSessionResponse(request.cwd(), null, null); + } + + @Prompt + PromptResponse prompt(PromptRequest request) throws InterruptedException { + bothPrompting.countDown(); + // Each prompt waits for the other connection's: both are served at once. + if (!bothPrompting.await(5, TimeUnit.SECONDS)) { + return PromptResponse.refusal(); + } + return PromptResponse.endTurn(); + } + + } + + @Test + void aFactoryServesTwoConnectionsAtOnceOverStreamableHttp() { + ConcurrentPromptAgent bean = new ConcurrentPromptAgent(); + AcpAgentFactory factory = AcpAgentSupport.create(bean).requestTimeout(TIMEOUT).buildFactory(); + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(0, AcpJsonMapper.createDefault(), + factory); + server.start().block(TIMEOUT); + AcpAsyncClient first = client(server); + AcpAsyncClient second = client(server); + try { + Mono firstTurn = turn(first, "/one"); + Mono secondTurn = turn(second, "/two"); + + assertThat(Mono.zip(firstTurn, secondTurn).block(TIMEOUT).toList()) + .containsExactly(StopReason.END_TURN, StopReason.END_TURN); + assertThat(bean.sessions).containsExactlyInAnyOrder("/one", "/two"); + } + finally { + first.closeGracefully().block(TIMEOUT); + second.closeGracefully().block(TIMEOUT); + server.closeGracefully().block(TIMEOUT); + } + } + + private static AcpAsyncClient client(StreamableHttpAcpAgentTransport server) { + return AcpClient + .async(new StreamableHttpAcpClientTransport( + URI.create("http://127.0.0.1:" + server.getPort() + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH), + AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); + } + + private static Mono turn(AcpAsyncClient client, String cwd) { + return client.initialize() + .then(client.newSession(new NewSessionRequest(cwd, List.of()))) + .flatMap(session -> client + .prompt(new PromptRequest(session.sessionId(), List.of(new TextContent("hi"))))) + .map(PromptResponse::stopReason); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderStateTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderStateTest.java new file mode 100644 index 00000000..62942fd5 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportBuilderStateTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@code AcpAgentSupport.Builder} refuses the states that would silently build a useless agent, + * and {@code run()} on the builder builds, starts and blocks until the transport ends, as the + * documentation's bootstrap snippet {@code create(bean).transport(..).run()} expects. + */ +class AcpAgentSupportBuilderStateTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @AcpAgent + static class EchoAgent { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void buildingWithoutAnAgentBeanIsRefused() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + assertThatThrownBy(() -> AcpAgentSupport.builder().transport(pair.agentTransport()).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("@AcpAgent") + .hasMessageContaining("create("); + assertThatThrownBy(() -> AcpAgentSupport.builder().buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("@AcpAgent"); + } + + @Test + void aFactoryWithATransportSetIsRefused() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + assertThatThrownBy(() -> AcpAgentSupport.create(new EchoAgent()).transport(pair.agentTransport()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("transport(") + .hasMessageContaining("build()"); + } + + @Test + void runOnTheBuilderServesUntilTheTransportEnds() throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport.Builder builder = AcpAgentSupport.create(new EchoAgent()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT); + CompletableFuture running = CompletableFuture.runAsync(builder::run); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + assertThat(client.initialize().block(TIMEOUT).protocolVersion()).isEqualTo(1); + assertThat(running).isNotDone(); + + client.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + + running.get(5, TimeUnit.SECONDS); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportConnectionContextTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportConnectionContextTest.java new file mode 100644 index 00000000..5fe2bd08 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportConnectionContextTest.java @@ -0,0 +1,303 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.Initialize; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.SetSessionMode; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentMessageChunk; +import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.CurrentModeUpdate; +import com.agentclientprotocol.sdk.spec.AcpSchema.FileSystemCapability; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.SessionNotification; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every annotated handler, not only {@code @Prompt}, can take its connection's + * {@link NegotiatedCapabilities} and its connection's agent ({@link AcpSyncAgent} or + * {@link AcpAsyncAgent}), so it can gate on what the client supports and push session updates + * outside a prompt turn, including when one handler bean serves many connections through + * {@code buildFactory()}. + */ +class AcpAgentSupportConnectionContextTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private AcpAgentSupport agentSupport; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentSupport != null) { + agentSupport.close(); + } + } + + @Test + void aNonPromptHandlerReceivesTheNegotiatedCapabilities() { + // Before, only @Prompt was given them: session/new failed with -32603 + // "NegotiatedCapabilities not available in current context". + AtomicReference received = new AtomicReference<>(); + AtomicReference atInitialize = new AtomicReference<>(); + + @AcpAgent + class CapabilitiesAgent { + + @Initialize + InitializeResponse initialize(NegotiatedCapabilities capabilities) { + atInitialize.set(capabilities); + return InitializeResponse.ok(); + } + + @NewSession + NewSessionResponse newSession(NegotiatedCapabilities capabilities) { + received.set(capabilities); + return new NewSessionResponse("s1", null, null); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + InMemoryTransportPair pair = InMemoryTransportPair.create(); + agentSupport = AcpAgentSupport.create(new CapabilitiesAgent()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agentSupport.start(); + client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(readOnlyFiles(true)) + .readTextFileHandler(request -> Mono.empty()) + .build(); + + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + assertThat(atInitialize.get()).isNotNull(); + assertThat(atInitialize.get().supportsReadTextFile()).isTrue(); + assertThat(received.get()).isNotNull(); + assertThat(received.get().supportsReadTextFile()).isTrue(); + assertThat(received.get().supportsWriteTextFile()).isFalse(); + } + + @Test + void aNonPromptHandlerReceivesItsAgentAndPushesASessionUpdate() { + AtomicReference syncAgent = new AtomicReference<>(); + List updates = new CopyOnWriteArrayList<>(); + + @AcpAgent + class AgentTakingAgent { + + @NewSession + NewSessionResponse newSession(AcpSyncAgent agent) { + syncAgent.set(agent); + return new NewSessionResponse("s1", null, null); + } + + @SetSessionMode + SetSessionModeResponse setMode(SetSessionModeRequest request, AcpAsyncAgent agent) { + agent.sendSessionUpdate(request.sessionId(), new CurrentModeUpdate(request.modeId())) + .block(TIMEOUT); + return new SetSessionModeResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + InMemoryTransportPair pair = InMemoryTransportPair.create(); + agentSupport = AcpAgentSupport.create(new AgentTakingAgent()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agentSupport.start(); + client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).sessionUpdateHandler(update -> { + updates.add(update); + return Mono.empty(); + }).build(); + + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + client.setSessionMode(new SetSessionModeRequest("s1", "plan")).block(TIMEOUT); + + assertThat(syncAgent.get()).isSameAs(agentSupport.getAgent()); + assertThat(updates).singleElement() + .satisfies(update -> assertThat(update.update()).isInstanceOf(CurrentModeUpdate.class)); + } + + record Echo(String text) { + } + + @Test + void anExtensionHandlerTakesItsParamsWithTheCapabilitiesAndAgent() { + AtomicReference received = new AtomicReference<>(); + + @AcpAgent + class ExtensionAgent { + + @ExtRequest("_test/echo") + Echo echo(AcpAsyncAgent agent, Echo params, NegotiatedCapabilities capabilities) { + received.set(agent); + return new Echo(params.text() + " " + capabilities.supportsReadTextFile()); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + InMemoryTransportPair pair = InMemoryTransportPair.create(); + agentSupport = AcpAgentSupport.create(new ExtensionAgent()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agentSupport.start(); + client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(readOnlyFiles(true)) + .readTextFileHandler(request -> Mono.empty()) + .build(); + + client.initialize().block(TIMEOUT); + Echo echo = client.sendExtRequest("_test/echo", new Echo("read:"), new TypeRef() { + }).block(TIMEOUT); + + assertThat(echo).isEqualTo(new Echo("read: true")); + assertThat(received.get()).isSameAs(agentSupport.getAgent().async()); + } + + /** One bean behind two connections: each call sees its own connection's capabilities and agent. */ + @AcpAgent + static class PerConnectionAgent { + + final Map readTextFileBySession = new ConcurrentHashMap<>(); + + final Map agentBySession = new ConcurrentHashMap<>(); + + @NewSession + NewSessionResponse newSession(NewSessionRequest request, NegotiatedCapabilities capabilities, + AcpSyncAgent agent) { + readTextFileBySession.put(request.cwd(), capabilities.supportsReadTextFile()); + agentBySession.put(request.cwd(), agent); + return new NewSessionResponse(request.cwd(), null, null); + } + + @SetSessionMode + SetSessionModeResponse setMode(SetSessionModeRequest request, AcpSyncAgent agent) { + agent.sendSessionUpdate(request.sessionId(), + new AgentMessageChunk(new TextContent("to " + request.sessionId()))); + return new SetSessionModeResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void underAFactoryEachConnectionGetsItsOwnCapabilitiesAndAgent() { + PerConnectionAgent bean = new PerConnectionAgent(); + AcpAgentFactory factory = AcpAgentSupport.create(bean).requestTimeout(TIMEOUT).buildFactory(); + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(0, AcpJsonMapper.createDefault(), + factory); + server.start().block(TIMEOUT); + List firstUpdates = new CopyOnWriteArrayList<>(); + List secondUpdates = new CopyOnWriteArrayList<>(); + AcpAsyncClient first = client(transport(server), readOnlyFiles(true), firstUpdates); + AcpAsyncClient second = client(transport(server), readOnlyFiles(false), secondUpdates); + try { + first.initialize().block(TIMEOUT); + second.initialize().block(TIMEOUT); + first.newSession(new NewSessionRequest("/one", List.of())).block(TIMEOUT); + second.newSession(new NewSessionRequest("/two", List.of())).block(TIMEOUT); + first.setSessionMode(new SetSessionModeRequest("/one", "plan")).block(TIMEOUT); + second.setSessionMode(new SetSessionModeRequest("/two", "plan")).block(TIMEOUT); + + assertThat(bean.readTextFileBySession).containsExactlyInAnyOrderEntriesOf(Map.of("/one", true, "/two", false)); + assertThat(bean.agentBySession.get("/one")).isNotNull().isNotSameAs(bean.agentBySession.get("/two")); + assertThat(firstUpdates).containsExactly("to /one"); + assertThat(secondUpdates).containsExactly("to /two"); + } + finally { + first.closeGracefully().block(TIMEOUT); + second.closeGracefully().block(TIMEOUT); + server.closeGracefully().block(TIMEOUT); + } + } + + private static ClientCapabilities readOnlyFiles(boolean readTextFile) { + return new ClientCapabilities(new FileSystemCapability(readTextFile, false), false); + } + + private static AcpClientTransport transport(StreamableHttpAcpAgentTransport server) { + return new StreamableHttpAcpClientTransport( + URI.create("http://127.0.0.1:" + server.getPort() + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH), + AcpJsonMapper.createDefault()); + } + + private static AcpAsyncClient client(AcpClientTransport transport, ClientCapabilities capabilities, + List updates) { + AcpClient.AsyncSpec spec = AcpClient.async(transport).requestTimeout(TIMEOUT).clientCapabilities(capabilities); + if (Boolean.TRUE.equals(capabilities.fs().readTextFile())) { + spec.readTextFileHandler(request -> Mono.empty()); + } + return spec.sessionUpdateHandler(notification -> { + if (notification.update() instanceof AgentMessageChunk chunk && chunk.content() instanceof TextContent text) { + updates.add(text.text()); + } + return Mono.empty(); + }).build(); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportErrorPathTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportErrorPathTest.java new file mode 100644 index 00000000..4f55b115 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportErrorPathTest.java @@ -0,0 +1,306 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.DisableProvider; +import com.agentclientprotocol.sdk.annotation.ForkSession; +import com.agentclientprotocol.sdk.annotation.Initialize; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema.DisableProviderRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.DisableProviderResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.ForkSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.ForkSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Annotated agents end to end: the unstable handlers not covered elsewhere, reactive + * return values, the error a client sees when a handler cannot be invoked or answered, and + * the lifetime of an agent registered by class. + */ +class AcpAgentSupportErrorPathTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private AcpAgentSupport agentSupport; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentSupport != null) { + agentSupport.close(); + } + } + + private AcpAsyncClient connect(AcpAgentSupport.Builder builder) { + agentSupport = builder.transport(transportPair.agentTransport()).requestTimeout(TIMEOUT).build(); + agentSupport.start(); + client = AcpClient.async(transportPair.clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + return client; + } + + private static PromptRequest prompt(String sessionId) { + return new PromptRequest(sessionId, List.of(new TextContent("hi"))); + } + + @AcpAgent + static class UnstableHandlersAgent { + + @com.agentclientprotocol.sdk.annotation.NewSession + com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse newSession() { + return new com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse("s1", null, null); + } + + @ForkSession + ForkSessionResponse fork(ForkSessionRequest request) { + return new ForkSessionResponse(request.sessionId() + "-fork", null, null, null); + } + + @SetSessionConfigOption + SetSessionConfigOptionResponse setConfigOption(SetSessionConfigOptionRequest request) { + return new SetSessionConfigOptionResponse(List.of()); + } + + @DisableProvider + DisableProviderResponse disableProvider(DisableProviderRequest request) { + return new DisableProviderResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void forkSetConfigOptionAndDisableProviderHandlersAreBound() { + AcpAsyncClient client = connect(AcpAgentSupport.create(new UnstableHandlersAgent())); + + assertThat(client.forkSession(new ForkSessionRequest("s1", "/workspace", List.of())).block(TIMEOUT) + .sessionId()).isEqualTo("s1-fork"); + assertThat(client.setSessionConfigOption(new SetSessionConfigOptionRequest("s1", "model", "fast", null, null)) + .block(TIMEOUT) + .configOptions()).isEmpty(); + assertThat(client.disableProvider(new DisableProviderRequest("main")).block(TIMEOUT)).isNotNull(); + } + + @AcpAgent + static class MonoAgent { + + @Prompt + Mono prompt(PromptRequest request) { + if ("empty".equals(request.sessionId())) { + return Mono.empty(); + } + return Mono.just(PromptResponse.endTurn()); + } + + } + + @Test + void aMonoReturnValueIsUnwrapped() { + AcpAsyncClient client = connect(AcpAgentSupport.create(new MonoAgent())); + + assertThat(client.prompt(prompt("s1")).block(TIMEOUT).stopReason()).isEqualTo(StopReason.END_TURN); + } + + @Test + void anEmptyMonoIsAnInternalError() { + AcpAsyncClient client = connect(AcpAgentSupport.create(new MonoAgent())); + + assertThatThrownBy(() -> client.prompt(prompt("empty")).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, + e -> assertThat(e.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR)) + .hasMessageContaining("produced no response"); + } + + @AcpAgent + static class CheckedExceptionAgent { + + @Prompt + PromptResponse prompt(PromptRequest request) throws Exception { + throw new java.io.IOException("disk gone"); + } + + } + + @Test + void aCheckedExceptionFromAHandlerFailsTheRequest() { + AcpAsyncClient client = connect(AcpAgentSupport.create(new CheckedExceptionAgent())); + + assertThatThrownBy(() -> client.prompt(prompt("s1")).block(TIMEOUT)) + .isInstanceOf(AcpError.class) + .hasMessage("Internal error") + .satisfies(e -> assertThat(((AcpError) e).getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR)); + } + + /** Keeps its sessions in a field, as the module README's complete example does. */ + @AcpAgent + public static class StatefulAgent { + + static final AtomicInteger instances = new AtomicInteger(); + + private final Map sessions = new ConcurrentHashMap<>(); + + public StatefulAgent() { + instances.incrementAndGet(); + } + + @NewSession + NewSessionResponse newSession(NewSessionRequest request) { + sessions.put("s1", request.cwd()); + return new NewSessionResponse("s1", null, null); + } + + @Prompt + PromptResponse prompt(PromptRequest request) { + if (!sessions.containsKey(request.sessionId())) { + return PromptResponse.refusal(); + } + return PromptResponse.endTurn(); + } + + } + + @Test + void anAgentRegisteredByClassIsOneInstanceThatKeepsItsState() { + StatefulAgent.instances.set(0); + AcpAsyncClient client = connect(AcpAgentSupport.create(StatefulAgent.class)); + + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + PromptResponse response = client.prompt(prompt(sessionId)).block(TIMEOUT); + + assertThat(response.stopReason()).as("the prompt handler sees the session the new-session handler stored") + .isEqualTo(StopReason.END_TURN); + assertThat(StatefulAgent.instances).hasValue(1); + } + + @Test + void anAgentRegisteredWithAFactoryIsCreatedOnce() { + AtomicInteger created = new AtomicInteger(); + AcpAsyncClient client = connect(AcpAgentSupport.builder().agent(StatefulAgent.class, () -> { + created.incrementAndGet(); + return new StatefulAgent(); + })); + + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + assertThat(client.prompt(prompt(sessionId)).block(TIMEOUT).stopReason()).isEqualTo(StopReason.END_TURN); + assertThat(created).hasValue(1); + } + + @AcpAgent + static class NoDefaultConstructorAgent { + + NoDefaultConstructorAgent(String required) { + } + + @Initialize + InitializeResponse initialize() { + return InitializeResponse.ok(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void aClassWithoutANoArgConstructorIsRefused() { + assertThatThrownBy(() -> AcpAgentSupport.create(NoDefaultConstructorAgent.class)) + .hasMessageContaining("Cannot instantiate") + .hasMessageContaining(NoDefaultConstructorAgent.class.getName()); + } + + @Test + void aClassWithoutTheAgentAnnotationIsRefused() { + assertThatThrownBy(() -> AcpAgentSupport.create(new Object())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("@AcpAgent"); + } + + @Test + void buildRequiresATransport() { + assertThatThrownBy(() -> AcpAgentSupport.create(new MonoAgent()).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Transport"); + } + + /** Fails its first prompt with an Error, as code compiled against another SDK version can. */ + @AcpAgent + static class ErrorThrowingAgent { + + private final AtomicInteger prompts = new AtomicInteger(); + + @Prompt + PromptResponse prompt(PromptRequest request) { + int prompt = prompts.getAndIncrement(); + if (prompt == 0) { + throw new NoSuchMethodError("secret payload"); + } + if (prompt == 1) { + throw new AssertionError("secret payload"); + } + return PromptResponse.endTurn(); + } + + } + + /** + * A handler method that throws an Error is answered -32603 naming the method, and the + * connection keeps serving. Before, Reactor rethrew the NoSuchMethodError on the handler + * thread and the client waited out its request timeout. + */ + @Test + void aHandlerThrowingAnErrorIsAnsweredInternalErrorAndTheConnectionServesOn() { + AcpAsyncClient client = connect(AcpAgentSupport.create(new ErrorThrowingAgent())); + + for (int i = 0; i < 2; i++) { + assertThatThrownBy(() -> client.prompt(prompt("s1")).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, error -> { + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).contains("session/prompt").doesNotContain("secret"); + }); + } + assertThat(client.prompt(prompt("s1")).block(TIMEOUT).stopReason()).isEqualTo(StopReason.END_TURN); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportExtensionTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportExtensionTest.java new file mode 100644 index 00000000..e2f98766 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportExtensionTest.java @@ -0,0 +1,231 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Annotated extension handlers: {@link ExtRequest} and {@link ExtNotification} methods + * serve custom {@code _}-prefixed requests and notifications, their params read as the + * method's parameter type. + */ +class AcpAgentSupportExtensionTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + record Ping(String text) { + } + + record Pong(String text) { + } + + private InMemoryTransportPair transportPair; + + private AcpAgentSupport agentSupport; + + private AcpAsyncClient client; + + @BeforeEach + void setUp() { + transportPair = InMemoryTransportPair.create(); + } + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentSupport != null) { + agentSupport.close(); + } + } + + @AcpAgent + static class ExtAgent { + + final CompletableFuture typedNote = new CompletableFuture<>(); + + final CompletableFuture> rawNote = new CompletableFuture<>(); + + @ExtRequest("_test/ping") + Pong ping(Ping ping) { + return new Pong("pong: " + ping.text()); + } + + @ExtRequest("_test/generic") + Map generic(Map> params) { + return Map.of("sum", params.get("values").stream().mapToInt(Integer::intValue).sum()); + } + + @ExtRequest("_test/mono") + Mono mono(Ping ping) { + return Mono.just(new Pong("mono: " + ping.text())); + } + + @ExtRequest("_test/noparams") + List noParams() { + return List.of("a", "b"); + } + + @ExtRequest("_test/null") + Object nothing() { + return null; + } + + @ExtNotification("_test/typed") + void typed(Ping ping) { + typedNote.complete(ping); + } + + @ExtNotification("_test/raw") + void raw(Map params) { + rawNote.complete(params); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + private ExtAgent start() { + ExtAgent agent = new ExtAgent(); + agentSupport = AcpAgentSupport.create(agent) + .transport(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agentSupport.start(); + client = AcpClient.async(transportPair.clientTransport()).requestTimeout(TIMEOUT).build(); + return agent; + } + + @Test + void extRequestMethodsAnswerWithTypedParams() { + start(); + + assertThat(client.sendExtRequest("_test/ping", new Ping("hi"), new TypeRef() { + }).block(TIMEOUT)).isEqualTo(new Pong("pong: hi")); + assertThat(client.sendExtRequest("_test/generic", Map.of("values", List.of(1, 2, 3))).block(TIMEOUT)) + .isEqualTo(Map.of("sum", 6)); + assertThat(client.sendExtRequest("_test/mono", new Ping("m"), new TypeRef() { + }).block(TIMEOUT)).isEqualTo(new Pong("mono: m")); + assertThat(client.sendExtRequest("_test/noparams", Map.of()).block(TIMEOUT)).isEqualTo(List.of("a", "b")); + } + + @Test + void extRequestMethodReturningNullAnswersWithError() { + start(); + + assertThatThrownBy(() -> client.sendExtRequest("_test/null", Map.of()).block(TIMEOUT)) + .isInstanceOf(AcpError.class) + .hasMessageContaining("produced no response"); + } + + @Test + void extNotificationMethodsReceiveParams() throws Exception { + ExtAgent agent = start(); + + client.sendExtNotification("_test/typed", new Ping("n")).block(TIMEOUT); + client.sendExtNotification("_test/raw", Map.of("k", "v")).block(TIMEOUT); + + assertThat(agent.typedNote.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("n")); + assertThat(agent.rawNote.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("k", "v")); + } + + @Test + void unannotatedExtMethodIsMethodNotFound() { + start(); + + assertThatThrownBy(() -> client.sendExtRequest("_test/absent", Map.of()).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND)); + } + + @AcpAgent + static class BadNameAgent { + + @ExtRequest("session/prompt") + Object hijack(Map params) { + return params; + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent + static class BadNotificationNameAgent { + + @ExtNotification("x/y") + void note(Map params) { + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent + static class TwoParamsAgent { + + @ExtRequest("_test/two") + Object two(Ping ping, String other) { + return ping; + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void discoveryRejectsNamesWithoutUnderscore() { + assertThatThrownBy(() -> AcpAgentSupport.create(new BadNameAgent())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("session/prompt"); + assertThatThrownBy(() -> AcpAgentSupport.create(new BadNotificationNameAgent())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("x/y"); + } + + @Test + void discoveryRejectsMoreThanOneParameter() { + assertThatThrownBy(() -> AcpAgentSupport.create(new TwoParamsAgent())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("two"); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportHandlerExecutorTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportHandlerExecutorTest.java new file mode 100644 index 00000000..3352d659 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportHandlerExecutorTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An annotated agent chooses the executor its handler methods run on, as a builder agent does. + */ +class AcpAgentSupportHandlerExecutorTest { + + @AcpAgent + static class ThreadReportingAgent { + + final java.util.concurrent.atomic.AtomicReference thread = new java.util.concurrent.atomic.AtomicReference<>(); + + @Prompt + PromptResponse prompt(PromptRequest request) { + this.thread.set(Thread.currentThread().getName()); + return PromptResponse.endTurn(); + } + + } + + /** An annotated agent's methods run on the executor given to the builder. */ + @Test + void handlerMethodsRunOnTheGivenExecutor() { + java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "app-annotated-pool"); + t.setDaemon(true); + return t; + }); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + ThreadReportingAgent bean = new ThreadReportingAgent(); + AcpAgentSupport support = AcpAgentSupport.create(bean) + .transport(pair.agentTransport()) + .handlerExecutor(pool) + .build(); + try { + support.start(); + com.agentclientprotocol.sdk.client.AcpSyncClient client = com.agentclientprotocol.sdk.client.AcpClient + .sync(pair.clientTransport()) + .build(); + client.initialize(); + client.newSession(new com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest("/w", java.util.List.of())); + client.prompt(new PromptRequest("s", java.util.List.of(new com.agentclientprotocol.sdk.spec.AcpSchema.TextContent("go")))); + client.close(); + } + finally { + support.close(); + pool.shutdownNow(); + pair.closeGracefully().block(Duration.ofSeconds(5)); + } + assertThat(bean.thread.get()).isEqualTo("app-annotated-pool"); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportLifecycleTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportLifecycleTest.java new file mode 100644 index 00000000..dc9ad1b8 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportLifecycleTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An annotated agent is {@link AutoCloseable}: try-with-resources closes it, and closing ends its + * transport. + */ +class AcpAgentSupportLifecycleTest { + + @AcpAgent + static class Agent { + + @Prompt + PromptResponse prompt(PromptRequest request) { + return PromptResponse.endTurn(); + } + + } + + @Test + void tryWithResourcesClosesTheAgentAndItsTransport() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport closed; + try (AcpAgentSupport support = AcpAgentSupport.create(new Agent()).transport(pair.agentTransport()).build()) { + support.start(); + closed = support; + } + // The transport has ended, so waiting for it returns at once. + closed.getAgent().async().awaitTermination().block(Duration.ofSeconds(5)); + assertThat(closed).isInstanceOf(AutoCloseable.class); + pair.closeGracefully().block(Duration.ofSeconds(5)); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportTest.java index e7bbf55a..71df500f 100644 --- a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportTest.java +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AcpAgentSupportTest.java @@ -6,10 +6,16 @@ import java.time.Duration; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import com.agentclientprotocol.sdk.agent.PromptContext; import com.agentclientprotocol.sdk.agent.SyncPromptContext; import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Authenticate; import com.agentclientprotocol.sdk.annotation.Cancel; import com.agentclientprotocol.sdk.annotation.CloseSession; import com.agentclientprotocol.sdk.annotation.DeleteSession; @@ -23,9 +29,11 @@ import com.agentclientprotocol.sdk.annotation.Prompt; import com.agentclientprotocol.sdk.annotation.ResumeSession; import com.agentclientprotocol.sdk.annotation.SetSessionMode; -import com.agentclientprotocol.sdk.annotation.SetSessionModel; import com.agentclientprotocol.sdk.client.AcpAsyncClient; import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentMessageChunk; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthenticateRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthenticateResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.CancelNotification; import com.agentclientprotocol.sdk.spec.AcpSchema.CloseSessionRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.CloseSessionResponse; @@ -49,20 +57,25 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.ResumeSessionRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.ResumeSessionResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.SessionInfo; +import com.agentclientprotocol.sdk.spec.AcpSchema.SessionNotification; import com.agentclientprotocol.sdk.spec.AcpSchema.SetProviderRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.SetProviderResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModeResponse; -import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModelRequest; -import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionModelResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import reactor.core.publisher.Mono; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Tests for {@link AcpAgentSupport}. @@ -109,7 +122,7 @@ void annotationBasedAgentHandlesFullLifecycle() throws Exception { .build(); // Initialize - InitializeResponse initResp = client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + InitializeResponse initResp = client.initialize().block(TIMEOUT); assertThat(initResp.protocolVersion()).isEqualTo(1); // New session @@ -146,7 +159,8 @@ NewSessionResponse newSession() { PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { receivedPrompt.set(req.prompt().get(0).toString()); receivedSessionId.set(ctx.getSessionId()); - return PromptResponse.text("Got it!"); + ctx.sendMessage("Got it!"); + return PromptResponse.endTurn(); } } @@ -163,7 +177,7 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("capture-session", List.of(new TextContent("Test message")))).block(TIMEOUT); @@ -171,8 +185,12 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { assertThat(receivedPrompt.get()).contains("Test message"); } + /** + * A {@code @Prompt} method's String is the agent's reply: the client receives it as an + * agent message chunk of the turn, then the turn ends. + */ @Test - void stringReturnValueConvertedToPromptResponse() throws Exception { + void aStringReturnedByAPromptMethodReachesTheClientAsAMessageChunk() throws Exception { @AcpAgent class StringReturningAgent { @@ -188,7 +206,7 @@ NewSessionResponse newSession() { @Prompt String prompt(PromptRequest req) { - return "Hello from String!"; + return "hello"; } } @@ -199,19 +217,27 @@ String prompt(PromptRequest req) { .build(); agentSupport.start(); - Thread.sleep(100); + List updates = new CopyOnWriteArrayList<>(); client = AcpClient.async(transportPair.clientTransport()) .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + updates.add(notification); + return Mono.empty(); + }) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); PromptResponse resp = client.prompt(new PromptRequest("string-session", List.of(new TextContent("test")))) .block(TIMEOUT); - // String should be converted to PromptResponse with END_TURN - assertThat(resp.stopReason()).isNotNull(); + assertThat(resp.stopReason()).isEqualTo(StopReason.END_TURN); + assertThat(updates).singleElement().satisfies(notification -> { + assertThat(notification.sessionId()).isEqualTo("string-session"); + assertThat(notification.update()).isInstanceOfSatisfying(AgentMessageChunk.class, + chunk -> assertThat(chunk.content()).isEqualTo(new TextContent("hello"))); + }); } @Test @@ -252,7 +278,7 @@ void prompt(PromptRequest req, SyncPromptContext ctx) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); PromptResponse resp = client.prompt(new PromptRequest("void-session", List.of(new TextContent("test")))) .block(TIMEOUT); @@ -279,6 +305,11 @@ LoadSessionResponse loadSession(LoadSessionRequest req) { return new LoadSessionResponse(null, null); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new LoadSessionAgent()) @@ -293,7 +324,7 @@ LoadSessionResponse loadSession(LoadSessionRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); LoadSessionResponse resp = client.loadSession(new LoadSessionRequest("existing-session", "/workspace", List.of())) .block(TIMEOUT); @@ -302,8 +333,66 @@ LoadSessionResponse loadSession(LoadSessionRequest req) { } @Test - void setSessionModeHandlerInvoked() throws Exception { - AtomicReference receivedModeId = new AtomicReference<>(); + void requestHandlerWithoutResultIsRejectedWhenBuilt() { + // A void handler for a request used to produce no JSON-RPC response at all, then an + // error at every call. It is now rejected when the agent is built. + @AcpAgent + class VoidSetModeAgent { + + @SetSessionMode + void setMode(SetSessionModeRequest req) { + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + assertThatThrownBy(() -> AcpAgentSupport.create(new VoidSetModeAgent()) + .transport(transportPair.agentTransport()) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("setMode") + .hasMessageContaining("void") + .hasMessageContaining(SetSessionModeResponse.class.getSimpleName()); + } + + @Test + void handlerReturningWrongTypeIsRejectedWhenBuilt() { + // A handler whose result is not the method's response type used to fail at every call. + @AcpAgent + class WrongTypeSetModeAgent { + + @SetSessionMode + String setMode(SetSessionModeRequest req) { + return "code"; + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + assertThatThrownBy(() -> AcpAgentSupport.create(new WrongTypeSetModeAgent()) + .transport(transportPair.agentTransport()) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("setMode") + .hasMessageContaining("String") + .hasMessageContaining(SetSessionModeResponse.class.getSimpleName()); + } + + @Test + void requestVetoedByInterceptorAnswersWithError() throws Exception { + // A preInvoke veto on a request used to produce no JSON-RPC response at all. + AcpInterceptor vetoSetMode = new AcpInterceptor() { + @Override + public boolean preInvoke(AcpInvocationContext context) { + return !"session/set_mode".equals(context.getAcpMethod()); + } + }; @AcpAgent class SetModeAgent { @@ -315,43 +404,133 @@ InitializeResponse init() { @NewSession NewSessionResponse newSession() { - return new NewSessionResponse("mode-session", null, null); + return new NewSessionResponse("s", null, null); } @SetSessionMode SetSessionModeResponse setMode(SetSessionModeRequest req) { - receivedModeId.set(req.modeId()); return new SetSessionModeResponse(); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new SetModeAgent()) .transport(transportPair.agentTransport()) .requestTimeout(TIMEOUT) + .interceptor(vetoSetMode) .build(); - agentSupport.start(); Thread.sleep(100); - client = AcpClient.async(transportPair.clientTransport()) + client = AcpClient.async(transportPair.clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + + assertThatThrownBy(() -> client.setSessionMode(new SetSessionModeRequest("s", "code")).block(TIMEOUT)) + .hasMessageContaining("produced no response"); + } + + @Test + void afterCompletionRunsExactlyOncePerInvocationOnEveryPath() throws Exception { + // A veto (or a preInvoke failure) used to run afterCompletion twice: inside + // applyPreInvoke and again from the invocation's finally block. + Map completions = new ConcurrentHashMap<>(); + AcpInterceptor counting = new AcpInterceptor() { + @Override + public int getOrder() { + return 0; + } + + @Override + public void afterCompletion(AcpInvocationContext context, Throwable ex) { + completions.computeIfAbsent(context.getAcpMethod(), m -> new AtomicInteger()).incrementAndGet(); + } + }; + AcpInterceptor vetoOrFail = new AcpInterceptor() { + @Override + public int getOrder() { + return 1; + } + + @Override + public boolean preInvoke(AcpInvocationContext context) { + if ("session/close".equals(context.getAcpMethod())) { + throw new IllegalStateException("preInvoke failed"); + } + return !"session/set_mode".equals(context.getAcpMethod()); + } + }; + + @AcpAgent + class LifecycleAgent { + + @Initialize + InitializeResponse init() { + return InitializeResponse.ok(); + } + + @NewSession + NewSessionResponse newSession() { + return new NewSessionResponse("s", null, null); + } + + @LoadSession + LoadSessionResponse load(LoadSessionRequest req) { + throw new IllegalStateException("handler failed"); + } + + @SetSessionMode + SetSessionModeResponse setMode(SetSessionModeRequest req) { + return new SetSessionModeResponse(); + } + + @CloseSession + CloseSessionResponse close(CloseSessionRequest req) { + return new CloseSessionResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + agentSupport = AcpAgentSupport.create(new LifecycleAgent()) + .transport(transportPair.agentTransport()) .requestTimeout(TIMEOUT) + .interceptor(counting) + .interceptor(vetoOrFail) .build(); + agentSupport.start(); + Thread.sleep(100); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); - client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - client.setSessionMode(new SetSessionModeRequest("mode-session", "code-review")).block(TIMEOUT); + client = AcpClient.async(transportPair.clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); - assertThat(receivedModeId.get()).isEqualTo("code-review"); + client.newSession(new NewSessionRequest("/", List.of())).block(TIMEOUT); + assertThatThrownBy(() -> client.loadSession(new LoadSessionRequest("s", "/", List.of())).block(TIMEOUT)) + .hasMessage("Internal error"); + assertThatThrownBy(() -> client.setSessionMode(new SetSessionModeRequest("s", "code")).block(TIMEOUT)) + .hasMessageContaining("produced no response"); + assertThatThrownBy(() -> client.closeSession(new CloseSessionRequest("s")).block(TIMEOUT)) + .hasMessage("Internal error"); + + assertThat(completions).containsOnlyKeys("initialize", "session/new", "session/load", "session/set_mode", + "session/close"); + assertThat(completions.values()).allSatisfy(count -> assertThat(count.get()).isEqualTo(1)); } @Test - @SuppressWarnings("removal") // exercises the deprecated-for-removal session/set_model handler - void setSessionModelHandlerInvoked() throws Exception { - AtomicReference receivedModelId = new AtomicReference<>(); + void setSessionModeHandlerInvoked() throws Exception { + AtomicReference receivedModeId = new AtomicReference<>(); @AcpAgent - class SetModelAgent { + class SetModeAgent { @Initialize InitializeResponse init() { @@ -360,18 +539,23 @@ InitializeResponse init() { @NewSession NewSessionResponse newSession() { - return new NewSessionResponse("model-session", null, null); + return new NewSessionResponse("mode-session", null, null); } - @SetSessionModel - SetSessionModelResponse setModel(SetSessionModelRequest req) { - receivedModelId.set(req.modelId()); - return new SetSessionModelResponse(); + @SetSessionMode + SetSessionModeResponse setMode(SetSessionModeRequest req) { + receivedModeId.set(req.modeId()); + return new SetSessionModeResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); } } - agentSupport = AcpAgentSupport.create(new SetModelAgent()) + agentSupport = AcpAgentSupport.create(new SetModeAgent()) .transport(transportPair.agentTransport()) .requestTimeout(TIMEOUT) .build(); @@ -383,11 +567,11 @@ SetSessionModelResponse setModel(SetSessionModelRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - client.setSessionModel(new SetSessionModelRequest("model-session", "gpt-4")).block(TIMEOUT); + client.setSessionMode(new SetSessionModeRequest("mode-session", "code-review")).block(TIMEOUT); - assertThat(receivedModelId.get()).isEqualTo("gpt-4"); + assertThat(receivedModeId.get()).isEqualTo("code-review"); } @Test @@ -412,6 +596,11 @@ void onCancel(CancelNotification notification) { cancelledSessionId.set(notification.sessionId()); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new CancelAgent()) @@ -426,7 +615,7 @@ void onCancel(CancelNotification notification) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.cancel(new CancelNotification("cancel-session")).block(TIMEOUT); @@ -455,6 +644,11 @@ ListSessionsResponse listSessions(ListSessionsRequest req) { List.of(new SessionInfo("session-1", "/workspace"))); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new ListSessionsAgent()) @@ -469,7 +663,7 @@ ListSessionsResponse listSessions(ListSessionsRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); ListSessionsResponse resp = client.listSessions(new ListSessionsRequest("/workspace")) .block(TIMEOUT); @@ -497,6 +691,11 @@ CloseSessionResponse closeSession(CloseSessionRequest req) { return new CloseSessionResponse(); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new CloseSessionAgent()) @@ -511,7 +710,7 @@ CloseSessionResponse closeSession(CloseSessionRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); CloseSessionResponse resp = client.closeSession(new CloseSessionRequest("session-to-close")) .block(TIMEOUT); @@ -519,6 +718,95 @@ CloseSessionResponse closeSession(CloseSessionRequest req) { assertThat(resp).isNotNull(); } + @Test + void promptHandlerTakingAsyncPromptContextIsInvoked() throws Exception { + // PromptContextResolver accepted a PromptContext parameter, but only the sync context + // was ever supplied, so the call failed with an ArgumentResolutionException. + AtomicReference receivedSessionId = new AtomicReference<>(); + List updates = new java.util.concurrent.CopyOnWriteArrayList<>(); + + @AcpAgent + class AsyncContextAgent { + + @NewSession + NewSessionResponse newSession() { + return new NewSessionResponse("async-context-session", null, null); + } + + @Prompt + Mono prompt(PromptRequest req, PromptContext ctx) { + receivedSessionId.set(ctx.getSessionId()); + return ctx.sendMessage("from the async context").then(Mono.just(PromptResponse.endTurn())); + } + + } + + agentSupport = AcpAgentSupport.create(new AsyncContextAgent()) + .transport(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + + agentSupport.start(); + Thread.sleep(100); + + client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + updates.add(notification.update().toString()); + return Mono.empty(); + }) + .build(); + + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + PromptResponse resp = client + .prompt(new PromptRequest("async-context-session", List.of(new TextContent("Hi")))) + .block(TIMEOUT); + + assertThat(resp.stopReason()).isEqualTo(PromptResponse.endTurn().stopReason()); + assertThat(receivedSessionId.get()).isEqualTo("async-context-session"); + assertThat(updates).anySatisfy(update -> assertThat(update).contains("from the async context")); + } + + @Test + void authenticateHandlerInvoked() throws Exception { + AtomicReference methodId = new AtomicReference<>(); + + @AcpAgent + class AuthenticateAgent { + + @Authenticate + AuthenticateResponse authenticate(AuthenticateRequest req) { + methodId.set(req.methodId()); + return new AuthenticateResponse(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + agentSupport = AcpAgentSupport.create(new AuthenticateAgent()) + .transport(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + + agentSupport.start(); + Thread.sleep(100); + + client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .build(); + + client.initialize().block(TIMEOUT); + AuthenticateResponse resp = client.authenticate(new AuthenticateRequest("api-key")).block(TIMEOUT); + + assertThat(methodId.get()).isEqualTo("api-key"); + assertThat(resp).isNotNull(); + } + @Test void logoutHandlerInvoked() throws Exception { AtomicReference loggedOut = new AtomicReference<>(false); @@ -537,6 +825,11 @@ LogoutResponse logout(LogoutRequest req) { return new LogoutResponse(); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new LogoutAgent()) @@ -551,7 +844,7 @@ LogoutResponse logout(LogoutRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); LogoutResponse resp = client.logout(new LogoutRequest()).block(TIMEOUT); assertThat(loggedOut.get()).isTrue(); @@ -576,6 +869,11 @@ DeleteSessionResponse deleteSession(DeleteSessionRequest req) { return new DeleteSessionResponse(); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new DeleteSessionAgent()) @@ -590,7 +888,7 @@ DeleteSessionResponse deleteSession(DeleteSessionRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); DeleteSessionResponse resp = client.deleteSession(new DeleteSessionRequest("session-to-delete")) .block(TIMEOUT); @@ -613,6 +911,11 @@ ListProvidersResponse listProviders(ListProvidersRequest req) { return new ListProvidersResponse(List.of(new ProviderInfo("openai", List.of("openai"), false, null))); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new ListProvidersAgent()) @@ -627,12 +930,12 @@ ListProvidersResponse listProviders(ListProvidersRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); ListProvidersResponse resp = client.listProviders(new ListProvidersRequest()).block(TIMEOUT); assertThat(resp).isNotNull(); assertThat(resp.providers()).hasSize(1); - assertThat(resp.providers().get(0).id()).isEqualTo("openai"); + assertThat(resp.providers().get(0).providerId()).isEqualTo("openai"); } @Test @@ -650,11 +953,16 @@ InitializeResponse init() { @SetProvider SetProviderResponse setProvider(SetProviderRequest req) { - configuredId.set(req.id()); + configuredId.set(req.providerId()); configuredBaseUrl.set(req.baseUrl()); return new SetProviderResponse(); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new SetProviderAgent()) @@ -669,7 +977,7 @@ SetProviderResponse setProvider(SetProviderRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); SetProviderResponse resp = client .setProvider(new SetProviderRequest("main", "anthropic", "https://api.anthropic.com")) .block(TIMEOUT); @@ -697,6 +1005,11 @@ ResumeSessionResponse resumeSession(ResumeSessionRequest req) { return new ResumeSessionResponse(null, null); } + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + } agentSupport = AcpAgentSupport.create(new ResumeSessionAgent()) @@ -711,7 +1024,7 @@ ResumeSessionResponse resumeSession(ResumeSessionRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); ResumeSessionResponse resp = client .resumeSession(new ResumeSessionRequest("existing-session", "/workspace", List.of())) .block(TIMEOUT); diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentDiscoveryTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentDiscoveryTest.java new file mode 100644 index 00000000..12ec4c8b --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentDiscoveryTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.LoadSession; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Handler discovery walks the class hierarchy: a bean that a framework proxies (Spring CGLIB, + * Quarkus ArC or Micronaut AOP generate a subclass that carries no annotations) is still found, + * and invoked through the proxy, so its interceptors run; handlers declared on a superclass are + * found; an override is one handler, not two; and the bridge method a generic override compiles + * to is not a handler of its own. + */ +class AgentDiscoveryTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @AcpAgent(name = "proxied", version = "1") + static class ProxiedAgent { + + @NewSession + public NewSessionResponse newSession() { + return new NewSessionResponse("s1", null, null); + } + + @LoadSession + public AcpSchema.LoadSessionResponse load() { + return new AcpSchema.LoadSessionResponse(null); + } + + @Prompt + public PromptResponse prompt() { + return new PromptResponse(AcpSchema.StopReason.END_TURN); + } + + } + + /** What a CGLIB/ArC/Micronaut proxy looks like: an unannotated subclass overriding public methods. */ + static class ProxiedAgent$$SpringCGLIB$$0 extends ProxiedAgent { + + final AtomicInteger intercepted = new AtomicInteger(); + + @Override + public PromptResponse prompt() { + intercepted.incrementAndGet(); + return super.prompt(); + } + + @Override + public NewSessionResponse newSession() { + intercepted.incrementAndGet(); + return super.newSession(); + } + + } + + @Test + void aProxiedBeanIsDiscoveredOnItsUserClassAndInvokedThroughTheProxy() { + ProxiedAgent$$SpringCGLIB$$0 proxy = new ProxiedAgent$$SpringCGLIB$$0(); + + InitializeResponse init = session(AcpAgentSupport.create(proxy), client -> { + assertThat(prompt(client)).isEqualTo(AcpSchema.StopReason.END_TURN); + return client.initialize().block(TIMEOUT); + }); + + assertThat(proxy.intercepted).as("newSession and prompt ran through the proxy").hasValue(2); + assertThat(init.agentCapabilities().loadSession()).isTrue(); + assertThat(init.agentInfo().name()).isEqualTo("proxied"); + } + + @Test + void anAnonymousSubclassIsDiscoveredToo() { + AtomicInteger intercepted = new AtomicInteger(); + ProxiedAgent anonymous = new ProxiedAgent() { + @Override + public PromptResponse prompt() { + intercepted.incrementAndGet(); + return super.prompt(); + } + }; + + session(AcpAgentSupport.create(anonymous), AgentDiscoveryTest::prompt); + + assertThat(intercepted).hasValue(1); + } + + abstract static class AbstractAgent { + + @NewSession + NewSessionResponse newSession() { + return new NewSessionResponse("s1", null, null); + } + + @Prompt + PromptResponse prompt(PromptRequest request) { + return new PromptResponse(AcpSchema.StopReason.MAX_TOKENS); + } + + } + + @AcpAgent + static class ConcreteAgent extends AbstractAgent { + + } + + @Test + void handlersOnAnAbstractSuperclassAreDiscovered() { + assertThat(session(AcpAgentSupport.create(new ConcreteAgent()), AgentDiscoveryTest::prompt)) + .isEqualTo(AcpSchema.StopReason.MAX_TOKENS); + } + + @AcpAgent + static class OverridingAgent extends AbstractAgent { + + @Override + @Prompt + PromptResponse prompt(PromptRequest request) { + return new PromptResponse(AcpSchema.StopReason.REFUSAL); + } + + } + + @Test + void anAnnotatedOverrideIsOneHandlerAndTheOverrideRuns() { + assertThat(session(AcpAgentSupport.create(new OverridingAgent()), AgentDiscoveryTest::prompt)) + .isEqualTo(AcpSchema.StopReason.REFUSAL); + } + + abstract static class GenericAgent { + + @Prompt + abstract PromptResponse prompt(R request); + + } + + /** The override compiles to a bridge prompt(Object) that carries the annotation too. */ + @AcpAgent + static class TypedAgent extends GenericAgent { + + @NewSession + NewSessionResponse newSession() { + return new NewSessionResponse("s1", null, null); + } + + @Override + @Prompt + PromptResponse prompt(PromptRequest request) { + return new PromptResponse(request.sessionId().equals("s1") ? AcpSchema.StopReason.END_TURN + : AcpSchema.StopReason.REFUSAL); + } + + } + + @Test + void aBridgeMethodIsNotAHandler() { + assertThat(session(AcpAgentSupport.create(new TypedAgent()), AgentDiscoveryTest::prompt)) + .isEqualTo(AcpSchema.StopReason.END_TURN); + } + + static class NotAnAgent { + + } + + @Test + void aClassWithNoAcpAgentInItsHierarchyIsRejectedNamingIt() { + assertThatThrownBy(() -> AcpAgentSupport.create(new NotAnAgent())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(NotAnAgent.class.getName()) + .hasMessageContaining("@AcpAgent"); + } + + private static AcpSchema.StopReason prompt(AcpAsyncClient client) { + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT); + return client.prompt(new PromptRequest("s1", List.of(new AcpSchema.TextContent("hi")))) + .block(TIMEOUT) + .stopReason(); + } + + private static T session(AcpAgentSupport.Builder builder, Function scenario) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = builder.transport(pair.agentTransport()).requestTimeout(TIMEOUT).build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + return scenario.apply(client); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentSupportArchitectureTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentSupportArchitectureTest.java new file mode 100644 index 00000000..cebfa7f0 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AgentSupportArchitectureTest.java @@ -0,0 +1,165 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-agent-support: annotation-driven agents. + * + *
+ *            support (AcpAgentSupport, AcpHandlerMethod)
+ *              /             |              \
+ *       resolver          handler         interceptor
+ *              \             |              /
+ *                         invocation
+ * 
+ * + *

+ * {@code AcpAgentSupport} discovers handler methods and drives them through three independent + * extension points, which see only the invocation model, never the entry classes or each other. + * The module builds on acp-core's public agent API and runs over any {@code AcpAgentTransport}: + * it does not reach into session implementations, client code, concrete transports or a JSON + * implementation. + *

+ */ +@AnalyzeClasses(locations = AgentSupportArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class AgentSupportArchitectureTest { + + + @ArchTest + static final ArchRule isFrameworkNeutral = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("org.springframework..", "io.quarkus..", "io.micronaut..", + "io.vertx..", "io.smallrye..", "jakarta.enterprise..", "jakarta.inject..", + "com.agentclientprotocol.sdk.spring..", "com.agentclientprotocol.sdk.micronaut..", + "com.agentclientprotocol.sdk.quarkus..", + "com.agentclientprotocol.sdk.integration..") + .as("a framework or a framework integration")) + .because("framework integrations build on the SDK, never the reverse"); + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule layers = layeredArchitecture().consideringOnlyDependenciesInLayers() + .layer("Support").definedBy("com.agentclientprotocol.sdk.agent.support") + .layer("Resolver").definedBy("com.agentclientprotocol.sdk.agent.support.resolver..") + .layer("Handler").definedBy("com.agentclientprotocol.sdk.agent.support.handler..") + .layer("Interceptor").definedBy("com.agentclientprotocol.sdk.agent.support.interceptor..") + .layer("Invocation").definedBy("com.agentclientprotocol.sdk.agent.support.invocation..") + .whereLayer("Support").mayNotBeAccessedByAnyLayer() + .whereLayer("Resolver").mayOnlyBeAccessedByLayers("Support") + .whereLayer("Handler").mayOnlyBeAccessedByLayers("Support") + .whereLayer("Interceptor").mayOnlyBeAccessedByLayers("Support") + .whereLayer("Invocation").mayOnlyBeAccessedByLayers("Support", "Resolver", "Handler", "Interceptor") + .because("the extension points are written against the invocation model, not against AcpAgentSupport"); + + /** + * acp-core's internals as seen from another module: the session implementations (request + * correlation and dispatch, which the agent and client runtimes own) and anything not public. + */ + static final DescribedPredicate SDK_INTERNALS = resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(belongToAnyOf(AcpAgentSession.class, AcpClientSession.class).or(not(modifier(JavaModifier.PUBLIC)))) + .as("acp-core internals (session implementations, non-public classes)"); + + @ArchTest + static final ArchRule usesOnlyThePublicAgentApi = noClasses().should() + .dependOnClassesThat(SDK_INTERNALS) + .because("annotation support is a client of acp-core's agent API, like any hand-written agent"); + + @ArchTest + static final ArchRule isTransportClientAndJsonAgnostic = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("com.agentclientprotocol.sdk.client..", + "com.agentclientprotocol.sdk.agent.transport..") + .or(resideInAPackage("com.agentclientprotocol.sdk.json..") + .and(not(belongToAnyOf(AcpJsonMapper.class, AcpJsonMapperSupplier.class, TypeRef.class)))) + .as("client code, concrete transports or a JSON implementation")) + .because("an annotated agent runs over whatever AcpAgentTransport and JSON mapper it is given"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = AcpAgentSupport.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AnnotatedPromptCancellationTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AnnotatedPromptCancellationTest.java new file mode 100644 index 00000000..4f95c21c --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AnnotatedPromptCancellationTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Cancel; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An annotated {@code @Prompt} method learns of its prompt's cancellation from its context, + * with no {@code @Cancel} handler or shared state: {@code SyncPromptContext.isCancelled()} or + * {@code PromptContext.whenCancelled()}. The grace period is long, so a prompt answered + * promptly with the handler's {@code _meta} was answered by the handler. + */ +class AnnotatedPromptCancellationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "annotated-cancel"; + + private static final Map HANDLERS_OWN = Map.of("answeredBy", "handler"); + + @AcpAgent + static class PollingAgent { + + final CountDownLatch running = new CountDownLatch(1); + + final CountDownLatch sawCancel = new CountDownLatch(1); + + final CountDownLatch cancelHandlerRan = new CountDownLatch(1); + + @NewSession + AcpSchema.NewSessionResponse newSession() { + return new AcpSchema.NewSessionResponse(SESSION, null, null); + } + + @Prompt + PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + running.countDown(); + while (!context.isCancelled()) { + Thread.interrupted(); // a cancelled request interrupts the thread: keep polling + pause(); + } + sawCancel.countDown(); + return new PromptResponse(AcpSchema.StopReason.CANCELLED, HANDLERS_OWN); + } + + @Cancel + void cancel() { + cancelHandlerRan.countDown(); + } + + } + + @AcpAgent + static class ReactiveAgent { + + @NewSession + AcpSchema.NewSessionResponse newSession() { + return new AcpSchema.NewSessionResponse(SESSION, null, null); + } + + @Prompt + Mono prompt(PromptContext context) { + return Mono.never() + .takeUntilOther(context.whenCancelled()) + .switchIfEmpty(Mono.fromSupplier( + () -> new PromptResponse(AcpSchema.StopReason.CANCELLED, HANDLERS_OWN))); + } + + } + + /** A void prompt: the SDK answers end_turn for it, unless session/cancel arrived. */ + @AcpAgent + static class VoidAgent { + + final CountDownLatch running = new CountDownLatch(1); + + @NewSession + AcpSchema.NewSessionResponse newSession() { + return new AcpSchema.NewSessionResponse(SESSION, null, null); + } + + @Prompt + void prompt(SyncPromptContext context) { + running.countDown(); + while (!context.isCancelled()) { + Thread.interrupted(); + pause(); + } + } + + } + + /** A prompt whose work fails once cancelled, with an exception that is no cancellation. */ + @AcpAgent + static class FailingAgent { + + final CountDownLatch running = new CountDownLatch(1); + + @NewSession + AcpSchema.NewSessionResponse newSession() { + return new AcpSchema.NewSessionResponse(SESSION, null, null); + } + + @Prompt + String prompt(SyncPromptContext context) { + running.countDown(); + while (!context.isCancelled()) { + Thread.interrupted(); + pause(); + } + throw new IllegalStateException("model stream closed"); + } + + } + + // ACP spec 7628b153: prompt-turn.mdx:354, "the Agent MUST respond to the original + // session/prompt request with the cancelled stop reason" + @Test + void aVoidPromptReturningAfterSessionCancelIsAnsweredCancelled() throws Exception { + VoidAgent bean = new VoidAgent(); + run(bean, client -> { + Mono response = client.prompt(prompt()).cache(); + response.subscribe(r -> { + }, e -> { + }); + await(bean.running); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + assertThat(response.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + }); + } + + // ACP spec 7628b153: prompt-turn.mdx:361, "Agents MUST catch these errors and return the + // semantically meaningful cancelled stop reason" + @Test + void aPromptFailingAfterSessionCancelIsAnsweredCancelled() throws Exception { + FailingAgent bean = new FailingAgent(); + run(bean, client -> { + Mono response = client.prompt(prompt()).cache(); + response.subscribe(r -> { + }, e -> { + }); + await(bean.running); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + assertThat(response.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + }); + } + + @Test + void aPollingPromptAnswersCancelledPromptlyOnSessionCancel() throws Exception { + PollingAgent bean = new PollingAgent(); + run(bean, client -> { + Mono response = client.prompt(prompt()).cache(); + response.subscribe(r -> { + }, e -> { + }); + await(bean.running); + long start = System.nanoTime(); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + PromptResponse answer = response.block(TIMEOUT); + assertThat(answer.stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(answer.meta()).as("the handler's own answer").isEqualTo(HANDLERS_OWN); + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(3)); + await(bean.cancelHandlerRan); + }); + } + + @Test + void aPollingPromptSeesACancelRequestForItsRequest() throws Exception { + PollingAgent bean = new PollingAgent(); + run(bean, client -> { + Disposable prompt = client.prompt(prompt()).subscribe(r -> { + }, e -> { + }); + await(bean.running); + + prompt.dispose(); // $/cancel_request + + await(bean.sawCancel); + }); + } + + @Test + void aReactivePromptCompletesOnWhenCancelled() throws Exception { + run(new ReactiveAgent(), client -> { + Mono response = client.prompt(prompt()).cache(); + response.subscribe(r -> { + }, e -> { + }); + pause(); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + assertThat(response.block(TIMEOUT).meta()).isEqualTo(HANDLERS_OWN); + }); + } + + interface ClientScenario { + + void run(AcpAsyncClient client) throws Exception; + + } + + private static void run(Object bean, ClientScenario scenario) throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgentSupport.create(bean) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(Duration.ofSeconds(30)) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT); + scenario.run(client); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + + private static PromptRequest prompt() { + return new PromptRequest(SESSION, List.of(new AcpSchema.TextContent("work"))); + } + + private static void await(CountDownLatch latch) throws InterruptedException { + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); + } + + private static void pause() { + try { + Thread.sleep(20); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AsyncReturnTypesTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AsyncReturnTypesTest.java new file mode 100644 index 00000000..bf2fe3ee --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/AsyncReturnTypesTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A handler may return its value later: as a {@code Mono}, a {@code CompletionStage} or a + * single-value Reactive Streams {@code Publisher}, with the same meaning as returning the value + * itself. A prompt method's {@code Mono} sends the text as an agent message chunk and ends + * the turn, as a {@code String} does. + */ +class AsyncReturnTypesTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @AcpAgent + static class MonoStringAgent { + + @Prompt + Mono prompt() { + return Mono.just("hello later"); + } + + } + + @AcpAgent + static class CompletionStageAgent { + + @NewSession + CompletableFuture newSession() { + return CompletableFuture.supplyAsync(() -> new NewSessionResponse("s1", null, null)); + } + + @Prompt + CompletionStage prompt() { + return CompletableFuture.supplyAsync(PromptResponse::refusal); + } + + } + + @AcpAgent + static class CompletionStageStringAgent { + + @Prompt + CompletionStage prompt() { + return CompletableFuture.completedFuture("staged text"); + } + + } + + @AcpAgent + static class PublisherAgent { + + @Prompt + Publisher prompt() { + return Flux.just(new PromptResponse(AcpSchema.StopReason.MAX_TOKENS)); + } + + } + + @Test + void aMonoOfStringSendsTheTextAndEndsTheTurn() { + List chunks = new CopyOnWriteArrayList<>(); + assertThat(prompt(new MonoStringAgent(), chunks)).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(chunks).containsExactly("hello later"); + } + + @Test + void aCompletionStageIsAwaited() { + assertThat(prompt(new CompletionStageAgent(), new CopyOnWriteArrayList<>())) + .isEqualTo(AcpSchema.StopReason.REFUSAL); + } + + @Test + void aCompletionStageOfStringSendsTheText() { + List chunks = new CopyOnWriteArrayList<>(); + assertThat(prompt(new CompletionStageStringAgent(), chunks)).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(chunks).containsExactly("staged text"); + } + + @Test + void aSingleValuePublisherIsAwaited() { + assertThat(prompt(new PublisherAgent(), new CopyOnWriteArrayList<>())) + .isEqualTo(AcpSchema.StopReason.MAX_TOKENS); + } + + @AcpAgent + static class WrongStageType { + + @NewSession + CompletionStage newSession() { + return CompletableFuture.completedFuture(PromptResponse.endTurn()); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void aStageOfAnotherMethodsResponseIsRejectedWhenBuilt() { + assertThatThrownBy(() -> AcpAgentSupport.create(new WrongStageType()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("WrongStageType.newSession") + .hasMessageContaining("NewSessionResponse"); + } + + private static AcpSchema.StopReason prompt(Object bean, List chunks) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgentSupport.create(bean) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.fromRunnable(() -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks.add(text.text()); + } + })) + .build(); + try { + client.initialize().block(TIMEOUT); + String session = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())) + .block(TIMEOUT) + .sessionId(); + return client.prompt(new PromptRequest(session, List.of(new AcpSchema.TextContent("hi")))) + .block(TIMEOUT) + .stopReason(); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderAnnotationParityTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderAnnotationParityTest.java new file mode 100644 index 00000000..fb4cb4c1 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderAnnotationParityTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Everything an agent builder offers is reachable from an annotated agent: each handler setter + * through a handler annotation ({@link HandlerAnnotationCoverageTest} checks which), and each + * other setter through a method of the same name on {@link AcpAgentSupport.Builder}, unless it + * is listed below as builder-only, with the reason. A setter added to the builders without an + * annotation equivalent fails here. + */ +class BuilderAnnotationParityTest { + + /** Builder methods an annotated agent reaches another way, or does not need, and why. */ + private static final Map BUILDER_ONLY = Map.of("extRequestHandler", + "served by @ExtRequest methods, one per extension method name", "extNotificationHandler", + "served by @ExtNotification methods, one per extension method name", "agentInfo", + "declared by @AcpAgent(name, version, title)"); + + @Test + void everySyncBuilderMethodHasAnAnnotationEquivalent() { + assertThat(unmatched(AcpAgent.SyncAgentBuilder.class)).isEmpty(); + } + + @Test + void everyAsyncBuilderMethodHasAnAnnotationEquivalent() { + assertThat(unmatched(AcpAgent.AsyncAgentBuilder.class)).isEmpty(); + } + + /** The builder-only list names only methods the builders have, so it cannot go stale. */ + @Test + void theBuilderOnlyListIsCurrent() { + assertThat(publicMethods(AcpAgent.SyncAgentBuilder.class)).containsAll(BUILDER_ONLY.keySet()); + } + + private static Set unmatched(Class builder) { + Set annotationSetters = AcpAgentSupport.handlerAnnotations() + .keySet() + .stream() + .map(Class::getSimpleName) + .map(name -> Character.toLowerCase(name.charAt(0)) + name.substring(1) + "Handler") + .collect(Collectors.toSet()); + Set supportBuilder = publicMethods(AcpAgentSupport.Builder.class); + Set unmatched = new TreeSet<>(publicMethods(builder)); + unmatched.removeIf(name -> annotationSetters.contains(name) || supportBuilder.contains(name) + || BUILDER_ONLY.containsKey(name)); + return unmatched; + } + + private static Set publicMethods(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers()) && !method.isSynthetic()) + .map(Method::getName) + .collect(Collectors.toCollection(TreeSet::new)); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderVsAnnotationComparisonTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderVsAnnotationComparisonTest.java index a2fdbaf7..b81b0d50 100644 --- a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderVsAnnotationComparisonTest.java +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/BuilderVsAnnotationComparisonTest.java @@ -20,13 +20,13 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema.CreateTerminalResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.FileSystemCapability; -import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.ReadTextFileResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.KillTerminalCommandResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.ReleaseTerminalResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.TerminalOutputResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; @@ -107,7 +107,7 @@ void builderBasedAgent() throws Exception { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("session-1", List.of(new TextContent("Hello")))).block(TIMEOUT); @@ -164,7 +164,7 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("session-1", List.of(new TextContent("Hello")))).block(TIMEOUT); @@ -209,7 +209,8 @@ void builderBasedAgent() throws Exception { ctx.writeFile("/test/output.txt", "processed: " + content); writtenContent.set("processed: " + content); - return PromptResponse.text("Done"); + ctx.sendMessage("Done"); + return PromptResponse.endTurn(); }) .build(); @@ -219,13 +220,14 @@ void builderBasedAgent() throws Exception { ClientCapabilities fsCaps = new ClientCapabilities( new FileSystemCapability(true, true), null); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(fsCaps) .requestTimeout(TIMEOUT) .clientCapabilities(fsCaps) .readTextFileHandler(req -> Mono.just(new ReadTextFileResponse("file content"))) .writeTextFileHandler(req -> Mono.just(new WriteTextFileResponse())) .build(); - client.initialize(new InitializeRequest(1, fsCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("file-session", List.of(new TextContent("process")))).block(TIMEOUT); @@ -269,7 +271,8 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ctx.writeFile("/test/output.txt", "processed: " + content); writtenContent.set("processed: " + content); - return PromptResponse.text("Done"); + ctx.sendMessage("Done"); + return PromptResponse.endTurn(); } } @@ -284,13 +287,14 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ClientCapabilities fsCaps = new ClientCapabilities( new FileSystemCapability(true, true), null); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(fsCaps) .requestTimeout(TIMEOUT) .clientCapabilities(fsCaps) .readTextFileHandler(req -> Mono.just(new ReadTextFileResponse("file content"))) .writeTextFileHandler(req -> Mono.just(new WriteTextFileResponse())) .build(); - client.initialize(new InitializeRequest(1, fsCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("file-session", List.of(new TextContent("process")))).block(TIMEOUT); @@ -332,7 +336,8 @@ void builderBasedAgent() throws Exception { var result = ctx.execute("echo", "hello"); executedCommand.set("echo hello"); exitCode.set(result.exitCode()); - return PromptResponse.text("Exit code: " + result.exitCode()); + ctx.sendMessage("Exit code: " + result.exitCode()); + return PromptResponse.endTurn(); }) .build(); @@ -341,6 +346,7 @@ void builderBasedAgent() throws Exception { ClientCapabilities termCaps = new ClientCapabilities(null, true); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(termCaps) .requestTimeout(TIMEOUT) .clientCapabilities(termCaps) .createTerminalHandler(req -> { @@ -351,9 +357,10 @@ void builderBasedAgent() throws Exception { .waitForTerminalExitHandler(req -> Mono.just(new WaitForTerminalExitResponse(0, null))) .terminalOutputHandler(req -> Mono.just(new TerminalOutputResponse("hello\n", false, null))) .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) .build(); - client.initialize(new InitializeRequest(1, termCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("term-session", List.of(new TextContent("run")))).block(TIMEOUT); @@ -393,7 +400,8 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { var result = ctx.execute("echo", "hello"); executedCommand.set("echo hello"); exitCode.set(result.exitCode()); - return PromptResponse.text("Exit code: " + result.exitCode()); + ctx.sendMessage("Exit code: " + result.exitCode()); + return PromptResponse.endTurn(); } } @@ -407,6 +415,7 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { ClientCapabilities termCaps = new ClientCapabilities(null, true); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(termCaps) .requestTimeout(TIMEOUT) .clientCapabilities(termCaps) .createTerminalHandler(req -> { @@ -417,9 +426,10 @@ PromptResponse prompt(PromptRequest req, SyncPromptContext ctx) { .waitForTerminalExitHandler(req -> Mono.just(new WaitForTerminalExitResponse(0, null))) .terminalOutputHandler(req -> Mono.just(new TerminalOutputResponse("hello\n", false, null))) .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) .build(); - client.initialize(new InitializeRequest(1, termCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("term-session", List.of(new TextContent("run")))).block(TIMEOUT); @@ -477,7 +487,7 @@ String prompt(PromptRequest req) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); PromptResponse resp = client.prompt(new PromptRequest("s1", List.of(new TextContent("World")))) .block(TIMEOUT); @@ -528,7 +538,7 @@ void prompt(PromptRequest req, SyncPromptContext ctx) { .requestTimeout(TIMEOUT) .build(); - client.initialize(new InitializeRequest(1, null)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); PromptResponse resp = client.prompt(new PromptRequest("v1", List.of(new TextContent("test")))) .block(TIMEOUT); diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/DefaultRequestTimeoutTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/DefaultRequestTimeoutTest.java new file mode 100644 index 00000000..6f00ec42 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/DefaultRequestTimeoutTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.Set; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The SDK has one default request timeout, 60 seconds: a builder agent, an annotated agent and a + * client that do not set {@code requestTimeout} all wait the same time for an answer. The default + * is not part of the API, so the test reads the timeout the built session was given. + */ +class DefaultRequestTimeoutTest { + + private static final Duration SDK_DEFAULT = Duration.ofSeconds(60); + + @com.agentclientprotocol.sdk.annotation.AcpAgent + static class EchoAgent { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void builderAgentAnnotatedAgentAndClientShareOneDefault() { + Object builderAgent = AcpAgent.sync(InMemoryTransportPair.create().agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .build(); + Object annotatedAgent = AcpAgentSupport.create(new EchoAgent()) + .transport(InMemoryTransportPair.create().agentTransport()) + .build(); + Object asyncClient = AcpClient.async(InMemoryTransportPair.create().clientTransport()).build(); + Object syncClient = AcpClient.sync(InMemoryTransportPair.create().clientTransport()).build(); + + assertThat(requestTimeoutOf(builderAgent)).isEqualTo(SDK_DEFAULT); + assertThat(requestTimeoutOf(annotatedAgent)).isEqualTo(SDK_DEFAULT); + assertThat(requestTimeoutOf(asyncClient)).isEqualTo(SDK_DEFAULT); + assertThat(requestTimeoutOf(syncClient)).isEqualTo(SDK_DEFAULT); + } + + @Test + void anAnnotatedAgentStillTakesItsOwnTimeout() { + Object annotatedAgent = AcpAgentSupport.create(new EchoAgent()) + .requestTimeout(Duration.ofSeconds(7)) + .transport(InMemoryTransportPair.create().agentTransport()) + .build(); + + assertThat(requestTimeoutOf(annotatedAgent)).isEqualTo(Duration.ofSeconds(7)); + } + + /** + * The first {@code Duration requestTimeout} field reachable from {@code root} through the + * SDK's own objects, breadth first. + */ + private static Duration requestTimeoutOf(Object root) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + Deque queue = new ArrayDeque<>(); + queue.add(root); + while (!queue.isEmpty()) { + Object current = queue.poll(); + if (!seen.add(current)) { + continue; + } + for (Class type = current.getClass(); type != null + && type.getName().startsWith("com.agentclientprotocol."); type = type.getSuperclass()) { + for (Field field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.getType().isPrimitive()) { + continue; + } + Object value = read(field, current); + if (field.getName().equals("requestTimeout") && value instanceof Duration duration) { + return duration; + } + if (value != null && value.getClass().getName().startsWith("com.agentclientprotocol.")) { + queue.add(value); + } + } + } + } + throw new AssertionError("No requestTimeout reachable from " + root.getClass().getName()); + } + + private static Object read(Field field, Object target) { + try { + field.setAccessible(true); + return field.get(target); + } + catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/HandlerAnnotationCoverageTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/HandlerAnnotationCoverageTest.java new file mode 100644 index 00000000..27a3203c --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/HandlerAnnotationCoverageTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.io.IOException; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.support.handler.DirectResponseHandler; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.Initialize; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every handler an agent builder takes has a handler annotation that {@link AcpAgentSupport} + * wires, every handler annotation in acp-annotations is wired, and every one declares what it + * advertises in the initialize response: an ACP method added to the builders without an + * annotation, or an annotation without wiring or a protocol mapping, fails here. + */ +class HandlerAnnotationCoverageTest { + + /** Builder methods that take a handler per extension method name, served by @ExtRequest/@ExtNotification. */ + private static final Set EXTENSION_HANDLER_SETTERS = Set.of("extRequestHandler", "extNotificationHandler"); + + @Test + void everySyncBuilderHandlerHasAnAnnotation() { + assertThat(annotationHandlerSetters()).isEqualTo(handlerSetters(AcpAgent.SyncAgentBuilder.class)); + } + + @Test + void everyAsyncBuilderHandlerHasAnAnnotation() { + assertThat(annotationHandlerSetters()).isEqualTo(handlerSetters(AcpAgent.AsyncAgentBuilder.class)); + } + + @Test + void everyHandlerAnnotationIsWired() throws Exception { + Set wired = AcpAgentSupport.handlerAnnotations() + .keySet() + .stream() + .map(Class::getName) + .collect(Collectors.toCollection(TreeSet::new)); + assertThat(wired).isEqualTo(methodAnnotationsInAcpAnnotations()); + } + + /** + * Every handler annotation, the extension ones included, declares what it advertises in + * the initialize response: a capability, or explicitly none. A new annotation without a + * mapping would silently advertise nothing. + */ + @Test + void everyHandlerAnnotationHasAProtocolMapping() throws Exception { + Set mapped = AgentAdvertisement.mappings() + .keySet() + .stream() + .map(Class::getName) + .collect(Collectors.toCollection(TreeSet::new)); + Set expected = new TreeSet<>(methodAnnotationsInAcpAnnotations()); + expected.add(ExtRequest.class.getName()); + expected.add(ExtNotification.class.getName()); + assertThat(mapped).isEqualTo(expected); + } + + /** A handler annotated for a request may return the request's response type as it is. */ + @Test + void everyResponseTypeIsReturnable() { + DirectResponseHandler direct = new DirectResponseHandler(); + Set unsupported = new TreeSet<>(); + for (Method setter : AcpAgent.SyncAgentBuilder.class.getMethods()) { + // Bridges re-declare the agent-aware overloads inherited from the builder's setter base, + // whose handlers' response type is a type variable. + if (setter.isBridge() || setter.getDeclaringClass() != AcpAgent.SyncAgentBuilder.class + || !setter.getName().endsWith("Handler") + || EXTENSION_HANDLER_SETTERS.contains(setter.getName())) { + continue; + } + Method handle = Arrays.stream(setter.getParameterTypes()[0].getMethods()) + .filter(method -> Modifier.isAbstract(method.getModifiers())) + .findFirst() + .orElseThrow(); + if (handle.getReturnType() != void.class + && !direct.supportsReturnType(AcpMethodParameter.forReturnType(handle))) { + unsupported.add(handle.getReturnType().getSimpleName()); + } + } + assertThat(unsupported).isEmpty(); + } + + @Test + void eachAnnotationMarksADistinctMethod() { + var methods = AcpAgentSupport.handlerAnnotations().values(); + assertThat(Set.copyOf(methods)).hasSameSizeAs(methods); + } + + /** {@code @SetSessionMode} serves {@code setSessionModeHandler}, and so on. */ + private static Set annotationHandlerSetters() { + return AcpAgentSupport.handlerAnnotations() + .keySet() + .stream() + .map(Class::getSimpleName) + .map(name -> Character.toLowerCase(name.charAt(0)) + name.substring(1) + "Handler") + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static Set handlerSetters(Class builder) { + return Arrays.stream(builder.getMethods()) + .filter(method -> method.getDeclaringClass() == builder) + .map(Method::getName) + .filter(name -> name.endsWith("Handler")) + .filter(name -> !EXTENSION_HANDLER_SETTERS.contains(name)) + .collect(Collectors.toCollection(TreeSet::new)); + } + + /** The method-level annotations of acp-annotations, other than the extension ones. */ + private static Set methodAnnotationsInAcpAnnotations() throws Exception { + Set found = new TreeSet<>(); + for (String className : annotationPackageClassNames()) { + Class type = Class.forName(className); + if (!type.isAnnotation() || type == ExtRequest.class || type == ExtNotification.class) { + continue; + } + Target target = type.getAnnotation(Target.class); + if (target != null && List.of(target.value()).equals(List.of(ElementType.METHOD))) { + found.add(className); + } + } + return found; + } + + private static List annotationPackageClassNames() throws IOException, URISyntaxException { + Path root = Path.of(Initialize.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + String prefix = Initialize.class.getPackageName().replace('.', '/') + "/"; + Stream entries; + if (Files.isDirectory(root)) { + try (Stream paths = Files.walk(root)) { + entries = paths.map(path -> root.relativize(path).toString().replace('\\', '/')).toList().stream(); + } + } + else { + try (JarFile jar = new JarFile(root.toFile())) { + entries = Collections.list(jar.entries()).stream().map(entry -> entry.getName()).toList().stream(); + } + } + return entries.filter(name -> name.startsWith(prefix) && name.endsWith(".class")) + .map(name -> name.substring(0, name.length() - ".class".length())) + .filter(name -> name.indexOf('/', prefix.length()) < 0 && !name.contains("$") + && !name.endsWith("package-info")) + .map(name -> name.replace('/', '.')) + .toList(); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InitializeAdvertisementTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InitializeAdvertisementTest.java new file mode 100644 index 00000000..84ba8421 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InitializeAdvertisementTest.java @@ -0,0 +1,570 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.AuthMethod; +import com.agentclientprotocol.sdk.annotation.Authenticate; +import com.agentclientprotocol.sdk.annotation.Cancel; +import com.agentclientprotocol.sdk.annotation.CloseSession; +import com.agentclientprotocol.sdk.annotation.DeleteSession; +import com.agentclientprotocol.sdk.annotation.DisableProvider; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.ForkSession; +import com.agentclientprotocol.sdk.annotation.Initialize; +import com.agentclientprotocol.sdk.annotation.ListProviders; +import com.agentclientprotocol.sdk.annotation.ListSessions; +import com.agentclientprotocol.sdk.annotation.LoadSession; +import com.agentclientprotocol.sdk.annotation.Logout; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.ResumeSession; +import com.agentclientprotocol.sdk.annotation.SetProvider; +import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.annotation.SetSessionMode; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthMethodAgent; +import com.agentclientprotocol.sdk.spec.AcpSchema.AuthMethodTerminal; +import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.Implementation; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.SessionCapabilities; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * An annotated agent advertises, in its {@code initialize} response, what its annotations + * declare: the capabilities its handlers imply, its auth methods, the MCP transports and prompt + * content it accepts, and its name and version as {@code agentInfo}. Each test initializes a + * real client against the agent. + */ +class InitializeAdvertisementTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + @AcpAgent(name = "full-agent", version = "2.1.0", title = "Full Agent", mcpHttp = true, authMethods = { + @AuthMethod(id = "api-key", name = "API key", description = "Reads FULL_API_KEY"), + @AuthMethod(id = "login", name = "Log in", type = AuthMethod.Type.TERMINAL, args = "--login", + env = { "FULL_MODE=login", "FULL_EMPTY=" }) }) + static class FullAgent { + + @Authenticate + AcpSchema.AuthenticateResponse authenticate(AcpSchema.AuthenticateRequest request) { + return new AcpSchema.AuthenticateResponse(null); + } + + @Logout + AcpSchema.LogoutResponse logout() { + return new AcpSchema.LogoutResponse(null); + } + + @NewSession + AcpSchema.NewSessionResponse newSession() { + return new AcpSchema.NewSessionResponse("s", null, null); + } + + @LoadSession + AcpSchema.LoadSessionResponse load() { + return null; + } + + @ListSessions + AcpSchema.ListSessionsResponse list() { + return null; + } + + @ResumeSession + AcpSchema.ResumeSessionResponse resume() { + return null; + } + + @CloseSession + AcpSchema.CloseSessionResponse close() { + return null; + } + + @DeleteSession + AcpSchema.DeleteSessionResponse delete() { + return null; + } + + @SetSessionMode + AcpSchema.SetSessionModeResponse mode() { + return null; + } + + @SetSessionConfigOption + AcpSchema.SetSessionConfigOptionResponse config() { + return null; + } + + @Prompt(image = true, embeddedContext = true) + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + @Cancel + void cancel() { + } + + @ExtRequest("_full/ping") + Map ping() { + return Map.of(); + } + + } + + @AcpAgent + static class MinimalAgent { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(name = "unstable-agent", version = "1") + static class UnstableAgent { + + @ForkSession + AcpSchema.ForkSessionResponse fork() { + return null; + } + + @ListProviders + AcpSchema.ListProvidersResponse providers() { + return null; + } + + @SetProvider + AcpSchema.SetProviderResponse setProvider() { + return null; + } + + @DisableProvider + AcpSchema.DisableProviderResponse disableProvider() { + return null; + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void anAgentWithoutInitializeAdvertisesWhatItsAnnotationsDeclare() { + InitializeResponse response = initialize(AcpAgentSupport.create(new FullAgent()), terminalAuthClient()); + + assertThat(response.protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + assertThat(response.agentInfo()).isEqualTo(new Implementation("full-agent", "2.1.0", "Full Agent")); + AgentCapabilities capabilities = response.agentCapabilities(); + assertThat(capabilities.loadSession()).isTrue(); + SessionCapabilities sessions = capabilities.sessionCapabilities(); + assertThat(sessions).isNotNull(); + assertThat(sessions.list()).isNotNull(); + assertThat(sessions.resume()).isNotNull(); + assertThat(sessions.close()).isNotNull(); + assertThat(sessions.delete()).isNotNull(); + assertThat(sessions.fork()).isNull(); + assertThat(sessions.additionalDirectories()).isNull(); + assertThat(capabilities.auth()).isNotNull(); + assertThat(capabilities.auth().logout()).isNotNull(); + assertThat(capabilities.mcpCapabilities().http()).isTrue(); + assertThat(capabilities.mcpCapabilities().sse()).isFalse(); + assertThat(capabilities.promptCapabilities().image()).isTrue(); + assertThat(capabilities.promptCapabilities().embeddedContext()).isTrue(); + assertThat(capabilities.promptCapabilities().audio()).isFalse(); + assertThat(capabilities.providers()).isNull(); + assertThat(response.authMethods()).containsExactly( + new AuthMethodAgent("api-key", "API key", "Reads FULL_API_KEY"), + new AuthMethodTerminal("login", "Log in", null, List.of("--login"), + Map.of("FULL_MODE", "login", "FULL_EMPTY", ""), null)); + } + + @AcpAgent(additionalDirectories = true) + static class MultiRootAgent { + + final java.util.List received = new java.util.concurrent.CopyOnWriteArrayList<>(); + + @NewSession + NewSessionResponse newSession(NewSessionRequest request) { + received.addAll(request.additionalDirectories()); + return new NewSessionResponse("multi-root", null, null); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void additionalDirectoriesAreAdvertisedWhenDeclared() { + InitializeResponse declared = initialize(AcpAgentSupport.create(new MultiRootAgent()), Function.identity()); + assertThat(declared.agentCapabilities().sessionCapabilities()).isNotNull(); + assertThat(declared.agentCapabilities().sessionCapabilities().additionalDirectories()).isNotNull(); + } + + @Test + void aClientSendsAdditionalDirectoriesToAnAgentThatDeclaresThem() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + MultiRootAgent bean = new MultiRootAgent(); + AcpAgentSupport agent = AcpAgentSupport.create(bean) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + assertThat(client.getAgentCapabilities().supportsAdditionalDirectories()).isTrue(); + client.newSession(new NewSessionRequest("/work", List.of(), List.of("/shared"))).block(TIMEOUT); + assertThat(bean.received).containsExactly("/shared"); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + + @AcpAgent(additionalDirectories = true) + static class MultiRootWithoutNewSession { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + /** The default session/new would drop the directories, which ACP forbids. */ + @Test + void declaringAdditionalDirectoriesNeedsANewSessionMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new MultiRootWithoutNewSession()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("additionalDirectories") + .hasMessageContaining("@NewSession"); + } + + /** What the client concludes from the response: every annotated method is supported. */ + @Test + void theClientNegotiatesEveryAnnotatedMethod() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgentSupport.create(new FullAgent()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + NegotiatedCapabilities negotiated = client.getAgentCapabilities(); + assertThat(negotiated.supportsLoadSession()).isTrue(); + assertThat(negotiated.supportsListSessions()).isTrue(); + assertThat(negotiated.supportsResumeSession()).isTrue(); + assertThat(negotiated.supportsCloseSession()).isTrue(); + assertThat(negotiated.supportsDeleteSession()).isTrue(); + assertThat(negotiated.supportsLogout()).isTrue(); + assertThat(negotiated.supportsImageContent()).isTrue(); + assertThat(negotiated.supportsMcpHttp()).isTrue(); + assertThat(negotiated.supportsForkSession()).isFalse(); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + + @Test + void terminalAuthMethodsAreAdvertisedOnlyToAClientThatSupportsThem() { + InitializeResponse response = initialize(AcpAgentSupport.create(new FullAgent()), Function.identity()); + + assertThat(response.authMethods()).extracting(AcpSchema.AuthMethod::id).containsExactly("api-key"); + } + + @Test + void aMinimalAgentAdvertisesNoOptionalCapabilityAndItsClassName() { + InitializeResponse response = initialize(AcpAgentSupport.create(new MinimalAgent()), Function.identity()); + + assertThat(response.agentInfo().name()).isEqualTo("MinimalAgent"); + assertThat(response.agentInfo().version()).isNotBlank(); + AgentCapabilities capabilities = response.agentCapabilities(); + assertThat(capabilities.loadSession()).isFalse(); + assertThat(capabilities.sessionCapabilities()).isNull(); + assertThat(capabilities.auth()).isNull(); + assertThat(capabilities.mcpCapabilities()).isEqualTo(new AcpSchema.McpCapabilities()); + assertThat(capabilities.promptCapabilities()).isEqualTo(new AcpSchema.PromptCapabilities()); + assertThat(response.authMethods()).isNullOrEmpty(); + } + + @Test + void unstableHandlersAdvertiseTheirUnstableCapabilities() { + InitializeResponse response = initialize(AcpAgentSupport.create(new UnstableAgent()), Function.identity()); + + assertThat(response.agentCapabilities().sessionCapabilities().fork()).isNotNull(); + assertThat(response.agentCapabilities().providers()).isNotNull(); + } + + @AcpAgent(name = "merging-agent", version = "1") + static class MergingAgent { + + @LoadSession + AcpSchema.LoadSessionResponse load() { + return null; + } + + @Authenticate + AcpSchema.AuthenticateResponse authenticate() { + return new AcpSchema.AuthenticateResponse(null); + } + + @Initialize + InitializeResponse initialize(InitializeRequest request) { + // ok() carries loadSession=false and all-false prompt and MCP capabilities + return new InitializeResponse(1, + AgentCapabilities.builder() + .sessionCapabilities(new SessionCapabilities(null, null, null, null, Map.of(), null)) + .promptCapabilities(new AcpSchema.PromptCapabilities(true, false, false)) + .build(), + List.of(new AuthMethodAgent("token", "Token", "dynamic")), new Implementation("custom", "9"), + Map.of("k", "v")); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(name = "ok-agent", version = "1", authMethods = @AuthMethod(id = "token", name = "Token")) + static class OkInitializeAgent { + + @LoadSession + AcpSchema.LoadSessionResponse load() { + return null; + } + + @Authenticate + AcpSchema.AuthenticateResponse authenticate() { + return new AcpSchema.AuthenticateResponse(null); + } + + @Initialize + InitializeResponse initialize() { + return InitializeResponse.ok(); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + /** The pre-0.80 idiom, returning ok(), no longer hides the agent's capabilities. */ + @Test + void anInitializeReturningOkKeepsTheDerivedResponse() { + InitializeResponse response = initialize(AcpAgentSupport.create(new OkInitializeAgent()), Function.identity()); + + assertThat(response.agentCapabilities().loadSession()).isTrue(); + assertThat(response.authMethods()).extracting(AcpSchema.AuthMethod::id).containsExactly("token"); + assertThat(response.agentInfo()).isEqualTo(new Implementation("ok-agent", "1")); + } + + @Test + void anInitializeResponseIsLaidOverTheDerivedOne() { + InitializeResponse response = initialize(AcpAgentSupport.create(new MergingAgent()), Function.identity()); + + AgentCapabilities capabilities = response.agentCapabilities(); + assertThat(capabilities.loadSession()).as("derived, kept although returned false").isTrue(); + assertThat(capabilities.sessionCapabilities().additionalDirectories()).as("returned").isNotNull(); + assertThat(capabilities.promptCapabilities().audio()).as("returned").isTrue(); + assertThat(response.authMethods()).extracting(AcpSchema.AuthMethod::id).containsExactly("token"); + assertThat(response.agentInfo()).isEqualTo(new Implementation("custom", "9")); + assertThat(response.meta()).containsEntry("k", "v"); + } + + @Test + void theProtocolVersionIsTheClientsWhenSupportedAndTheLatestOtherwise() { + assertThat(initialize(AcpAgentSupport.create(new MinimalAgent()), Function.identity(), 1).protocolVersion()) + .isEqualTo(1); + assertThat(initialize(AcpAgentSupport.create(new MinimalAgent()), Function.identity(), 99).protocolVersion()) + .isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + } + + @Test + void aFactoryAdvertisesOnEveryConnection() { + AcpAgentFactory factory = AcpAgentSupport.create(new FullAgent()).requestTimeout(TIMEOUT).buildFactory(); + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(0, AcpJsonMapper.createDefault(), + factory); + server.start().block(TIMEOUT); + try { + for (int i = 0; i < 2; i++) { + AcpAsyncClient client = AcpClient + .async(new StreamableHttpAcpClientTransport( + URI.create("http://127.0.0.1:" + server.getPort() + + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH), + AcpJsonMapper.createDefault())) + .requestTimeout(TIMEOUT) + .build(); + try { + InitializeResponse response = client.initialize().block(TIMEOUT); + assertThat(response.agentCapabilities().loadSession()).isTrue(); + assertThat(response.agentCapabilities().sessionCapabilities().list()).isNotNull(); + assertThat(response.agentInfo().name()).isEqualTo("full-agent"); + assertThat(response.authMethods()).extracting(AcpSchema.AuthMethod::id).containsExactly("api-key"); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + } + finally { + server.closeGracefully().block(TIMEOUT); + } + } + + /** Interceptors see the derived initialize exactly as they see a declared @Initialize method. */ + @Test + void interceptorsSeeTheDerivedInitialize() { + java.util.List seen = new java.util.concurrent.CopyOnWriteArrayList<>(); + com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor recorder = new com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor() { + @Override + public boolean preInvoke(com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext context) { + seen.add("pre " + context.getAcpMethod()); + return true; + } + + @Override + public Object postInvoke(com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext context, + Object result) { + seen.add("post " + context.getAcpMethod() + " " + result.getClass().getSimpleName()); + return result; + } + }; + InitializeResponse response = initialize(AcpAgentSupport.create(new MinimalAgent()).interceptor(recorder), + Function.identity()); + + assertThat(response.agentInfo().name()).isEqualTo("MinimalAgent"); + assertThat(seen).containsExactly("pre initialize", "post initialize InitializeResponse"); + } + + @AcpAgent(authMethods = @AuthMethod(id = "api-key", name = "API key")) + static class AgentMethodWithoutAuthenticate { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(authMethods = { @AuthMethod(id = "same", name = "One"), @AuthMethod(id = "same", name = "Two") }) + static class DuplicateAuthMethodIds { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(authMethods = @AuthMethod(id = "t", name = "T", type = AuthMethod.Type.TERMINAL, env = "NO_EQUALS")) + static class MalformedEnv { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(authMethods = @AuthMethod(id = "t", name = "T", type = AuthMethod.Type.TERMINAL)) + static class TerminalOnly { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void anAgentAuthMethodNeedsAnAuthenticateHandler() { + assertThatThrownBy(() -> AcpAgentSupport.create(new AgentMethodWithoutAuthenticate()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("api-key") + .hasMessageContaining("@Authenticate"); + } + + @Test + void aTerminalOnlyAgentNeedsNoAuthenticateHandler() { + assertThat(AcpAgentSupport.create(new TerminalOnly()).buildFactory()).isNotNull(); + } + + @Test + void malformedAuthMethodsAreRejected() { + assertThatThrownBy(() -> AcpAgentSupport.create(new DuplicateAuthMethodIds())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("same"); + assertThatThrownBy(() -> AcpAgentSupport.create(new MalformedEnv())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("NO_EQUALS"); + } + + private static Function terminalAuthClient() { + return spec -> spec.clientCapabilities( + ClientCapabilities.builder().auth(new AuthCapabilities(true)).build()); + } + + private static InitializeResponse initialize(AcpAgentSupport.Builder builder, + Function clientSpec) { + return initialize(builder, clientSpec, AcpSchema.LATEST_PROTOCOL_VERSION); + } + + private static InitializeResponse initialize(AcpAgentSupport.Builder builder, + Function clientSpec, int protocolVersion) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = builder.transport(pair.agentTransport()).requestTimeout(TIMEOUT).build(); + agent.start(); + AcpAsyncClient client = clientSpec.apply(AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT)) + .build(); + try { + return client.initialize(protocolVersion, null).block(TIMEOUT); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InterceptorContractTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InterceptorContractTest.java new file mode 100644 index 00000000..c24f23ec --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InterceptorContractTest.java @@ -0,0 +1,305 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The interceptor contract around a failing call: what {@code onError} can answer, which + * interceptors it reaches, what {@code afterCompletion} learns, and that every ACP method the + * agent answers, the default {@code session/new} included, passes through the chain. + */ +class InterceptorContractTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private AcpAgentSupport agentSupport; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentSupport != null) { + agentSupport.close(); + } + } + + private AcpAsyncClient connect(AcpAgentSupport.Builder builder) { + agentSupport = builder.transport(transportPair.agentTransport()).requestTimeout(TIMEOUT).build(); + agentSupport.start(); + client = AcpClient.async(transportPair.clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + return client; + } + + private static AcpError failure(Runnable call) { + try { + call.run(); + } + catch (AcpError e) { + return e; + } + throw new AssertionError("the call did not fail with an AcpError"); + } + + @AcpAgent + static class FailingAgent { + + @Prompt + PromptResponse prompt(PromptRequest request) { + throw new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND, "load failed"); + } + + @ExtRequest("_test/ok") + Map ok() { + return Map.of("ok", true); + } + + } + + private String prompt(AcpAsyncClient client) { + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + client.prompt(PromptRequest.text(sessionId, "hi")).block(TIMEOUT); + return sessionId; + } + + @Test + void anAcpProtocolExceptionThrownFromOnErrorIsTheAnswer() { + AcpInterceptor mapper = new AcpInterceptor() { + @Override + public Object onError(AcpInvocationContext context, Throwable ex) { + throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "mapped", Map.of("was", ex.getMessage())); + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(mapper)); + + AcpError error = failure(() -> prompt(client)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INVALID_PARAMS); + assertThat(error.getError().message()).isEqualTo("mapped"); + assertThat(error.getData()).isEqualTo(Map.of("was", "[-32002] load failed")); + } + + @Test + void anyOtherExceptionThrownFromOnErrorIsAnsweredAsAnInternalError() { + AcpInterceptor broken = new AcpInterceptor() { + @Override + public Object onError(AcpInvocationContext context, Throwable ex) { + throw new IllegalStateException("interceptor bug"); + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(broken)); + + AcpError error = failure(() -> prompt(client)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + } + + @Test + void anInterceptorThatThrowsFromOnErrorEndsTheWalk() { + List calls = new CopyOnWriteArrayList<>(); + AcpInterceptor outer = new Recording("outer", 0, calls); + AcpInterceptor mapper = new Recording("mapper", 1, calls) { + @Override + public Object onError(AcpInvocationContext context, Throwable ex) { + super.onError(context, ex); + throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "mapped"); + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(outer).interceptor(mapper)); + + assertThat(failure(() -> prompt(client)).getCode()).isEqualTo(AcpErrorCodes.INVALID_PARAMS); + assertThat(calls).filteredOn(call -> call.startsWith("onError")).containsExactly("onError mapper"); + } + + @Test + void onErrorSkipsInterceptorsWhosePreInvokeDidNotReturnTrue() { + List calls = new CopyOnWriteArrayList<>(); + AcpInterceptor first = new Recording("first", 0, calls); + AcpInterceptor rejecting = new Recording("rejecting", 1, calls) { + @Override + public boolean preInvoke(AcpInvocationContext context) { + super.preInvoke(context); + if (AcpSchema.METHOD_SESSION_PROMPT.equals(context.getAcpMethod())) { + throw new AcpProtocolException(AcpErrorCodes.INVALID_REQUEST, "rejected"); + } + return true; + } + }; + AcpInterceptor last = new Recording("last", 2, calls); + AcpAsyncClient client = connect( + AcpAgentSupport.create(new FailingAgent()).interceptor(first).interceptor(rejecting).interceptor(last)); + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + calls.clear(); + + assertThat(failure(() -> client.prompt(PromptRequest.text(sessionId, "hi")).block(TIMEOUT)).getCode()) + .isEqualTo(AcpErrorCodes.INVALID_REQUEST); + assertThat(calls).filteredOn(call -> call.startsWith("onError")).containsExactly("onError first"); + assertThat(calls).filteredOn(call -> call.startsWith("afterCompletion")) + .containsExactly("afterCompletion first"); + } + + @Test + void theDefaultNewSessionPassesThroughTheInterceptors() { + List calls = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = connect( + AcpAgentSupport.create(new FailingAgent()).interceptor(new Recording("log", 0, calls))); + + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + assertThat(calls).contains("preInvoke log session/new", "afterCompletion log session/new"); + } + + @Test + void anInterceptorCanVetoTheDefaultNewSession() { + AcpInterceptor deny = new AcpInterceptor() { + @Override + public boolean preInvoke(AcpInvocationContext context) { + if (AcpSchema.METHOD_SESSION_NEW.equals(context.getAcpMethod())) { + throw new AcpProtocolException(AcpErrorCodes.AUTHENTICATION_REQUIRED, "log in first"); + } + return true; + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(deny)); + + assertThat(failure(() -> client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT)) + .getCode()).isEqualTo(AcpErrorCodes.AUTHENTICATION_REQUIRED); + } + + @Test + void afterCompletionReceivesTheFailureOrNull() { + Map endings = new ConcurrentHashMap<>(); + AcpInterceptor recorder = new AcpInterceptor() { + @Override + public void afterCompletion(AcpInvocationContext context, Throwable ex) { + endings.put(context.getAcpMethod(), (ex != null) ? ex : "none"); + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(recorder)); + + client.sendExtRequest("_test/ok", Map.of()).block(TIMEOUT); + failure(() -> prompt(client)); + + assertThat(endings).containsEntry("_test/ok", "none"); + assertThat(endings.get(AcpSchema.METHOD_SESSION_PROMPT)).isInstanceOf(AcpProtocolException.class) + .hasFieldOrPropertyWithValue("code", AcpErrorCodes.RESOURCE_NOT_FOUND); + } + + @Test + void afterCompletionReceivesTheExceptionOnErrorThrew() { + AcpProtocolException mapped = new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "mapped"); + Map endings = new ConcurrentHashMap<>(); + AcpInterceptor interceptor = new AcpInterceptor() { + @Override + public Object onError(AcpInvocationContext context, Throwable ex) { + throw mapped; + } + + @Override + public void afterCompletion(AcpInvocationContext context, Throwable ex) { + endings.put(context.getAcpMethod(), (ex != null) ? ex : "none"); + } + }; + AcpAsyncClient client = connect(AcpAgentSupport.create(new FailingAgent()).interceptor(interceptor)); + + failure(() -> prompt(client)); + + assertThat(endings.get(AcpSchema.METHOD_SESSION_PROMPT)).isSameAs(mapped); + } + + @Test + void negativeDurationsAreRejectedByTheSetters() { + AcpAgentSupport.Builder builder = AcpAgentSupport.create(new FailingAgent()); + assertThatThrownBy(() -> builder.cancelGracePeriod(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cancelGracePeriod"); + assertThatThrownBy(() -> builder.maxPromptDuration(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxPromptDuration"); + assertThatThrownBy(() -> builder.cancelGracePeriod(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> builder.maxPromptDuration(null)).isInstanceOf(IllegalArgumentException.class); + assertThat(builder.cancelGracePeriod(Duration.ZERO).maxPromptDuration(Duration.ZERO).buildFactory()) + .isNotNull(); + } + + @Test + void aNullInterceptorIsRejectedByTheSetter() { + AcpAgentSupport.Builder builder = AcpAgentSupport.create(new FailingAgent()); + assertThatThrownBy(() -> builder.interceptor(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("interceptor"); + } + + /** Records each step it sees as "step name method". */ + static class Recording implements AcpInterceptor { + + private final String name; + + private final int order; + + private final List calls; + + Recording(String name, int order, List calls) { + this.name = name; + this.order = order; + this.calls = calls; + } + + @Override + public int getOrder() { + return order; + } + + @Override + public boolean preInvoke(AcpInvocationContext context) { + calls.add("preInvoke " + name + " " + context.getAcpMethod()); + return true; + } + + @Override + public Object onError(AcpInvocationContext context, Throwable ex) { + calls.add("onError " + name); + return null; + } + + @Override + public void afterCompletion(AcpInvocationContext context, Throwable ex) { + calls.add("afterCompletion " + name + (AcpSchema.METHOD_SESSION_NEW.equals(context.getAcpMethod()) + ? " " + context.getAcpMethod() : "")); + } + + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InvocationInfrastructureTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InvocationInfrastructureTest.java new file mode 100644 index 00000000..7b5c71b8 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/InvocationInfrastructureTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.support.handler.AsyncValueHandler; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlerComposite; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlingException; +import com.agentclientprotocol.sdk.agent.support.handler.VoidHandler; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolutionException; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolverComposite; +import com.agentclientprotocol.sdk.agent.support.resolver.SessionIdResolver; +import com.agentclientprotocol.sdk.annotation.SessionId; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for the extension points of the annotation layer: method parameters, the + * resolver and return-value composites, and the invocation context. + */ +class InvocationInfrastructureTest { + + @SuppressWarnings("unused") + static class Handlers { + + Mono prompt(@SessionId String sessionId, PromptRequest request) { + return Mono.just(PromptResponse.endTurn()); + } + + @SuppressWarnings("rawtypes") + Mono raw() { + return Mono.empty(); + } + + void nothing() { + } + + } + + private static Method method(String name) { + for (Method method : Handlers.class.getDeclaredMethods()) { + if (method.getName().equals(name)) { + return method; + } + } + throw new AssertionError(name); + } + + private static AcpInvocationContext context(String sessionId) { + return AcpInvocationContext.builder() + .acpMethod("session/prompt") + .request(new PromptRequest(sessionId, List.of())) + .sessionId(sessionId) + .build(); + } + + @Test + void methodParameterDescribesAParameterAndTheReturnType() { + Method prompt = method("prompt"); + AcpMethodParameter sessionId = new AcpMethodParameter(prompt, 0); + AcpMethodParameter returnType = AcpMethodParameter.forReturnType(prompt); + + assertThat(sessionId.getMethod()).isEqualTo(prompt); + assertThat(sessionId.getIndex()).isZero(); + assertThat(sessionId.getParameterType()).isEqualTo(String.class); + assertThat(sessionId.hasAnnotation(SessionId.class)).isTrue(); + assertThat(sessionId.getAnnotation(Deprecated.class)).isNull(); + assertThat(sessionId.isReturnType()).isFalse(); + assertThat(sessionId.toString()).startsWith("parameter 0 (").endsWith(") of prompt"); + + assertThat(returnType.isReturnType()).isTrue(); + assertThat(returnType.getName()).isNull(); + assertThat(returnType.getAnnotations()).isEmpty(); + assertThat(returnType.getParameterType()).isEqualTo(Mono.class); + assertThat(returnType).hasToString("return type of prompt"); + } + + @Test + void methodParametersAreEqualByMethodAndIndex() { + Method prompt = method("prompt"); + + assertThat(new AcpMethodParameter(prompt, 1)).isEqualTo(new AcpMethodParameter(prompt, 1)) + .hasSameHashCodeAs(new AcpMethodParameter(prompt, 1)) + .isNotEqualTo(new AcpMethodParameter(prompt, 0)) + .isNotEqualTo(AcpMethodParameter.forReturnType(method("raw"))) + .isNotEqualTo("parameter"); + } + + @Test + void asyncValueTypeIsTheTypeArgumentOrObject() { + assertThat(AsyncValueHandler.getValueType(AcpMethodParameter.forReturnType(method("prompt")))) + .isEqualTo(PromptResponse.class); + assertThat(AsyncValueHandler.getValueType(AcpMethodParameter.forReturnType(method("raw")))) + .isEqualTo(Object.class); + } + + @Test + void asyncValueHandlerPassesNullThrough() { + AcpMethodParameter returnType = AcpMethodParameter.forReturnType(method("prompt")); + + assertThat(new AsyncValueHandler().handleReturnValue(null, returnType, context("s"))).isNull(); + } + + @Test + void resolverCompositeUsesTheFirstSupportingResolverAndCachesTheChoice() { + AtomicInteger asked = new AtomicInteger(); + ArgumentResolver counting = new ArgumentResolver() { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + asked.incrementAndGet(); + return false; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + throw new AssertionError("not supported"); + } + + }; + ArgumentResolverComposite composite = new ArgumentResolverComposite().addResolver(counting) + .addResolvers(List.of(new SessionIdResolver())); + AcpMethodParameter sessionId = new AcpMethodParameter(method("prompt"), 0); + + assertThat(composite.supportsParameter(sessionId)).isTrue(); + assertThat(composite.resolveArgument(sessionId, context("s1"))).isEqualTo("s1"); + assertThat(asked).as("the choice is cached").hasValue(1); + composite.clearCache(); + composite.supportsParameter(sessionId); + assertThat(asked).hasValue(2); + assertThat(composite.getResolvers()).hasSize(2); + } + + @Test + void resolverCompositeRefusesAParameterNoResolverSupports() { + ArgumentResolverComposite composite = new ArgumentResolverComposite(); + AcpMethodParameter request = new AcpMethodParameter(method("prompt"), 1); + + assertThat(composite.supportsParameter(request)).isFalse(); + assertThatThrownBy(() -> composite.resolveArgument(request, context("s1"))) + .isInstanceOf(ArgumentResolutionException.class) + .hasMessageContaining(PromptRequest.class.getName()); + } + + @Test + void sessionIdResolverRequiresASessionId() { + AcpInvocationContext noSession = AcpInvocationContext.builder() + .acpMethod("initialize") + .request(new Object()) + .build(); + + assertThatThrownBy( + () -> new SessionIdResolver().resolveArgument(new AcpMethodParameter(method("prompt"), 0), noSession)) + .isInstanceOf(ArgumentResolutionException.class) + .hasMessageContaining("Session ID"); + } + + @Test + void returnValueCompositeRefusesAReturnTypeNoHandlerSupports() { + ReturnValueHandlerComposite composite = new ReturnValueHandlerComposite() + .addHandlers(List.of(new VoidHandler())); + AcpMethodParameter monoReturn = AcpMethodParameter.forReturnType(method("prompt")); + + assertThat(composite.getHandlers()).hasSize(1); + assertThat(composite.supportsReturnType(monoReturn)).isFalse(); + assertThat(composite.supportsReturnType(AcpMethodParameter.forReturnType(method("nothing")))).isTrue(); + assertThatThrownBy(() -> composite.handleReturnValue(Mono.empty(), monoReturn, context("s"))) + .isInstanceOf(ReturnValueHandlingException.class) + .hasMessageContaining(Mono.class.getName()); + } + + @Test + void invocationContextCarriesTypedAttributes() { + AcpInvocationContext context = context("s1"); + context.setAttribute("count", 3); + + assertThat(context.getAttribute("count")).contains(3); + assertThat(context.getAttribute("count", Integer.class)).contains(3); + assertThat(context.getAttribute("count", String.class)).isEmpty(); + assertThat(context.getAttribute("missing")).isEmpty(); + assertThat(context.getRequest(PromptRequest.class).sessionId()).isEqualTo("s1"); + assertThat(context.getPromptContext()).isEmpty(); + assertThat(context.getSyncPromptContext()).isEmpty(); + assertThat(context.getCapabilities()).isEmpty(); + } + + @Test + void invocationContextRequiresMethodAndRequest() { + assertThatThrownBy(() -> AcpInvocationContext.builder().request(new Object()).build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("acpMethod"); + assertThatThrownBy(() -> AcpInvocationContext.builder().acpMethod("initialize").build()) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("request"); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/ReadmeReturnValueHandlerTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/ReadmeReturnValueHandlerTest.java new file mode 100644 index 00000000..db714052 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/ReadmeReturnValueHandlerTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlingException; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The custom return value handler example in this module's README: it serves the type it adds, + * and registering it does not replace a built-in handler. The old example handled {@code CompletableFuture}, which the + * built-in {@code AsyncValueHandler} already does; as a custom handler it was asked first, so + * a {@code CompletableFuture} from {@code @Prompt} failed every prompt with -32603. + */ +class ReadmeReturnValueHandlerTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private AcpAgentSupport agentSupport; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentSupport != null) { + agentSupport.close(); + } + } + + // The README example, verbatim (README.md, "Custom Return Value Handlers"). + public class FutureHandler implements ReturnValueHandler { + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + // Future itself only: a CompletableFuture stays with the built-in handler + return returnType.getParameterType() == Future.class; + } + + @Override + public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + try { + return ((Future) returnValue).get(); // the method's response, such as a PromptResponse + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new CancellationException("Interrupted while waiting for the handler's result"); + } + catch (ExecutionException e) { + throw new ReturnValueHandlingException("The handler's Future failed", e.getCause()); + } + } + } + + @AcpAgent + static class FutureAgent { + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @Prompt + CompletableFuture prompt(PromptRequest request) { + return CompletableFuture.completedFuture("hello"); + } + + @ExtRequest("_test/plain-future") + Future> plainFuture() { + return executor.submit(() -> Map.of("done", true)); + } + + } + + @Test + void theReadmeExampleIsTheOneTestedHere() throws IOException { + String readme = Files.readString(Path.of("README.md")); + String section = readme.substring(readme.indexOf("## Custom Return Value Handlers")); + assertThat(section).contains("public class FutureHandler implements ReturnValueHandler") + .contains("return returnType.getParameterType() == Future.class;"); + } + + @Test + void registeringTheReadmeExampleKeepsTheBuiltInHandlers() { + List updates = new CopyOnWriteArrayList<>(); + agentSupport = AcpAgentSupport.create(new FutureAgent()) + .returnValueHandler(new FutureHandler()) + .transport(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agentSupport.start(); + client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(update -> { + updates.add(update); + return reactor.core.publisher.Mono.empty(); + }) + .build(); + client.initialize().block(TIMEOUT); + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT).sessionId(); + + PromptResponse response = client.prompt(PromptRequest.text(sessionId, "hi")).block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(updates).isNotEmpty(); + // and the type the example adds is served + assertThat(client.sendExtRequest("_test/plain-future", Map.of()).block(TIMEOUT)).isEqualTo(Map.of("done", true)); + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/RegistrationErrorTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/RegistrationErrorTest.java new file mode 100644 index 00000000..520f123d --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/RegistrationErrorTest.java @@ -0,0 +1,398 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ConfigValue; +import com.agentclientprotocol.sdk.annotation.ExtNotification; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.ListSessions; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.SessionId; +import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.annotation.SetSessionMode; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Misusing the annotations fails when the agent is built, not at the first request, with a + * message that names the class, the method and the fix. One test per message. + */ +class RegistrationErrorTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @AcpAgent + static class UnresolvableParameter { + + @Prompt + PromptResponse prompt(Integer unresolvable) { + return PromptResponse.endTurn(); + } + + } + + @Test + void aParameterNoResolverHandles() { + assertThatThrownBy(() -> AcpAgentSupport.create(new UnresolvableParameter()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("UnresolvableParameter.prompt") + .hasMessageContaining("java.lang.Integer") + .hasMessageContaining("ArgumentResolver"); + } + + /** A custom resolver registered for the type makes the same method valid. */ + @Test + void aCustomResolverMakesTheParameterResolvable() { + ArgumentResolver integers = new ArgumentResolver() { + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return parameter.getParameterType() == Integer.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return 7; + } + }; + assertThat(AcpAgentSupport.create(new UnresolvableParameter()).argumentResolver(integers).buildFactory()) + .isNotNull(); + } + + @AcpAgent + static class OtherMethodsRequest { + + @Prompt + PromptResponse prompt(NewSessionRequest wrongRequestType) { + return PromptResponse.endTurn(); + } + + } + + @Test + void aRequestTypeOfAnotherMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new OtherMethodsRequest()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OtherMethodsRequest.prompt") + .hasMessageContaining("NewSessionRequest") + .hasMessageContaining("PromptRequest"); + } + + @AcpAgent + static class SessionIdWithoutASession { + + @ListSessions + AcpSchema.ListSessionsResponse list(@SessionId String sessionId) { + return new AcpSchema.ListSessionsResponse(List.of()); + } + + } + + @Test + void aSessionIdOnAMethodWithoutASession() { + assertThatThrownBy(() -> AcpAgentSupport.create(new SessionIdWithoutASession()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("SessionIdWithoutASession.list") + .hasMessageContaining("@SessionId") + .hasMessageContaining("session/list"); + } + + @AcpAgent + static class SessionIdOnAnExtension { + + @ExtRequest("_example/ping") + Map ping(Map params, @SessionId String sessionId) { + return Map.of(); + } + + } + + /** An extension's @SessionId parameter used to receive the params, and every call failed -32602. */ + @Test + void aSessionIdOnAnExtensionMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new SessionIdOnAnExtension())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("SessionIdOnAnExtension.ping") + .hasMessageContaining("@SessionId"); + } + + @AcpAgent + static class ValueFromAnExtNotification { + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + @ExtNotification("_example/file_opened") + String fileOpened(Map params) { + return "dropped"; + } + + } + + /** A notification has no answer: a value its method returns used to be dropped silently. */ + @Test + void aValueReturnedFromAnExtNotification() { + assertThatThrownBy(() -> AcpAgentSupport.create(new ValueFromAnExtNotification()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ValueFromAnExtNotification.fileOpened") + .hasMessageContaining("java.lang.String") + .hasMessageContaining("void"); + } + + @AcpAgent + static class PromptContextElsewhere { + + @NewSession + NewSessionResponse newSession(PromptContext context) { + return new NewSessionResponse("s", null, null); + } + + } + + @Test + void aPromptContextOutsideAPromptMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new PromptContextElsewhere()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PromptContextElsewhere.newSession") + .hasMessageContaining("PromptContext") + .hasMessageContaining("@Prompt"); + } + + @AcpAgent + static class ConfigValueElsewhere { + + @NewSession + NewSessionResponse newSession(@ConfigValue String value) { + return new NewSessionResponse("s", null, null); + } + + } + + @Test + void aConfigValueOutsideASetConfigOptionMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new ConfigValueElsewhere()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ConfigValueElsewhere.newSession") + .hasMessageContaining("@ConfigValue") + .hasMessageContaining("@SetSessionConfigOption"); + } + + @AcpAgent + static class UnsupportedReturnType { + + @NewSession + Integer newSession(NewSessionRequest request) { + return 42; + } + + } + + @Test + void aReturnTypeNoHandlerAccepts() { + assertThatThrownBy(() -> AcpAgentSupport.create(new UnsupportedReturnType()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("UnsupportedReturnType.newSession") + .hasMessageContaining("java.lang.Integer") + .hasMessageContaining("NewSessionResponse"); + } + + @AcpAgent + static class OtherMethodsResponse { + + @NewSession + InitializeResponse newSession(NewSessionRequest request) { + return InitializeResponse.ok(); + } + + } + + @Test + void aResponseTypeOfAnotherMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new OtherMethodsResponse()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("OtherMethodsResponse.newSession") + .hasMessageContaining("InitializeResponse") + .hasMessageContaining("NewSessionResponse"); + } + + @AcpAgent + static class MissingReturn { + + @NewSession + void newSession(NewSessionRequest request) { + } + + } + + @Test + void aMissingReturnWhereAResponseIsRequired() { + assertThatThrownBy(() -> AcpAgentSupport.create(new MissingReturn()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("MissingReturn.newSession") + .hasMessageContaining("void") + .hasMessageContaining("NewSessionResponse"); + } + + @AcpAgent + static class TwoPromptMethods { + + @Prompt + PromptResponse first() { + return PromptResponse.endTurn(); + } + + @Prompt + PromptResponse second() { + return PromptResponse.refusal(); + } + + } + + /** One of the two used to win silently. */ + @Test + void twoMethodsForOneHandler() { + assertThatThrownBy(() -> AcpAgentSupport.create(new TwoPromptMethods())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("@Prompt") + .hasMessageContaining("first") + .hasMessageContaining("second") + .hasMessageContaining(TwoPromptMethods.class.getSimpleName()); + } + + @AcpAgent + static class TwoAnnotationsOnOneMethod { + + @NewSession + @Prompt + NewSessionResponse both() { + return new NewSessionResponse("s", null, null); + } + + } + + @Test + void oneMethodWithTwoHandlerAnnotations() { + assertThatThrownBy(() -> AcpAgentSupport.create(new TwoAnnotationsOnOneMethod())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("TwoAnnotationsOnOneMethod.both") + .hasMessageContaining("@NewSession") + .hasMessageContaining("@Prompt"); + } + + @AcpAgent + static class ModesWithoutNewSession { + + @SetSessionMode + AcpSchema.SetSessionModeResponse setMode() { + return new AcpSchema.SetSessionModeResponse(null); + } + + } + + @AcpAgent + static class ConfigOptionsWithoutNewSession { + + @SetSessionConfigOption + AcpSchema.SetSessionConfigOptionResponse set() { + return new AcpSchema.SetSessionConfigOptionResponse(List.of()); + } + + } + + /** + * The default session/new offers no modes or config options, so a client would never see any + * to set: such an agent needs a @NewSession method that returns them. + */ + @Test + void modesOrConfigOptionsWithoutANewSessionMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new ModesWithoutNewSession()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("ModesWithoutNewSession") + .hasMessageContaining("@SetSessionMode") + .hasMessageContaining("@NewSession"); + assertThatThrownBy(() -> AcpAgentSupport.create(new ConfigOptionsWithoutNewSession()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("@SetSessionConfigOption") + .hasMessageContaining("@NewSession"); + } + + @AcpAgent + static class NoPromptMethod { + + @NewSession + NewSessionResponse newSession() { + return new NewSessionResponse("s", null, null); + } + + } + + @Test + void anAgentWithoutAPromptMethod() { + assertThatThrownBy(() -> AcpAgentSupport.create(new NoPromptMethod()).buildFactory()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("NoPromptMethod") + .hasMessageContaining("@Prompt"); + } + + @AcpAgent + static class PrivateAndStaticHandlers { + + @NewSession + private static NewSessionResponse newSession() { + return new NewSessionResponse("s1", null, null); + } + + @Prompt + private PromptResponse prompt() { + return PromptResponse.refusal(); + } + + } + + /** Visibility and static are not misuse: such handlers are served. */ + @Test + void privateAndStaticHandlersAreServed() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgentSupport.create(new PrivateAndStaticHandlers()) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + String session = client.newSession(new NewSessionRequest("/w", List.of())).block(TIMEOUT).sessionId(); + assertThat(client.prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("x")))) + .block(TIMEOUT) + .stopReason()).isEqualTo(AcpSchema.StopReason.REFUSAL); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/TypedConfigValueTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/TypedConfigValueTest.java new file mode 100644 index 00000000..53d30ef5 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/TypedConfigValueTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ConfigId; +import com.agentclientprotocol.sdk.annotation.ConfigValue; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.SessionId; +import com.agentclientprotocol.sdk.annotation.SetSessionConfigOption; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.SetSessionConfigOptionResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A {@code @SetSessionConfigOption} method takes the option id and the value, typed by its + * parameter, instead of unpacking the raw request: {@code @ConfigValue String} for a select + * option, {@code @ConfigValue boolean} for a boolean one, {@code @ConfigValue Object} for either. + */ +class TypedConfigValueTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @AcpAgent + static class SelectAgent { + + final Map received = new ConcurrentHashMap<>(); + + @com.agentclientprotocol.sdk.annotation.NewSession + com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse newSession() { + return new com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse("s1", null, null); + } + + @SetSessionConfigOption + SetSessionConfigOptionResponse set(@SessionId String session, @ConfigId String id, @ConfigValue String value) { + received.put(session + "/" + id, value); + return new SetSessionConfigOptionResponse(List.of()); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent + static class BooleanAgent { + + final Map received = new ConcurrentHashMap<>(); + + @com.agentclientprotocol.sdk.annotation.NewSession + com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse newSession() { + return new com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse("s1", null, null); + } + + @SetSessionConfigOption + SetSessionConfigOptionResponse set(@ConfigId String id, @ConfigValue boolean value) { + received.put(id, value); + return new SetSessionConfigOptionResponse(List.of()); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent + static class RawAgent { + + final Map received = new ConcurrentHashMap<>(); + + @com.agentclientprotocol.sdk.annotation.NewSession + com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse newSession() { + return new com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse("s1", null, null); + } + + @SetSessionConfigOption + SetSessionConfigOptionResponse set(@ConfigId String id, @ConfigValue Object value) { + received.put(id, value); + return new SetSessionConfigOptionResponse(List.of()); + } + + @Prompt + PromptResponse prompt() { + return PromptResponse.endTurn(); + } + + } + + @Test + void aSelectValueArrivesAsAString() { + SelectAgent agent = new SelectAgent(); + run(agent, client -> client.setSessionConfigOption(SetSessionConfigOptionRequest.select("s1", "model", "fast")) + .block(TIMEOUT)); + assertThat(agent.received).containsExactly(Map.entry("s1/model", "fast")); + } + + @Test + void aBooleanValueArrivesAsABoolean() { + BooleanAgent agent = new BooleanAgent(); + run(agent, client -> client.setSessionConfigOption(SetSessionConfigOptionRequest.bool("s1", "verbose", true)) + .block(TIMEOUT)); + assertThat(agent.received).containsExactly(Map.entry("verbose", true)); + } + + @Test + void anObjectParameterTakesEitherKind() { + RawAgent agent = new RawAgent(); + run(agent, client -> { + client.setSessionConfigOption(SetSessionConfigOptionRequest.bool("s1", "verbose", false)).block(TIMEOUT); + client.setSessionConfigOption(SetSessionConfigOptionRequest.select("s1", "model", "deep")).block(TIMEOUT); + }); + assertThat(agent.received).containsOnly(Map.entry("verbose", false), Map.entry("model", "deep")); + } + + @Test + void aValueOfTheOtherKindIsInvalidParamsAndTheMethodIsNotCalled() { + BooleanAgent booleans = new BooleanAgent(); + run(booleans, client -> assertThatThrownBy(() -> client + .setSessionConfigOption(SetSessionConfigOptionRequest.select("s1", "verbose", "yes")) + .block(TIMEOUT)).isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INVALID_PARAMS))); + assertThat(booleans.received).isEmpty(); + + SelectAgent selects = new SelectAgent(); + run(selects, client -> assertThatThrownBy(() -> client + .setSessionConfigOption(SetSessionConfigOptionRequest.bool("s1", "model", true)) + .block(TIMEOUT)).isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INVALID_PARAMS))); + assertThat(selects.received).isEmpty(); + } + + private static void run(Object bean, Consumer scenario) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgentSupport.create(bean) + .transport(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + scenario.accept(client); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + } + } + +} diff --git a/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/UnstableAnnotationSupportTest.java b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/UnstableAnnotationSupportTest.java new file mode 100644 index 00000000..e6a941a0 --- /dev/null +++ b/acp-agent-support/src/test/java/com/agentclientprotocol/sdk/agent/support/UnstableAnnotationSupportTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.support; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The annotation model marks the same protocol methods unstable as acp-core does (where + * {@code UnstableApiMarkerTest} derives them from the stable schema): a handler annotation is + * marked {@link UnstableAcpApi} exactly when the agent builder's setter for its method is, and an + * argument resolver that reads an unstable request type is marked. The marker has class + * retention, so it is read from the bytecode. + */ +class UnstableAnnotationSupportTest { + + private static final JavaClasses CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.agentclientprotocol.sdk"); + + @Test + void handlerAnnotationsAreMarkedExactlyWhenTheirBuilderSetterIs() { + JavaClass builder = CLASSES.get(AcpAgent.SyncAgentBuilder.class); + List violations = new ArrayList<>(); + int unstable = 0; + for (Map.Entry, String> entry : AcpAgentSupport + .handlerAnnotations() + .entrySet()) { + String name = entry.getKey().getSimpleName(); + String setter = Character.toLowerCase(name.charAt(0)) + name.substring(1) + "Handler"; + boolean setterMarked = builder.getMethods() + .stream() + .filter(method -> method.getName().equals(setter)) + .anyMatch(method -> method.isAnnotatedWith(UnstableAcpApi.class)); + unstable += setterMarked ? 1 : 0; + if (CLASSES.get(entry.getKey()).isAnnotatedWith(UnstableAcpApi.class) != setterMarked) { + violations.add("@" + name + " (" + entry.getValue() + ")" + (setterMarked ? " is not marked" : " is marked")); + } + } + assertThat(unstable).as("unstable handler setters").isPositive(); + assertThat(violations).isEmpty(); + } + + @Test + void resolversOfUnstableRequestTypesAreMarked() { + List violations = new ArrayList<>(); + for (JavaClass resolver : CLASSES) { + if (!resolver.isAssignableTo(ArgumentResolver.class) || resolver.isInterface() + || resolver.isAnnotatedWith(UnstableAcpApi.class)) { + continue; + } + resolver.getDirectDependenciesFromSelf() + .stream() + .map(dependency -> dependency.getTargetClass()) + .filter(target -> target.isAnnotatedWith(UnstableAcpApi.class)) + .findFirst() + .ifPresent(target -> violations.add(resolver.getSimpleName() + " reads " + target.getSimpleName())); + } + assertThat(violations).isEmpty(); + } + +} diff --git a/acp-agent-support/src/test/resources/archunit.properties b/acp-agent-support/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-agent-support/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-agent-support/src/test/resources/logback-test.xml b/acp-agent-support/src/test/resources/logback-test.xml new file mode 100644 index 00000000..5243e19c --- /dev/null +++ b/acp-agent-support/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/acp-annotations/README.md b/acp-annotations/README.md index 015ceeda..f154f241 100644 --- a/acp-annotations/README.md +++ b/acp-annotations/README.md @@ -6,15 +6,31 @@ Zero-dependency annotation library for declarative ACP agent development. Use wi | Annotation | Target | Purpose | |-----------|--------|---------| -| `@AcpAgent` | Class | Marks a class as an ACP agent | -| `@Initialize` | Method | Handles initialization requests | -| `@NewSession` | Method | Handles new session creation | -| `@LoadSession` | Method | Handles session resume | -| `@Prompt` | Method | Handles prompt requests | -| `@Cancel` | Method | Handles cancel notifications | -| `@SessionState` | Parameter | Injects session-scoped state | -| `@SetSessionMode` | Method | Handles mode change requests | -| `@SetSessionModel` | Method | Handles model change requests | +| `@AcpAgent` | Class | Marks a class as an ACP agent; its `name`, `version`, `title`, `authMethods`, `mcpHttp` and `mcpSse` are advertised in the `initialize` response | +| `@AuthMethod` | (inside `@AcpAgent`) | Declares one advertised authentication method, agent or terminal | +| `@Initialize` | Method | Optionally handles `initialize`; its response is laid over the one derived from the annotations | +| `@Authenticate` | Method | Handles `authenticate` | +| `@Logout` | Method | Handles `logout` | +| `@NewSession` | Method | Handles `session/new` | +| `@LoadSession` | Method | Handles `session/load` (with history replay) | +| `@ResumeSession` | Method | Handles `session/resume` (without history replay) | +| `@ListSessions` | Method | Handles `session/list` | +| `@CloseSession` | Method | Handles `session/close` | +| `@DeleteSession` | Method | Handles `session/delete` | +| `@ForkSession` | Method | Handles `session/fork` (unstable) | +| `@Prompt` | Method | Handles `session/prompt`; `image`, `audio` and `embeddedContext` advertise the prompt content accepted | +| `@SetSessionMode` | Method | Handles `session/set_mode` | +| `@SetSessionConfigOption` | Method | Handles `session/set_config_option` | +| `@ListProviders` | Method | Handles `providers/list` (unstable) | +| `@SetProvider` | Method | Handles `providers/set` (unstable) | +| `@DisableProvider` | Method | Handles `providers/disable` (unstable) | +| `@Cancel` | Method | Handles the `session/cancel` notification | +| `@ExtRequest` | Method | Handles a `_`-prefixed extension request | +| `@ExtNotification` | Method | Handles a `_`-prefixed extension notification | +| `@SessionId` | Parameter | Injects the current session ID | +| `@ConfigId` | Parameter | Injects the id of the config option being set | +| `@ConfigValue` | Parameter | Injects the config option's new value, typed `String`, `boolean` or `Object` | +| `@UnstableAcpApi` | Any | Marks API for protocol elements not in the stable schema | ## Installation @@ -22,7 +38,7 @@ Zero-dependency annotation library for declarative ACP agent development. Use wi com.agentclientprotocol acp-annotations - 0.9.0 + 0.18.0 ``` diff --git a/acp-annotations/pom.xml b/acp-annotations/pom.xml index 9200a78f..404d0e35 100644 --- a/acp-annotations/pom.xml +++ b/acp-annotations/pom.xml @@ -7,7 +7,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-annotations @@ -15,13 +15,45 @@ Zero-dependency annotations for ACP agent development + + + 1.00 + 1.00 + + - + + + org.jspecify + jspecify + true + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + com.tngtech.archunit + archunit-junit5 + test + diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpAgent.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpAgent.java index b3ba303e..b48e5cc0 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpAgent.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpAgent.java @@ -11,31 +11,114 @@ import java.lang.annotation.Target; /** - * Marks a class as an ACP agent that can handle client requests. + * Marks a class as an ACP agent written with annotations: each of its handler methods answers + * one protocol method, such as {@link Initialize}, {@link NewSession} or {@link Prompt}. Use it + * when one method per request reads better than lambdas on the {@code AcpAgent.sync(...)} builder + * of {@code acp-core}. {@code AcpAgentSupport}, in the {@code acp-agent-support} module, finds the + * methods and serves them over a transport; the example below shows a whole agent. * - *

Classes annotated with {@code @AcpAgent} can define handler methods - * using annotations like {@link Prompt}, {@link Initialize}, and {@link NewSession}. + *

This annotation and the builder entry point {@code com.agentclientprotocol.sdk.agent.AcpAgent} + * have the same simple name. A file that uses both imports one and writes the other fully + * qualified. + * + *

Finding the handler methods. {@code AcpAgentSupport} looks for them once, when the + * object is given to its builder, and rejects a class with this annotation neither on it nor on a + * superclass with an {@code IllegalArgumentException}. It finds methods of any visibility declared + * in the class and its superclasses, so handlers can live on an abstract base class, and a + * subclass that a framework generates for a bean (a Spring CGLIB, Quarkus ArC or Micronaut AOP + * proxy, which carries no annotations) is found through the annotated class it extends and + * invoked on the proxy, so its interceptors run. An annotated override replaces the method it + * overrides. Declare one method for each annotation, and one annotation on each method: two + * methods for one annotation, or two handler annotations on one method, are rejected with an + * {@code IllegalArgumentException} naming them. With no {@link Initialize} method the agent + * answers {@code initialize} with the response derived from its annotations (see Capabilities + * below); with no {@link NewSession} method it answers {@code session/new} with a random UUID as + * the session id and no modes or config options, so an agent with a {@link SetSessionMode} or + * {@link SetSessionConfigOption} method must also have a {@link NewSession} method that offers + * them (building the agent fails otherwise). Any other request without a handler method is answered + * "Method not found" ({@code -32601}). + * + *

Capabilities. ACP lets a client call an optional method, such as {@code session/load} + * or {@code logout}, only when the agent advertised it in its {@code initialize} response. The SDK + * derives that response from the class, so no {@link Initialize} method is needed to advertise: + * each handler annotation advertises the capability its method needs ({@link LoadSession} sets + * {@code loadSession}, {@link ListSessions} sets {@code sessionCapabilities.list}, {@link Logout} + * sets {@code auth.logout}, and so on; each annotation names its own), {@link #name()}, + * {@link #version()} and {@link #title()} become {@code agentInfo}, {@link #authMethods()} become + * {@code authMethods}, and {@link #mcpHttp()}, {@link #mcpSse()} and the attributes of + * {@link Prompt} declare the MCP transports and the prompt content the agent accepts, and + * {@link #additionalDirectories()} declares {@code sessionCapabilities.additionalDirectories}. The protocol + * version answered is the client's when the SDK speaks it, otherwise the latest it speaks. An + * {@link Initialize} method, when present, adds to the derived response (see {@link Initialize}). + * + *

Parameters. Each annotation lists the parameters its methods can take, all optional + * and in any order. Besides those, every handler method, extension handlers included, can take + * these, resolved for the connection the request or notification arrived on (with one handler bean + * serving many connections, each call gets its own connection's): + *

    + *
  • {@code NegotiatedCapabilities}: the capabilities the client offered in its + * {@code initialize} request, available from the {@link Initialize} method on, to check what + * the client supports before offering an option or calling it
  • + *
  • {@code AcpSyncAgent} or {@code AcpAsyncAgent}: the connection's agent, to send session + * updates (such as a {@code ConfigOptionUpdate}) and requests to the client outside a prompt + * turn
  • + *
+ * A parameter that a call cannot fill is rejected when the agent is built + * ({@code build()} or {@code buildFactory()} throws an {@code IllegalStateException} naming the + * class, the method and the fix): a type no argument resolver supplies, the request type of + * another method, a {@link SessionId} parameter on a method without a session (extension methods + * included), a prompt context outside a {@link Prompt} method, or a {@link ConfigId} or + * {@link ConfigValue} parameter outside a {@link SetSessionConfigOption} method. A parameter a + * custom argument resolver supplies is the application's to check. On an {@link ExtRequest} or + * {@link ExtNotification} method, every parameter that is not a connection parameter is read + * from the params, so a prompt context there is not rejected when the agent is built; instead + * every {@code @ExtRequest} call is answered with {@code -32602} (invalid params). + * + *

Return values. A request handler returns its method's response, or a {@code Mono}, a + * {@code CompletionStage} or a single-value Reactive Streams {@code Publisher} of it, which the + * runtime waits for on the handler's thread. A request must get a result, so a request handler declared + * {@code void}, or declared to return another type than its response, is rejected when the agent + * is built, and one that returns {@code null} or an empty {@code Mono} is answered with an + * internal error ({@code -32603}). Of the request handlers, only a {@link Prompt} method may be + * {@code void}. A notification handler ({@link Cancel}) returns {@code void}. + * + *

Errors. To answer a request with an error, throw an {@code AcpProtocolException} (from + * {@code acp-core}) with a code from {@code AcpErrorCodes}: the client receives its code, message + * and data, so keep secrets out of what you put in it. A + * {@code java.util.concurrent.CancellationException} is answered {@code -32800} (request + * cancelled) with its own message. Any other exception is answered with an internal error + * ({@code -32603}) whose message is only "Internal error"; the exception itself is logged at the + * agent. A {@link Prompt} method that fails after {@code session/cancel} is answered stop reason + * {@code cancelled} instead (see {@link Prompt}). An exception from a notification handler is + * logged and dropped. + * + *

Threads and state. One instance of the class serves every request: from every session, + * and, with {@code AcpAgentSupport.Builder.buildFactory()}, from every connection. Its handler + * methods run on the SDK's handler threads, so they may block, but they are called concurrently + * and must be thread-safe. Keep per-session state in a concurrent map keyed by session id, and + * remove it in a {@link CloseSession} or {@link DeleteSession} method. * *

Example usage: *

{@code
- * @AcpAgent(name = "support-agent", version = "1.0")
- * public class SupportAgent {
- *
- *     @Initialize
- *     public InitializeResponse init(InitializeRequest req) {
- *         return InitializeResponse.ok();
- *     }
+ * @AcpAgent(name = "greeting-agent", version = "1.0")
+ * public class GreetingAgent {
  *
  *     @Prompt
- *     public PromptResponse handlePrompt(PromptRequest req, SyncPromptContext context) {
+ *     public PromptResponse prompt(PromptRequest request, SyncPromptContext context) {
  *         context.sendMessage("Hello!");
  *         return PromptResponse.endTurn();
  *     }
+ *
+ *     public static void main(String[] args) {
+ *         AcpAgentSupport.create(GreetingAgent.class)
+ *             .transport(new StdioAcpAgentTransport())
+ *             .build()
+ *             .run();
+ *     }
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see Prompt * @see Initialize * @see NewSession @@ -46,15 +129,61 @@ public @interface AcpAgent { /** - * The name of the agent. If not specified, the class name will be used. + * The agent's name, sent to the client as {@code agentInfo.name} in the {@code initialize} + * response. Empty (the default) sends the class's simple name. * @return the agent name */ String name() default ""; /** - * The version of the agent. + * The agent's version, sent as {@code agentInfo.version}. Empty (the default) sends the + * {@code Implementation-Version} of the class's jar manifest, or {@code "unknown"} when it + * has none. * @return the agent version */ String version() default ""; + /** + * A human-readable title for the agent, sent as {@code agentInfo.title}. Empty (the + * default) sends none. + * @return the agent title + */ + String title() default ""; + + /** + * The authentication methods the agent advertises in its {@code initialize} response + * ({@code authMethods}). An agent that declares an {@link AuthMethod.Type#AGENT} method + * must have an {@link Authenticate} handler. Terminal methods are advertised only to a + * client that announced {@code clientCapabilities.auth.terminal}. + * @return the authentication methods; none by default + */ + AuthMethod[] authMethods() default {}; + + /** + * Whether the agent connects to MCP servers over HTTP, advertised as + * {@code agentCapabilities.mcpCapabilities.http}: a client then may pass HTTP MCP servers + * in {@code session/new}, {@code session/load} and {@code session/resume}. + * @return false by default + */ + boolean mcpHttp() default false; + + /** + * Whether the agent connects to MCP servers over SSE, advertised as + * {@code agentCapabilities.mcpCapabilities.sse}. + * @return false by default + */ + boolean mcpSse() default false; + + /** + * Whether the agent accepts additional workspace directories, advertised as + * {@code agentCapabilities.sessionCapabilities.additionalDirectories}: a client then may send + * {@code additionalDirectories} in {@code session/new}, {@code session/load}, + * {@code session/resume} and {@code session/fork}, and the SDK's client refuses to send them + * to an agent that does not advertise it. Declare it only when the agent's handlers use the + * directories they receive: ACP forbids an agent to drop them silently. The agent needs a + * {@link NewSession} method to receive them; building one without it fails. + * @return false by default + */ + boolean additionalDirectories() default false; + } diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpExceptionHandler.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpExceptionHandler.java deleted file mode 100644 index f7c6dec9..00000000 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AcpExceptionHandler.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.annotation; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a method as an exception handler for ACP agent operations. - * - *

The annotated method is called when an exception occurs during - * handler method invocation. This allows for centralized error handling - * and conversion of exceptions to appropriate responses. - * - *

The method can have the following parameters (all optional): - *

    - *
  • The exception type to handle (required as first parameter)
  • - *
  • {@code @SessionId String} - the session ID where the error occurred
  • - *
  • {@code AcpInvocationContext} - the invocation context
  • - *
- * - *

The method should return one of: - *

    - *
  • A response object appropriate for the failed method
  • - *
  • {@code void} - to let the exception propagate
  • - *
  • {@code null} - to let the exception propagate
  • - *
- * - *

Exception handler methods are matched by exception type. More specific - * exception types take precedence over general ones. - * - *

Example usage: - *

{@code
- * @AcpExceptionHandler
- * public PromptResponse handleIllegalArgument(IllegalArgumentException ex) {
- *     return PromptResponse.text("Invalid input: " + ex.getMessage());
- * }
- *
- * @AcpExceptionHandler
- * public PromptResponse handleGeneral(Exception ex) {
- *     log.error("Unexpected error", ex);
- *     return PromptResponse.text("An error occurred");
- * }
- * }
- * - * @author Mark Pollack - * @since 1.0.0 - * @see AcpAgent - */ -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface AcpExceptionHandler { - - /** - * Exception types handled by this method. - *

If empty, the method parameter types are used to determine - * which exceptions are handled. - * @return array of exception classes to handle - */ - Class[] value() default {}; - -} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AuthMethod.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AuthMethod.java new file mode 100644 index 00000000..100849c1 --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/AuthMethod.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * One authentication method an agent advertises in its {@code initialize} response + * ({@code authMethods}), declared in {@link AcpAgent#authMethods()}. + * + *

An {@link Type#AGENT} method is one the client passes to {@code authenticate}, so an + * agent that declares one must have an {@link Authenticate} handler. A {@link Type#TERMINAL} + * method is one the client runs itself, by starting the agent program again in an + * interactive terminal with {@link #args()} and {@link #env()} added; the client never passes + * it to {@code authenticate}, and it is advertised only to a client that announced + * {@code clientCapabilities.auth.terminal}. + * + *

{@code
+ * @AcpAgent(name = "my-agent", version = "1.0", authMethods = {
+ *     @AuthMethod(id = "api-key", name = "API key", description = "Reads MY_API_KEY"),
+ *     @AuthMethod(id = "login", name = "Log in", type = AuthMethod.Type.TERMINAL, args = "--login") })
+ * class MyAgent {
+ *
+ *     @Authenticate
+ *     AuthenticateResponse authenticate(AuthenticateRequest request) { ... }
+ *
+ * }
+ * }
+ * + * @author Mark Pollack + * @since 0.80.0 + * @see AcpAgent#authMethods() + * @see Authenticate + */ +@Target({}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface AuthMethod { + + /** + * The method id, which the client passes to {@code authenticate}. + * @return the id, unique among the agent's methods + */ + String id(); + + /** + * The human-readable name a client shows. + * @return the name + */ + String name(); + + /** + * An optional description; empty for none. + * @return the description + */ + String description() default ""; + + /** + * Who runs the authentication. + * @return {@link Type#AGENT} by default + */ + Type type() default Type.AGENT; + + /** + * For a {@link Type#TERMINAL} method, the arguments the client adds to the agent program. + * @return the extra arguments + */ + String[] args() default {}; + + /** + * For a {@link Type#TERMINAL} method, the environment variables the client sets for the + * agent program, each written {@code NAME=value}. + * @return the extra environment variables + */ + String[] env() default {}; + + /** + * Who runs an authentication method. + */ + enum Type { + + /** The agent authenticates, in its {@link Authenticate} handler. */ + AGENT, + + /** The client runs the agent program in an interactive terminal. */ + TERMINAL + + } + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Authenticate.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Authenticate.java new file mode 100644 index 00000000..09e73acd --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Authenticate.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the {@link AcpAgent} method that answers {@code authenticate}: the client logs in with one + * of the authentication methods the agent listed ({@code authMethods}) in its {@code initialize} + * response, and the method checks the login. Declare those methods on the class, in + * {@link AcpAgent#authMethods()}; they are advertised without an {@link Initialize} method, and an + * agent that declares an agent-type method without an {@code @Authenticate} method fails to build. + * Declare one when the user must log in before the agent can work. Without an {@code @Authenticate} + * method the agent answers {@code authenticate} with "Method not found" ({@code -32601}). + * + *

The SDK does not track whether a client has logged in. A handler method that needs a login, + * such as a {@link NewSession} method, checks for one itself and, when there is none, throws an + * {@code AcpProtocolException} with {@code AcpErrorCodes.AUTHENTICATION_REQUIRED} + * ({@code -32000}). This method throws the same exception to reject a failed login. + * + *

The method can take an {@code AuthenticateRequest}, which carries the chosen + * {@code methodId}, and the connection parameters (see {@link AcpAgent}). It returns an + * {@code AuthenticateResponse}, or a {@code Mono} of one. + * + *

Example usage: + *

{@code
+ * // on the class: @AcpAgent(authMethods = @AuthMethod(id = "api-key", name = "API key"))
+ * @Authenticate
+ * public AuthenticateResponse authenticate(AuthenticateRequest request) {
+ *     if (System.getenv("EXAMPLE_API_KEY") == null) {
+ *         throw new AcpProtocolException(AcpErrorCodes.AUTHENTICATION_REQUIRED,
+ *                 "Set EXAMPLE_API_KEY and log in again");
+ *     }
+ *     return new AuthenticateResponse();
+ * }
+ * }
+ * + * @author Mark Pollack + * @since 0.80.0 + * @see AcpAgent + * @see Logout + * @see AuthMethod + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Authenticate { + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Cancel.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Cancel.java index 72dab445..6a0b419e 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Cancel.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Cancel.java @@ -11,36 +11,59 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for cancellation notifications. + * Marks the {@link AcpAgent} method that receives {@code session/cancel}: the client asks the + * agent to stop the session's running prompt turn, and the method tells the {@link Prompt} method + * to stop. {@code session/cancel} is a notification, so the method returns {@code void} and + * nothing is sent back. The prompt method does not need it to learn of the cancel: its context + * says so ({@code SyncPromptContext.isCancelled()} or {@code onCancel(Runnable)}, + * {@code PromptContext.whenCancelled()}), for {@code session/cancel} and for a + * {@code $/cancel_request} naming the prompt. Declare a {@code @Cancel} method for work outside + * the prompt method, such as stopping a job the prompt started elsewhere. * - *

The annotated method handles the {@code session/cancel} JSON-RPC notification, - * which is sent when a client wants to cancel an ongoing operation. + *

The cancel does not end the turn. The prompt method should stop, send any last updates, and + * return; the SDK answers the prompt {@code cancelled} once {@code session/cancel} was received, + * whatever the method returns or throws. Until it returns, a new prompt on the session is + * answered with {@code -32600} (Invalid request). If it has not returned when the cancel grace period ends + * (60 seconds unless set with {@code AcpAgentSupport.Builder.cancelGracePeriod}), the SDK answers + * the prompt {@code cancelled} itself and interrupts the prompt method's thread. The grace period + * is SDK policy; ACP sets no time limit. * - *

Note: This is a notification handler, not a request handler. The method - * should not return a response - it should perform cleanup and return void. + *

This method runs on a handler thread while the prompt method is still running on another, so + * state the two share is thread-safe and keyed by session id, as in the example. An exception it + * throws is logged and dropped. {@code $/cancel_request}, which cancels a single request, never + * reaches this method: the SDK handles it. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code CancelNotification} - the cancel notification containing the sessionId
  • - *
  • {@code @SessionId String} - the session ID being cancelled
  • - *
+ *

The method can take a {@code CancelNotification}, a {@link SessionId @SessionId} + * {@code String} and the connection parameters (see {@link AcpAgent}). * - *

The method should return: - *

    - *
  • {@code void} - notifications do not have responses
  • - *
- * - *

Example usage: + *

Example usage, stopping a build the prompt started on a build server: *

{@code
+ * private final Map runningBuilds = new ConcurrentHashMap<>();
+ *
  * @Cancel
- * public void onCancel(@SessionId String sessionId) {
- *     // Clean up resources for the cancelled session
- *     runningTasks.get(sessionId).cancel();
+ * public void cancel(@SessionId String sessionId) {
+ *     String buildId = runningBuilds.remove(sessionId);
+ *     if (buildId != null) {
+ *         buildServer.abort(buildId);
+ *     }
+ * }
+ * }
+ * + *

A prompt method that only needs to stop its own loop checks its context instead: + *

{@code
+ * @Prompt
+ * public PromptResponse prompt(SyncPromptContext context) {
+ *     for (int step = 1; step <= 3; step++) {
+ *         if (context.isCancelled()) {
+ *             return PromptResponse.cancelled();
+ *         }
+ *         context.sendMessage("Step " + step + " done. ");
+ *     }
+ *     return PromptResponse.endTurn();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see Prompt */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/CloseSession.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/CloseSession.java index c7aa2c52..2b40013c 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/CloseSession.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/CloseSession.java @@ -11,35 +11,40 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for closing ACP sessions. + * Marks the {@link AcpAgent} method that answers {@code session/close}: the client is done with an + * active session, and the method stops the session's work and frees what it holds. Declare one when + * the agent keeps per-session state or runs work that should end with the session. Without a + * {@code @CloseSession} method the agent answers {@code session/close} with "Method not found" + * ({@code -32601}). * - *

The annotated method handles the {@code session/close} JSON-RPC method, - * which is called when a client wants to close an active session and cancel - * any in-flight work. + *

ACP lets a client call {@code session/close} only when the agent advertised + * {@code sessionCapabilities.close} in its {@code initialize} response (the agent advertises it + * for you when it has this method), and requires the agent to cancel any running work in the + * session as if {@code session/cancel} had arrived. The SDK does that part before it calls this + * method: it calls the {@link Cancel} method and cancels a running {@link Prompt} method's turn, + * then calls this method once that prompt has answered {@code cancelled}. To remove a stored + * session for good, the client uses + * {@link DeleteSession}. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code CloseSessionRequest} - the close session request containing the sessionId
  • - *
  • {@code @SessionId String} - the session ID being closed
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code CloseSessionResponse} - the close session response
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code CloseSessionRequest}, a {@link SessionId @SessionId} + * {@code String} and the connection parameters (see {@link AcpAgent}). It must return a + * {@code CloseSessionResponse} (not {@code void}), or a {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map stopFlags = new ConcurrentHashMap<>();
+ *
  * @CloseSession
- * public CloseSessionResponse close(CloseSessionRequest req) {
- *     // Clean up session resources
+ * public CloseSessionResponse close(@SessionId String sessionId) {
+ *     AtomicBoolean stop = stopFlags.remove(sessionId);
+ *     if (stop != null) {
+ *         stop.set(true);  // the session's prompt method checks this flag
+ *     }
  *     return new CloseSessionResponse();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see Cancel */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigId.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigId.java new file mode 100644 index 00000000..616fd502 --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigId.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a {@code String} parameter of a {@link SetSessionConfigOption} method to receive the + * id of the config option the client is setting ({@code configId}). + * + * @author Mark Pollack + * @since 0.80.0 + * @see SetSessionConfigOption + * @see ConfigValue + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ConfigId { + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigValue.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigValue.java new file mode 100644 index 00000000..f6bd2edf --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ConfigValue.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a parameter of a {@link SetSessionConfigOption} method to receive the value the client + * is setting, typed by the parameter: + *
    + *
  • {@code String} - the value id of a {@code select} option
  • + *
  • {@code boolean} or {@code Boolean} - the value of a {@code boolean} option
  • + *
  • {@code Object} - the value as it arrived, whichever kind of option it is
  • + *
+ * A value of the other kind (a boolean for a {@code String} parameter, or a select value for a + * {@code boolean} one) is answered with error {@code -32602} (invalid params) without calling + * the method. Whether the id and value name an option and a value the session offers is the + * method's to check. + * + * @author Mark Pollack + * @since 0.80.0 + * @see SetSessionConfigOption + * @see ConfigId + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ConfigValue { + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DeleteSession.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DeleteSession.java index 6e0c84e8..4252ec56 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DeleteSession.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DeleteSession.java @@ -11,39 +11,33 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for deleting ACP sessions. + * Marks the {@link AcpAgent} method that answers {@code session/delete}: the client removes a + * stored session for good, and the method deletes it so that {@code session/list} no longer + * returns it. Declare one, together with a {@link ListSessions} method, when users can clean up + * their session history. Without a {@code @DeleteSession} method the agent answers + * {@code session/delete} with "Method not found" ({@code -32601}). * - *

The annotated method handles the {@code session/delete} JSON-RPC method, - * which is called when a client wants to permanently delete a stored session so - * it no longer appears in {@code session/list}. + *

ACP lets a client call {@code session/delete} only when the agent advertised + * {@code sessionCapabilities.delete} in its {@code initialize} response; the agent advertises it + * for you when it has this method. To end an active session without deleting it, the client uses + * {@link CloseSession}. * - *

Only available if the agent advertises the {@code sessionCapabilities.delete} - * capability. - * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code DeleteSessionRequest} - the delete session request containing the sessionId
  • - *
  • {@code @SessionId String} - the session ID being deleted
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code DeleteSessionResponse} - the delete session response
  • - *
  • {@code Mono} - for async handling
  • - *
  • {@code void} - an empty response is sent automatically
  • - *
+ *

The method can take a {@code DeleteSessionRequest}, a {@link SessionId @SessionId} + * {@code String} and the connection parameters (see {@link AcpAgent}). It must return a + * {@code DeleteSessionResponse} (not {@code void}), or a {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map> history = new ConcurrentHashMap<>();
+ *
  * @DeleteSession
- * public DeleteSessionResponse delete(DeleteSessionRequest req) {
- *     // Permanently remove the stored session
+ * public DeleteSessionResponse delete(@SessionId String sessionId) {
+ *     history.remove(sessionId);
  *     return new DeleteSessionResponse();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see CloseSession */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DisableProvider.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DisableProvider.java index 1e9e9b69..2001ce83 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DisableProvider.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/DisableProvider.java @@ -15,7 +15,7 @@ * *

The annotated method disables a provider by id. It may take a {@code DisableProviderRequest} * parameter and should return a {@code DisableProviderResponse} (or - * {@code Mono}, or {@code void} for an empty response). + * {@code Mono}). * * @author Mark Pollack * @since 0.13.0 diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtNotification.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtNotification.java new file mode 100644 index 00000000..c72c4003 --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtNotification.java @@ -0,0 +1,62 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the {@link AcpAgent} method that receives a custom extension notification: the client sends + * a notification whose method is the name given here, which starts with {@code _}, and the method + * acts on it. A notification gets no answer, so the method returns {@code void}: building the agent + * rejects one that returns a value, with an {@code IllegalStateException}. Declare one for each one-way message outside ACP that the agent and its clients agree + * on, such as the client saying a file was opened. Without an {@code @ExtNotification} method for a + * name, the agent ignores that notification, as ACP asks. Clients send it with + * {@code sendExtNotification} on {@code AcpAsyncClient} or {@code AcpSyncClient}. + * + *

It differs from {@link ExtRequest} only in having no answer. The name rules are the same: + * building the agent rejects a name that does not start with an underscore, and two methods for one + * name, with an {@code IllegalArgumentException}. An exception the method throws, or params that + * cannot be read as its params type, is logged and the notification dropped; the client is not + * told. + * + *

The method can take one params parameter, which receives the notification's params read as + * that parameter's type: a record, a {@code Map} for the raw object, or any other + * type the JSON mapper can read; omitted params arrive as an empty object. It can also take the + * connection parameters (see {@link AcpAgent}); a {@link SessionId}, {@link ConfigId} or + * {@link ConfigValue} parameter is rejected when the agent is built. + * + *

Example usage: + *

{@code
+ * record FileOpened(String path) {}
+ *
+ * private final Set recentFiles = ConcurrentHashMap.newKeySet();
+ *
+ * @ExtNotification("_example.com/file_opened")
+ * public void fileOpened(FileOpened notification) {
+ *     recentFiles.add(notification.path());
+ * }
+ * }
+ * + * @author Mark Pollack + * @see ExtRequest + * @see AcpAgent + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ExtNotification { + + /** + * The extension method's name, which must start with {@code _}, such as + * {@code "_example.com/file_opened"}. + * @return the method name + */ + String value(); + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtRequest.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtRequest.java new file mode 100644 index 00000000..99d8a2c8 --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ExtRequest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks the {@link AcpAgent} method that answers a custom extension request: the client sends a + * request whose method is the name given here, which starts with {@code _}, and the method returns + * the result. Declare one for each feature outside ACP that the agent and its clients agree on, + * such as a client asking which files the agent has open. Without an {@code @ExtRequest} method for + * a name, the agent answers that request with "Method not found" ({@code -32601}). Clients send it + * with {@code sendExtRequest} on {@code AcpAsyncClient} or {@code AcpSyncClient}. + * + *

ACP reserves names that start with an underscore for extensions (ACP v1, Extensibility): + * building the agent rejects any other name with an {@code IllegalArgumentException}, and rejects + * two methods for one name. The SDK checks no capability for it and does not wait for + * {@code initialize}: a client may call it at any time. ACP asks agents to advertise their + * extensions in the {@code _meta} of their capabilities, which an {@link Initialize} method can + * add. The rules every extension method follows are on {@code ExtensionMethods} in + * {@code acp-core}. + * + *

The method can take one params parameter, which receives the request's params read as that + * parameter's type: a record, a {@code Map} for the raw object, or any other type + * the JSON mapper can read. Omitted params arrive as an empty object, and params that cannot be + * read as the type are answered "Invalid params" ({@code -32602}). It can also take the connection + * parameters (see {@link AcpAgent}); a {@link SessionId}, {@link ConfigId} or {@link ConfigValue} + * parameter is rejected when the agent is built, since an extension method has no session. It + * returns the result, any value the JSON mapper can write, or a {@code Mono} of one. A {@code void} + * method is rejected when the agent is built, and a {@code null} result or an empty {@code Mono} is + * answered with an internal error ({@code -32603}): return an empty map when there is nothing to + * return. + * + *

Example usage: + *

{@code
+ * record BufferQuery(String language) {}
+ * record Buffers(List paths) {}
+ *
+ * private final Map openBuffers = new ConcurrentHashMap<>(); // path to language
+ *
+ * @ExtRequest("_example.com/workspace/buffers")
+ * public Buffers buffers(BufferQuery query) {
+ *     List paths = openBuffers.entrySet().stream()
+ *         .filter(entry -> entry.getValue().equals(query.language()))
+ *         .map(Map.Entry::getKey)
+ *         .toList();
+ *     return new Buffers(paths);
+ * }
+ * }
+ * + * @author Mark Pollack + * @see ExtNotification + * @see AcpAgent + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ExtRequest { + + /** + * The extension method's name, which must start with {@code _}, such as + * {@code "_example.com/workspace/buffers"}. + * @return the method name + */ + String value(); + +} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Initialize.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Initialize.java index 2af3e7ca..ce687159 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Initialize.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Initialize.java @@ -11,32 +11,45 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for ACP initialization requests. + * Marks the {@link AcpAgent} method that answers {@code initialize}: the first request on a + * connection, in which the client sends its protocol version, capabilities and name, and the + * method answers with the agent's. It is optional: without one, the agent answers with the + * response derived from its class (see {@link AcpAgent}): the capabilities its handler + * annotations imply, the {@link AcpAgent#authMethods()}, and {@code agentInfo} from + * {@link AcpAgent#name()} and {@link AcpAgent#version()}, in the protocol version negotiated with + * the client. Declare one to look at the client's request, or to advertise what the annotations + * cannot express, such as a capability's {@code _meta}. * - *

The annotated method handles the {@code initialize} JSON-RPC method, - * which is called when a client first connects to negotiate capabilities. - * - *

The method can have the following parameters (all optional): + *

Merge rule. The derived response is the base, and the response this method returns + * is laid over it: *

    - *
  • {@code InitializeRequest} - the initialization request
  • + *
  • a capability is advertised when either side advertises it, so returning + * {@code InitializeResponse.ok()} keeps every derived capability, and a capability a handler + * implies cannot be withdrawn here (remove the handler instead)
  • + *
  • {@code authMethods} are the derived methods followed by the returned ones, a returned + * method replacing a derived method with the same id
  • + *
  • {@code protocolVersion}, and {@code agentInfo} and {@code _meta} when not null, are the + * returned ones
  • *
* - *

The method should return one of: - *

    - *
  • {@code InitializeResponse} - the initialization response
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The client's capabilities are recorded before this method runs, so a + * {@code NegotiatedCapabilities} parameter already holds them here, and every later handler method + * on the connection gets the same ones. + * + *

The method can take an {@code InitializeRequest} and the connection parameters (see + * {@link AcpAgent}). It returns an {@code InitializeResponse}, or a {@code Mono} of one. * - *

Example usage: + *

Example usage, adding a capability no annotation implies to the derived ones: *

{@code
  * @Initialize
- * public InitializeResponse init(InitializeRequest req) {
- *     return InitializeResponse.ok();
+ * public InitializeResponse initialize(InitializeRequest request) {
+ *     return InitializeResponse.ok(AgentCapabilities.builder()
+ *         .sessionCapabilities(new SessionCapabilities(null, null, null, null, Map.of(), null))
+ *         .build());
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent */ @Target(ElementType.METHOD) diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ListSessions.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ListSessions.java index de5ccd6d..22e4fd11 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ListSessions.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ListSessions.java @@ -11,34 +11,37 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for listing ACP sessions. + * Marks the {@link AcpAgent} method that answers {@code session/list}: the client asks which + * sessions the agent knows, optionally only those of one working directory, and the method returns + * them, a page at a time. Clients use it to show session history and let the user switch sessions; + * listing restores nothing ({@link LoadSession} and {@link ResumeSession} do). Without a + * {@code @ListSessions} method the agent answers {@code session/list} with "Method not found" + * ({@code -32601}). * - *

The annotated method handles the {@code session/list} JSON-RPC method, - * which is called when a client wants to list available sessions, optionally - * filtered by working directory. + *

ACP lets a client call {@code session/list} only when the agent advertised + * {@code sessionCapabilities.list} in its {@code initialize} response; the agent advertises it for + * you when it has this method. Pages are linked by a cursor the agent chooses: return it as {@code + * nextCursor} while more sessions remain, and the client sends it back as {@code cursor}. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code ListSessionsRequest} - the list sessions request containing optional cwd filter and cursor
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code ListSessionsResponse} - the response containing a list of session info objects
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code ListSessionsRequest} (the {@code cwd} filter and the + * {@code cursor}, each of which may be null) and the connection parameters (see + * {@link AcpAgent}). It returns a {@code ListSessionsResponse}, or a {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map workingDirectories = new ConcurrentHashMap<>();
+ *
  * @ListSessions
- * public ListSessionsResponse list(ListSessionsRequest req) {
- *     return new ListSessionsResponse(List.of(
- *         new SessionInfo("session-1", "/workspace")));
+ * public ListSessionsResponse list(ListSessionsRequest request) {
+ *     List sessions = workingDirectories.entrySet().stream()
+ *         .filter(session -> request.cwd() == null || request.cwd().equals(session.getValue()))
+ *         .map(session -> new SessionInfo(session.getKey(), session.getValue()))
+ *         .toList();
+ *     return new ListSessionsResponse(sessions);
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent */ @Target(ElementType.METHOD) diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/LoadSession.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/LoadSession.java index 5a667783..37dc69f0 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/LoadSession.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/LoadSession.java @@ -11,34 +11,43 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for loading existing ACP sessions. + * Marks the {@link AcpAgent} method that answers {@code session/load}: the client reopens a session + * the agent stored earlier, and the method restores it and replays its conversation to the client. + * Declare one when the agent keeps conversations, so that a user can return to one. Without a + * {@code @LoadSession} method the agent answers {@code session/load} with "Method not found" + * ({@code -32601}). * - *

The annotated method handles the {@code session/load} JSON-RPC method, - * which is called when a client wants to resume an existing session. + *

ACP lets a client call {@code session/load} only when the agent advertised + * {@code loadSession} in its {@code initialize} response; the agent advertises it for you when it + * has this method. It also requires the agent to send the whole conversation, as session updates + * like those of a prompt turn, before the method returns: take an {@code AcpSyncAgent} parameter + * and call its {@code sendSessionUpdate}. To reopen a session without the replay, the client uses + * {@link ResumeSession}. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code LoadSessionRequest} - the load session request containing sessionId, cwd, and mcpServers
  • - *
  • {@code @SessionId String} - the session ID being loaded
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code LoadSessionResponse} - the load session response with modes and models
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code LoadSessionRequest} (session id, {@code cwd} and + * {@code mcpServers}), a {@link SessionId @SessionId} {@code String} and the connection parameters + * (see {@link AcpAgent}). It returns a {@code LoadSessionResponse} with the session's modes and + * config options, or a {@code Mono} of one. * *

Example usage: *

{@code
+ * record Turn(boolean fromUser, String text) {
+ * }
+ *
+ * private final Map> history = new ConcurrentHashMap<>();
+ *
  * @LoadSession
- * public LoadSessionResponse load(LoadSessionRequest req) {
- *     // Restore session state
- *     return new LoadSessionResponse(modes, models);
+ * public LoadSessionResponse load(LoadSessionRequest request, AcpSyncAgent agent) {
+ *     for (Turn turn : history.getOrDefault(request.sessionId(), List.of())) {
+ *         TextContent text = new TextContent(turn.text());
+ *         agent.sendSessionUpdate(request.sessionId(), turn.fromUser()
+ *                 ? new UserMessageChunk(text) : new AgentMessageChunk(text));
+ *     }
+ *     return new LoadSessionResponse(null, null);  // no modes, no config options
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see NewSession */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Logout.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Logout.java index 7c3cef42..e7097c5a 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Logout.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Logout.java @@ -11,36 +11,33 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for logout requests. + * Marks the {@link AcpAgent} method that answers {@code logout}: the client asks the agent to end + * the current login, and the method clears the credentials the agent stored for it. Declare one, + * together with an {@link Authenticate} method, when users can log out from the client. Without a + * {@code @Logout} method the agent answers {@code logout} with "Method not found" + * ({@code -32601}). * - *

The annotated method handles the {@code logout} JSON-RPC method, which is - * called when a client wants to clear stored credentials and terminate the - * current authenticated session. + *

ACP lets a client call {@code logout} only when the agent advertised {@code auth.logout} in + * its {@code initialize} response; the agent advertises it for you when it has this method. * - *

The method can have the following parameter (optional): - *

    - *
  • {@code LogoutRequest} - the logout request
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code LogoutResponse} - the logout response
  • - *
  • {@code Mono} - for async handling
  • - *
  • {@code void} - an empty response is sent automatically
  • - *
+ *

The method can take a {@code LogoutRequest} and the connection parameters (see + * {@link AcpAgent}). It must return a {@code LogoutResponse} (not {@code void}), or a + * {@code Mono} of one. * *

Example usage: *

{@code
+ * private final AtomicReference token = new AtomicReference<>();
+ *
  * @Logout
- * public LogoutResponse logout(LogoutRequest req) {
- *     // Clear stored credentials
+ * public LogoutResponse logout(LogoutRequest request) {
+ *     token.set(null);
  *     return new LogoutResponse();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent + * @see Authenticate */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/NewSession.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/NewSession.java index a0081b0d..4c87d9ff 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/NewSession.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/NewSession.java @@ -11,32 +11,35 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for new session requests. + * Marks the {@link AcpAgent} method that answers {@code session/new}: the client starts a new ACP + * session (a conversation) in a working directory, with the MCP servers the agent should use, and + * the method returns the new session's id. Declare one to choose the ids, set up per-session state, + * or offer modes and config options for the session. Without a {@code @NewSession} method the agent + * answers with a random UUID as the session id and no modes or config options. * - *

The annotated method handles the {@code session/new} JSON-RPC method, - * which is called when a client wants to create a new conversation session. + *

The SDK keeps no list of sessions: later requests reach their handler methods with whatever + * session id the client sends. Keep the sessions you create, for example in a concurrent map, and + * answer an unknown id with an {@code AcpProtocolException}, such as one with + * {@code AcpErrorCodes.RESOURCE_NOT_FOUND}. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code NewSessionRequest} - the new session request
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code NewSessionResponse} - the new session response with session ID
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code NewSessionRequest}, which carries {@code cwd} and + * {@code mcpServers}, and the connection parameters (see {@link AcpAgent}). It cannot take a + * {@link SessionId} parameter: the session does not exist yet. It returns a + * {@code NewSessionResponse}, or a {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map workingDirectories = new ConcurrentHashMap<>();
+ *
  * @NewSession
- * public NewSessionResponse newSession(NewSessionRequest req) {
- *     return new NewSessionResponse(UUID.randomUUID().toString(), null, null);
+ * public NewSessionResponse newSession(NewSessionRequest request) {
+ *     String sessionId = UUID.randomUUID().toString();
+ *     workingDirectories.put(sessionId, request.cwd());
+ *     return new NewSessionResponse(sessionId, null, null);  // no modes, no config options
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent */ @Target(ElementType.METHOD) diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Prompt.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Prompt.java index 6b3502d2..b2e5ffa2 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Prompt.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/Prompt.java @@ -11,49 +11,98 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for prompt requests. + * Marks the {@link AcpAgent} method that answers {@code session/prompt}: the client sends the + * user's message, and the method does the agent's work for that prompt turn. While it runs, it + * streams progress to the client (message chunks, thoughts, tool calls) through its prompt context, + * and it can read files, run commands and ask permission on the client. It returns how the turn + * ended. Every agent has one: building an agent without a {@code @Prompt} method fails with an + * {@code IllegalStateException}. * - *

The annotated method handles the {@code session/prompt} JSON-RPC method, - * which is the main entry point for processing user messages. - * - *

The method can have the following parameters (all optional, in any order): + *

The method can take these parameters, all optional and in any order: *

    - *
  • {@code PromptRequest} - the prompt request containing user message
  • - *
  • {@code SyncPromptContext} - context for sync handlers with convenience methods
  • - *
  • {@code PromptContext} - context for async handlers returning Mono
  • - *
  • {@code NegotiatedCapabilities} - the negotiated client capabilities
  • - *
  • Custom types annotated with {@code @SessionState} - session-scoped state
  • + *
  • {@code PromptRequest}: the session id and the prompt's content blocks
  • + *
  • {@code SyncPromptContext}: the turn's context, with blocking calls such as + * {@code sendMessage}, {@code readFile} and {@code askPermission}
  • + *
  • {@code PromptContext}: the same turn's context with calls that return a {@code Mono}, for + * code that composes Reactor operators
  • + *
  • {@link SessionId @SessionId} {@code String}: the session id
  • + *
  • the connection parameters every handler method can take (see {@link AcpAgent})
  • *
* - *

The method should return one of: + *

It returns one of: *

    - *
  • {@code PromptResponse} - the prompt response
  • - *
  • {@code String} - converted to PromptResponse.text()
  • - *
  • {@code void} - converted to PromptResponse.endTurn()
  • - *
  • {@code Mono} - for async handling
  • + *
  • {@code PromptResponse}: the turn's stop reason, usually {@code PromptResponse.endTurn()}; + * after {@code session/cancel} the SDK sends stop reason {@code cancelled} whatever it is
  • + *
  • {@code void}: the same as {@code PromptResponse.endTurn()}
  • + *
  • {@code Mono}, {@code CompletionStage} or single-value {@code Publisher} of + * {@code PromptResponse} or {@code String}: the runtime waits for it on the handler thread, and + * the value means what returning it directly means; an empty {@code Mono} is answered with an + * internal error ({@code -32603})
  • + *
  • {@code String}: sent to the client as an agent message chunk of the turn, then the turn + * ends like {@code void}; a null or empty string sends nothing
  • *
* + *

Prompts always carry text and resource links. The {@link #image()}, {@link #audio()} and + * {@link #embeddedContext()} attributes declare the other content the method accepts; they are + * advertised as {@code promptCapabilities}, and a client sends only content the agent advertised. + * + *

A session has one prompt turn at a time. Until this method returns, a second prompt on the + * same session is answered with {@code -32600} (Invalid request); prompts on other sessions run at + * the same time, on other threads. + * + *

Cancellation. The context tells the method that its turn was cancelled, by + * {@code session/cancel} for its session or by {@code $/cancel_request} for its request: a sync + * method polls {@code SyncPromptContext.isCancelled()} (or registers {@code onCancel(Runnable)}), + * a method returning {@code Mono} composes {@code PromptContext.whenCancelled()}. After + * {@code session/cancel} the method keeps running until it returns: it should stop, send any last + * updates, and return. Once {@code session/cancel} was received, the SDK answers the prompt + * {@code cancelled}, as ACP requires, whatever the method returns ({@code void}, a string or + * another stop reason; a {@code PromptResponse}'s {@code _meta} is kept) and also when it throws. + * If it has not returned when the cancel grace period ends, the SDK answers {@code cancelled} for + * it and interrupts its thread. After + * {@code $/cancel_request} the SDK has already answered, so the method just stops. + * {@code AcpAgentSupport.Builder} sets the grace period and can also limit how long any turn runs + * ({@code maxPromptDuration}, off by default). + * *

Example usage: *

{@code
  * @Prompt
- * public PromptResponse handlePrompt(PromptRequest req, SyncPromptContext context) {
- *     context.sendMessage("Processing your request...");
- *
- *     // Read files, execute commands, etc.
- *     String content = context.readFile("/path/to/file.txt");
- *
- *     return PromptResponse.text("Here's what I found: " + content);
+ * public PromptResponse prompt(PromptRequest request, SyncPromptContext context) {
+ *     context.sendThought("Reading the README");
+ *     String readme = context.readFile("/workspace/README.md");
+ *     context.sendMessage("The README has " + readme.lines().count() + " lines.");
+ *     return PromptResponse.endTurn();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent - * @see SessionState + * @see Cancel */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Prompt { + /** + * Whether the handler accepts image content blocks, advertised as + * {@code agentCapabilities.promptCapabilities.image}. + * @return false by default + */ + boolean image() default false; + + /** + * Whether the handler accepts audio content blocks, advertised as + * {@code agentCapabilities.promptCapabilities.audio}. + * @return false by default + */ + boolean audio() default false; + + /** + * Whether the handler accepts embedded resources ({@code resource} content blocks), + * advertised as {@code agentCapabilities.promptCapabilities.embeddedContext}. + * @return false by default + */ + boolean embeddedContext() default false; + } diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ResumeSession.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ResumeSession.java index 63a6e3eb..8f547b3f 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ResumeSession.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/ResumeSession.java @@ -11,36 +11,35 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for resuming ACP sessions. + * Marks the {@link AcpAgent} method that answers {@code session/resume}: the client reconnects to a + * session the agent stored earlier, and the method restores it without replaying the conversation. + * Declare one instead of a {@link LoadSession} method when the agent can continue a session but + * cannot send its history. Without a {@code @ResumeSession} method the agent answers + * {@code session/resume} with "Method not found" ({@code -32601}). * - *

The annotated method handles the {@code session/resume} JSON-RPC method, - * which is called when a client wants to reconnect to an existing session - * without replaying conversation history. Unlike {@link LoadSession}, this - * method does not send previous messages via session/update notifications. + *

ACP lets a client call {@code session/resume} only when the agent advertised + * {@code sessionCapabilities.resume} in its {@code initialize} response; the agent advertises it + * for you when it has this method. Unlike {@code session/load}, ACP says the agent must not replay + * the earlier conversation as session updates before it answers, so the method only restores what + * the session needs to continue. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code ResumeSessionRequest} - the resume session request containing sessionId, cwd, and mcpServers
  • - *
  • {@code @SessionId String} - the session ID being resumed
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code ResumeSessionResponse} - the resume session response with modes and models
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code ResumeSessionRequest} (session id, {@code cwd} and + * {@code mcpServers}), a {@link SessionId @SessionId} {@code String} and the connection parameters + * (see {@link AcpAgent}). It returns a {@code ResumeSessionResponse} with the session's modes and + * config options, or a {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map workingDirectories = new ConcurrentHashMap<>();
+ *
  * @ResumeSession
- * public ResumeSessionResponse resume(ResumeSessionRequest req) {
- *     // Reconnect to session without history replay
- *     return new ResumeSessionResponse(modes, models);
+ * public ResumeSessionResponse resume(ResumeSessionRequest request) {
+ *     workingDirectories.put(request.sessionId(), request.cwd());
+ *     return new ResumeSessionResponse(null, null);  // no modes, no config options
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see LoadSession */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionId.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionId.java index 1f4e920b..ad253a64 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionId.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionId.java @@ -11,27 +11,30 @@ import java.lang.annotation.Target; /** - * Marks a String parameter to receive the current session ID. + * Marks a {@code String} parameter of a handler method that receives the session id of the request + * being handled, the same value as the request's {@code sessionId()}. Use it when the method needs + * only the id, or does not take the request at all. * - *

This annotation can be used on a {@code String} parameter in handler - * methods to inject the current session identifier without needing to - * extract it from the request object. + *

It works in the methods of requests that name a session: {@link Prompt}, {@link Cancel}, + * {@link LoadSession}, {@link ResumeSession}, {@link CloseSession}, {@link DeleteSession}, + * {@link SetSessionMode}, {@link SetSessionConfigOption} and {@link ForkSession}. In + * {@link Initialize}, {@link Authenticate}, {@link Logout}, {@link NewSession} and + * {@link ListSessions} methods there is no session id, and building the agent fails with an + * {@code IllegalStateException} naming the method; in {@link ExtRequest} and + * {@link ExtNotification} methods registering the agent fails with an + * {@code IllegalArgumentException}. A parameter that is not a {@code String} is not supplied + * either, and building the agent fails the same way. * *

Example usage: *

{@code
  * @Prompt
- * public PromptResponse handlePrompt(
- *         PromptRequest req,
- *         @SessionId String sessionId,
- *         SyncPromptContext context) {
- *
- *     context.sendMessage("Processing request for session: " + sessionId);
+ * public PromptResponse prompt(@SessionId String sessionId, SyncPromptContext context) {
+ *     context.sendMessage("This is session " + sessionId);
  *     return PromptResponse.endTurn();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent * @see Prompt */ diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionState.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionState.java deleted file mode 100644 index 0a3751f7..00000000 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SessionState.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.annotation; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a parameter as session-scoped state that should be injected - * and persisted across requests within the same session. - * - *

Session state is automatically managed by the runtime: - *

    - *
  • On first access, a new instance is created (if not present)
  • - *
  • The same instance is returned for subsequent requests in the session
  • - *
  • State is cleared when the session ends
  • - *
- * - *

Example usage: - *

{@code
- * public class ConversationHistory {
- *     private final List messages = new ArrayList<>();
- *
- *     public void add(String role, String content) {
- *         messages.add(new Message(role, content));
- *     }
- *
- *     public List getMessages() {
- *         return messages;
- *     }
- * }
- *
- * @Prompt
- * public PromptResponse handlePrompt(
- *         PromptRequest req,
- *         @SessionState ConversationHistory history,
- *         SyncPromptContext context) {
- *
- *     history.add("user", req.prompt().toString());
- *     String response = processWithHistory(history);
- *     history.add("assistant", response);
- *
- *     return PromptResponse.text(response);
- * }
- * }
- * - * @author Mark Pollack - * @since 1.0.0 - * @see AcpAgent - * @see Prompt - */ -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface SessionState { - - /** - * Optional key to identify the state. If not specified, the parameter - * type's fully qualified name is used as the key. - * @return the state key - */ - String value() default ""; - -} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetProvider.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetProvider.java index 837895ed..36a38edf 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetProvider.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetProvider.java @@ -15,7 +15,7 @@ * *

The annotated method configures a provider's routing (protocol, base URL, headers). It may * take a {@code SetProviderRequest} parameter and should return a {@code SetProviderResponse} - * (or {@code Mono}, or {@code void} for an empty response). + * (or {@code Mono}). * * @author Mark Pollack * @since 0.13.0 diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionConfigOption.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionConfigOption.java index b851f3c4..6bcd108f 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionConfigOption.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionConfigOption.java @@ -11,10 +11,50 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for setting session configuration options. + * Marks the {@link AcpAgent} method that answers {@code session/set_config_option}: the client + * changes one of the config options the agent offered for a session, such as the model, and the + * method applies the change and returns all of the session's options with their current values. + * Declare one when the agent returns config options from its {@link NewSession}, + * {@link LoadSession} or {@link ResumeSession} method; the agent must then also have a {@link + * NewSession} method, since the default {@code session/new} offers none (building the agent fails + * otherwise); a model picker is a select option with category {@code "model"}. Without a {@code + * @SetSessionConfigOption} method the agent answers {@code session/set_config_option} with "Method + * not found" ({@code -32601}). * - *

The annotated method handles the {@code session/set_config_option} - * JSON-RPC method, which changes a configuration value for the session. + *

The request names the option ({@code configId}) and its new {@code value}: a + * {@code String} for a select option, a {@code Boolean} for a boolean one. A {@link ConfigId} + * {@code String} parameter receives the id, and a {@link ConfigValue} parameter the value, typed + * by the parameter ({@code String}, {@code boolean} or {@code Object}); a value of the other kind + * is answered with {@code -32602} without calling the method. The SDK does not check that the id + * and value name an option and a value the session offered, + * so answer an unknown option or a value that was not offered with an {@code AcpProtocolException} + * with {@code AcpErrorCodes.INVALID_PARAMS} ({@code -32602}). Offer boolean options only to clients + * that support them: take a {@code NegotiatedCapabilities} parameter and check + * {@code supportsBooleanConfigOptions()}. When the agent changes options on its own, it tells the + * client with a {@code ConfigOptionUpdate} session update. + * + *

The method can take a {@code SetSessionConfigOptionRequest}, a {@link SessionId @SessionId} + * {@code String}, {@link ConfigId @ConfigId} and {@link ConfigValue @ConfigValue} parameters, and + * the connection parameters (see {@link AcpAgent}). It returns a + * {@code SetSessionConfigOptionResponse} with the full list of options, or a {@code Mono} of one. + * + *

Example usage: + *

{@code
+ * private final Map models = new ConcurrentHashMap<>();
+ *
+ * @SetSessionConfigOption
+ * public SetSessionConfigOptionResponse set(@SessionId String sessionId, @ConfigId String id,
+ *         @ConfigValue String model) {
+ *     if (!"model".equals(id) || !Set.of("fast", "deep").contains(model)) {
+ *         throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS,
+ *                 "No option " + id + " = " + model);
+ *     }
+ *     models.put(sessionId, model);
+ *     return new SetSessionConfigOptionResponse(List.of(SessionConfigSelect.model("model", "Model",
+ *             model, List.of(new SessionConfigSelectOption("fast", "Fast"),
+ *                     new SessionConfigSelectOption("deep", "Deep")))));
+ * }
+ * }
* * @author Mark Pollack * @since 0.12.0 diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionMode.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionMode.java index e7ee5857..8d5948b7 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionMode.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionMode.java @@ -11,37 +11,38 @@ import java.lang.annotation.Target; /** - * Marks a method as the handler for setting the session mode. + * Marks the {@link AcpAgent} method that answers {@code session/set_mode}: the client switches a + * session to another of the modes the agent offered, such as "ask" or "code", and the method makes + * the session work in it. Declare one when the agent returns modes ({@code SessionModeState}) from + * its {@link NewSession}, {@link LoadSession} or {@link ResumeSession} method; the agent must then + * also have a {@link NewSession} method, since the default {@code session/new} offers none + * (building the agent fails otherwise). Without a {@code @SetSessionMode} method the agent answers + * {@code session/set_mode} with "Method not found" ({@code -32601}). * - *

The annotated method handles the {@code session/set_mode} JSON-RPC method, - * which is called when a client wants to change the operating mode of the session. + *

ACP requires the mode id to be one of the {@code availableModes} the agent offered; the SDK + * does not check it. When the agent changes a session's mode itself, it tells the client with a + * {@code CurrentModeUpdate} session update. In ACP, session config options + * ({@link SetSessionConfigOption}) supersede modes; an agent with mode-like settings should send + * both, and keep them in step, so clients that only know modes still work. * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code SetSessionModeRequest} - the request containing sessionId and modeId
  • - *
  • {@code @SessionId String} - the session ID
  • - *
  • {@code String modeId} - the mode ID to set (when parameter name matches)
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code SetSessionModeResponse} - the response (typically empty)
  • - *
  • {@code void} - auto-converts to empty response
  • - *
  • {@code Mono} - for async handling
  • - *
+ *

The method can take a {@code SetSessionModeRequest} (session id and {@code modeId}), a + * {@link SessionId @SessionId} {@code String} and the connection parameters (see + * {@link AcpAgent}). It must return a {@code SetSessionModeResponse} (not {@code void}), or a + * {@code Mono} of one. * *

Example usage: *

{@code
+ * private final Map modes = new ConcurrentHashMap<>();
+ *
  * @SetSessionMode
- * public void setMode(SetSessionModeRequest req) {
- *     currentMode = req.modeId();
+ * public SetSessionModeResponse setMode(SetSessionModeRequest request) {
+ *     modes.put(request.sessionId(), request.modeId());
+ *     return new SetSessionModeResponse();
  * }
  * }
* * @author Mark Pollack - * @since 1.0.0 * @see AcpAgent - * @see SetSessionModel */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionModel.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionModel.java deleted file mode 100644 index dfb47174..00000000 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/SetSessionModel.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.annotation; - -import java.lang.annotation.Documented; -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a method as the handler for setting the session model. - * - *

The annotated method handles the {@code session/set_model} JSON-RPC method, - * which is called when a client wants to change the AI model used by the session. - * - *

The method can have the following parameters (all optional): - *

    - *
  • {@code SetSessionModelRequest} - the request containing sessionId and modelId
  • - *
  • {@code @SessionId String} - the session ID
  • - *
  • {@code String modelId} - the model ID to set (when parameter name matches)
  • - *
- * - *

The method should return one of: - *

    - *
  • {@code SetSessionModelResponse} - the response (typically empty)
  • - *
  • {@code void} - auto-converts to empty response
  • - *
  • {@code Mono} - for async handling
  • - *
- * - *

Example usage: - *

{@code
- * @SetSessionModel
- * public void setModel(SetSessionModelRequest req) {
- *     currentModel = req.modelId();
- * }
- * }
- * - * @author Mark Pollack - * @since 1.0.0 - * @see AcpAgent - * @see SetSessionMode - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Expose model selection through {@code session/set_config_option} with a - * config option whose {@code category} is {@code "model"} instead (handled with - * {@link SetSessionConfigOption}). Slated for removal. - */ -@Deprecated(forRemoval = true) -@UnstableAcpApi -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -@Documented -public @interface SetSessionModel { - -} diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/UnstableAcpApi.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/UnstableAcpApi.java index fc223b07..2d40841a 100644 --- a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/UnstableAcpApi.java +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/UnstableAcpApi.java @@ -11,28 +11,22 @@ import java.lang.annotation.Target; /** - * Marks an ACP API element as unstable. + * Marks an element of this SDK that maps to a part of ACP defined only in the protocol's unstable + * schema ({@code schema.unstable.json}), such as session forking and the provider methods. Such + * elements work, but can change or be removed in any release, as the protocol changes them. An + * element without this annotation is a stable part of the API. * - *

Unstable APIs correspond to protocol elements defined only in - * {@code schema.unstable.json}. They are public and functional but may - * change signature, behavior, wire mapping, or be removed in any minor - * release to track the ACP protocol. See {@code VERSIONING.md} for the - * full stability policy. + *

When the protocol moves the element into its stable schema ({@code schema.json}), the SDK + * removes the annotation; removing it is a compatible change. "Unstable" refers to the protocol's + * stability, not to the quality of the implementation. * - *

When the protocol element is promoted to {@code schema.json}, this - * annotation will be removed. Removing it is a compatible change. + *

What it covers: on a package, the package's types, but not its subpackages; on a type, the + * type's members, but not its subtypes; on a method, that method only, not the methods that + * override it. * - *

"Unstable" refers to protocol stability, not implementation quality. - * - *

Propagation: if a package is marked, all its classes are - * considered unstable (subpackages are not affected). If a type is marked, - * all its members are unstable, but inheritors are not. If a method is - * marked, overriding methods are not considered unstable. - * - *

This annotation is a stability marker and documentation signal, not - * an access-control mechanism. IntelliJ users can configure the built-in - * Unstable API Usage inspection to flag usages of APIs carrying - * this annotation. + *

The annotation is kept in class files but not at run time, so reflection cannot see it. It is + * a marker for readers and tools, not access control: IntelliJ IDEA's + * Unstable API Usage inspection can be set up to flag code that uses annotated elements. * * @author Mark Pollack * @since 0.12.0 @@ -45,9 +39,9 @@ public @interface UnstableAcpApi { /** - * Optional reference to the tracking spec entry, RFD, or issue that gates - * stabilization. Empty by default. - * @return a URL or identifier linking to the protocol proposal + * A link to, or an identifier of, the protocol proposal, specification entry or issue that + * the element's move to the stable schema depends on. + * @return the link or identifier, empty by default */ String value() default ""; diff --git a/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/package-info.java b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/package-info.java new file mode 100644 index 00000000..6877aded --- /dev/null +++ b/acp-annotations/src/main/java/com/agentclientprotocol/sdk/annotation/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Annotations for declaring ACP agents (@AcpAgent and the handler-method annotations) and for marking unstable protocol API. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.annotation; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AnnotationsArchitectureTest.java b/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AnnotationsArchitectureTest.java new file mode 100644 index 00000000..2289b05f --- /dev/null +++ b/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AnnotationsArchitectureTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-annotations: the annotations an agent class is written with. Agent code compiles against + * them without pulling in the SDK, so the module depends on nothing in it, nor on anything but + * the JDK and JSpecify's nullness annotations. + */ +@AnalyzeClasses(locations = AnnotationsArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class AnnotationsArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule dependsOnNothingInTheSdk = classes().should() + .onlyDependOnClassesThat() + .resideInAnyPackage("com.agentclientprotocol.sdk.annotation..", "java..", "org.jspecify..") + .because("annotations are a compile-time vocabulary; acp-agent-support interprets them"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = AcpAgent.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AuthMethodTest.java b/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AuthMethodTest.java new file mode 100644 index 00000000..8cb4bcfc --- /dev/null +++ b/acp-annotations/src/test/java/com/agentclientprotocol/sdk/annotation/AuthMethodTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.annotation; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link AuthMethod} declares the two kinds of authentication method ACP v1 defines, and a + * method is an agent method unless it says otherwise, as on the wire, where a method without a + * {@code type} is an agent method. + */ +class AuthMethodTest { + + @AcpAgent(authMethods = @AuthMethod(id = "api-key", name = "API key")) + static class Declared { + + } + + @Test + void theKindsAreTheSchemas() { + assertThat(AuthMethod.Type.values()).containsExactly(AuthMethod.Type.AGENT, AuthMethod.Type.TERMINAL); + assertThat(AuthMethod.Type.valueOf("TERMINAL")).isSameAs(AuthMethod.Type.TERMINAL); + } + + @Test + void aMethodIsAnAgentMethodByDefault() { + AuthMethod method = Declared.class.getAnnotation(AcpAgent.class).authMethods()[0]; + assertThat(method.type()).isEqualTo(AuthMethod.Type.AGENT); + assertThat(method.description()).isEmpty(); + assertThat(method.args()).isEmpty(); + assertThat(method.env()).isEmpty(); + } + +} diff --git a/acp-annotations/src/test/resources/archunit.properties b/acp-annotations/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-annotations/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-core/pom.xml b/acp-core/pom.xml index 3c1dc509..e9330a7e 100644 --- a/acp-core/pom.xml +++ b/acp-core/pom.xml @@ -7,7 +7,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-core @@ -16,7 +16,20 @@ ACP Core Core ACP implementation with stdio transport; needs one JSON module (acp-json-jackson2 or acp-json-jackson3) at runtime + + + 0.90 + 0.78 + + + + + + + org.jspecify + jspecify + com.agentclientprotocol @@ -50,6 +63,11 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + test + org.assertj assertj-core @@ -65,6 +83,11 @@ logback-classic test + + org.jetbrains.lincheck + lincheck + test + io.projectreactor reactor-test @@ -96,6 +119,17 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -XX:+IgnoreUnrecognizedVMOptions -XX:+EnableDynamicAgentLoading + + + + pit + + 1.30.0 + 1.2.3 + + DEFAULTS + + 4 + + + + + org.pitest + pitest-maven + ${pitest-maven.version} + + + org.pitest + pitest-junit5-plugin + ${pitest-junit5-plugin.version} + + + + + pit + test + + mutationCoverage + + + + + + 85 + + com.agentclientprotocol.sdk.spec.AcpClientSession* + com.agentclientprotocol.sdk.spec.AcpAgentSession* + com.agentclientprotocol.sdk.spec.OutboundMessages* + com.agentclientprotocol.sdk.spec.PendingResponses* + com.agentclientprotocol.sdk.spec.ActivePrompts* + com.agentclientprotocol.sdk.spec.Prompt* + com.agentclientprotocol.sdk.spec.InboundMessages* + com.agentclientprotocol.sdk.spec.ResponseResults* + com.agentclientprotocol.sdk.spec.AcpError* + com.agentclientprotocol.sdk.json.* + + + com.agentclientprotocol.sdk.spec.AcpSchema* + + + com.agentclientprotocol.sdk.* + + + + com.agentclientprotocol.sdk.*IT + + com.agentclientprotocol.sdk.ArchitectureTest + com.agentclientprotocol.sdk.SchedulerBestPracticesTest + + com.agentclientprotocol.sdk.*LincheckTest + + + org.slf4j + + + ${pit.mutators} + + ${pit.threads} + + 8000 + 2.0 + false + + HTML + XML + + true + + + + + + diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgent.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgent.java index 85550922..9c5b41d0 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgent.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgent.java @@ -6,272 +6,472 @@ import java.time.Duration; -import java.util.concurrent.Executors; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; +import com.agentclientprotocol.sdk.spec.PromptTimeouts; import com.agentclientprotocol.sdk.util.Assert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.agentclientprotocol.sdk.util.HandlerFailures; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; /** - * Factory class for creating Agent Client Protocol (ACP) agents. ACP agents - * provide autonomous coding capabilities to clients (such as code editors) - * through a standardized interface. + * The entry point for writing an ACP agent with builders: {@link #sync(AcpAgentTransport)} and + * {@link #async(AcpAgentTransport)} start a builder on an agent-side transport, you register one + * handler for each ACP method the agent serves, and {@code build()} returns the agent. Use + * {@code sync} for handlers that return plain values and may block, and {@code async} for handlers + * that return Reactor {@link Mono}s. To write the agent as an annotated class instead, use + * {@code AcpAgentSupport} from the {@code acp-agent-support} module. * - *

- * This class serves as the main entry point for implementing ACP-compliant agents, - * implementing the agent-side of the ACP specification. The protocol follows a - * client-agent architecture where: - *

    - *
  • The agent (this implementation) responds to client requests and sends updates
  • - *
  • The client connects to the agent and sends prompts
  • - *
  • Communication occurs through a transport layer (e.g., stdio) using JSON-RPC 2.0
  • - *
- * - *

- * The class provides factory methods to create either: - *

    - *
  • {@link AcpAsyncAgent} for non-blocking operations with Mono/Flux responses
  • - *
  • {@link AcpSyncAgent} for blocking operations with direct responses
  • - *
- * - *

- * Example of creating a basic asynchronous agent: + *

The interface is not implemented. It holds the two builders, the handler interfaces they take, + * and a few shared constants. The built agent is an {@link AcpSyncAgent} or an + * {@link AcpAsyncAgent} that serves one connection, for example over + * {@link com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport}. A listener transport, + * which accepts many connections, takes an {@link AcpAgentFactory} that runs the builder once per + * connection instead. * *

{@code
- * AcpAsyncAgent agent = AcpAgent.async(transport)
- *     .requestTimeout(Duration.ofSeconds(30))
- *     .agentInfo(new AcpSchema.AgentCapabilities(true, null, null))
- *     .initializeHandler(request -> {
- *         return Mono.just(new AcpSchema.InitializeResponse(1,
- *             new AcpSchema.AgentCapabilities(), List.of()));
- *     })
- *     .newSessionHandler(request -> {
- *         return Mono.just(new AcpSchema.NewSessionResponse(
- *             "session-1", null, null));
- *     })
- *     .promptHandler((request, updater) -> {
- *         updater.sendUpdate(new AcpSchema.AgentMessageChunk(
- *             "agent_message_chunk",
- *             new AcpSchema.TextContent("Working on it...")));
- *         return Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN));
+ * AcpSyncAgent agent = AcpAgent.sync(new StdioAcpAgentTransport())
+ *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+ *     .newSessionHandler(request -> new AcpSchema.NewSessionResponse(
+ *         UUID.randomUUID().toString(), null, null))
+ *     .promptHandler((request, context) -> {
+ *         context.sendMessage("Working on it...");
+ *         return AcpSchema.PromptResponse.endTurn();
  *     })
  *     .build();
- *
- * agent.start().block();
+ * agent.run();
  * }
* - *

- * The agent supports: - *

    - *
  • Protocol version negotiation and capability exchange
  • - *
  • Optional authentication with various methods
  • - *
  • Session creation and management (including loadSession)
  • - *
  • Prompt processing with streaming session updates
  • - *
  • File system requests to client (read/write)
  • - *
  • Permission requests for sensitive operations
  • - *
  • Terminal operations for command execution
  • - *
+ *

Handlers

+ * + *

A request for a method without a handler is answered with JSON-RPC error {@code -32601} + * (method not found). {@code build()} requires a {@code session/prompt} handler; without an + * {@code initialize} or {@code session/new} handler the agent answers those with defaults (see + * {@link AsyncAgentBuilder#build()}). A handler that fails is answered with an error: an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} with its own code and message, + * an {@link com.agentclientprotocol.sdk.spec.AcpError} from a call to the client with the error it + * carries, a {@link java.util.concurrent.CancellationException} with {@code -32800} (request + * cancelled) and its own message, anything else with {@code -32603} (internal error, message + * "Internal error"). A notification without a handler, or whose handler fails, is logged and + * dropped. + * + *

Prompt turns and cancellation

+ * + *

Each ACP session, named by its {@code sessionId}, has at most one prompt turn at a time: a + * second {@code session/prompt} for a busy session is answered {@code -32600} (invalid request). A + * {@code session/cancel} does not end the turn; the prompt's answer does, with stop reason + * {@code cancelled}: once {@code session/cancel} was received, the SDK answers {@code cancelled} + * whatever the handler returns or fails with. The builders' {@code cancelGracePeriod} (60 seconds by default) and + * {@code maxPromptDuration} (off by default) bound how long that can take: the SDK then cancels the + * handler and answers for it. Both limits are Java SDK policy, not protocol. A + * {@code $/cancel_request} from the client cancels the handler of the request it names, which is + * answered {@code -32800} (request cancelled), or {@code cancelled} for a prompt already under + * {@code session/cancel}. + * + *

Timeouts and threads

+ * + *

Requests the agent sends the client wait at most 60 seconds unless the builder's + * {@code requestTimeout} says otherwise, the same default as the client builders'. Asynchronous + * handlers are called on the transport's thread and must not block. Synchronous handlers run on the + * executor given to {@link SyncAgentBuilder#handlerExecutor}, by default a virtual thread per call + * on JDK 21 and later, and before that a pool of daemon threads the SDK shares between all + * synchronous agents in the JVM. + * + *

Not to be confused with the class annotation + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent}, which marks an annotated agent. Where a + * file uses both, import one and write the other fully qualified. * * @author Mark Pollack + * @see AcpSyncAgent * @see AcpAsyncAgent * @see AcpAgentTransport */ public interface AcpAgent { - Logger logger = LoggerFactory.getLogger(AcpAgent.class); - - /** - * Default request timeout duration. - */ - Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(60); - /** - * Library-owned scheduler for executing synchronous handlers. - * Uses daemon threads with descriptive names to prevent JVM hang on exit. - * This follows the best practice of never using global Schedulers.boundedElastic(). - */ - Scheduler SYNC_HANDLER_SCHEDULER = Schedulers.fromExecutorService( - Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r, "acp-agent-sync-handler"); - t.setDaemon(true); - return t; - }), "acp-agent-sync-handler"); - - /** - * Start building a synchronous ACP agent with the specified transport layer. - * The synchronous agent provides blocking operations for simpler implementations. - * @param transport The transport layer to use for communication - * @return A builder for configuring the synchronous agent + * Starts a builder for an agent whose handlers return plain values and may block. The handlers + * run on the builder's handler executor ({@link SyncAgentBuilder#handlerExecutor}). + * @param transport the agent-side transport the agent serves, for example a + * {@link com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport} + * @return a new builder + * @throws IllegalArgumentException if {@code transport} is null */ static SyncAgentBuilder sync(AcpAgentTransport transport) { return new SyncAgentBuilder(transport); } /** - * Start building an asynchronous ACP agent with the specified transport layer. - * The asynchronous agent provides non-blocking operations with Mono/Flux responses. - * @param transport The transport layer to use for communication - * @return A builder for configuring the asynchronous agent + * Starts a builder for an agent whose handlers return Reactor {@link Mono}s and must not block. + * @param transport the agent-side transport the agent serves, for example a + * {@link com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport} + * @return a new builder + * @throws IllegalArgumentException if {@code transport} is null */ static AsyncAgentBuilder async(AcpAgentTransport transport) { return new AsyncAgentBuilder(transport); } /** - * Functional interface for handling initialize requests. + * Answers {@code initialize}, the client's first request on a connection: it carries the + * protocol version the client wants and the client's capabilities, and the answer carries the + * protocol version for the connection, the agent's capabilities, its authentication methods and + * its name. Register it with {@link AsyncAgentBuilder#initializeHandler} when the agent needs + * to choose that answer itself, for example to list authentication methods or advertise prompt + * capabilities. + * + *

The handler is optional. Without one, the built agent answers {@code initialize} itself, + * with the negotiated protocol version, the builder's {@code agentInfo} and the capabilities + * its other handlers imply ({@code session/load} advertises {@code loadSession}, + * {@code session/list} advertises {@code sessionCapabilities.list}, {@code logout} advertises + * {@code auth.logout}, and so on). A handler's answer is sent as it is: nothing is derived from + * the other handlers, so it must advertise them itself, or clients will not call them. ACP asks + * the agent to answer with the client's protocol version if it supports it, and otherwise with + * the latest version it supports; the handler chooses the version, and the SDK does not check + * it. The client's capabilities are recorded before the handler runs + * ({@link AcpAsyncAgent#getClientCapabilities()}). + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface InitializeHandler { + /** + * Answers the client's {@code initialize} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the agent's answer: protocol version, capabilities, + * authentication methods and agent info + */ Mono handle(AcpSchema.InitializeRequest request); } /** - * Functional interface for handling authenticate requests. + * Answers {@code authenticate}: the client logs in with one of the authentication methods the + * agent listed in its {@code initialize} answer, named by its ID. Register it with + * {@link AsyncAgentBuilder#authenticateHandler} when the agent lists authentication methods. + * Answer with an empty {@link AcpSchema.AuthenticateResponse} once the login has succeeded. + * + *

The SDK checks neither the method ID nor whether a client has logged in. The handler + * should reject an ID the agent did not list, and refuse a failed login by failing with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#AUTHENTICATION_REQUIRED}. Handlers + * that need a login check for one themselves and fail the same way. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface AuthenticateHandler { + /** + * Answers the client's {@code authenticate} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the empty answer once the login has succeeded + */ Mono handle(AcpSchema.AuthenticateRequest request); } /** - * Functional interface for handling logout requests. + * Answers {@code logout}: the client ends its authenticated state with the agent. Register it + * with {@link AsyncAgentBuilder#logoutHandler}, and answer with an empty + * {@link AcpSchema.LogoutResponse} once the agent has dropped the login. + * + *

Clients send {@code logout} only to an agent that advertises {@code auth.logout}. The + * agent's default {@code initialize} answer advertises it whenever this handler is registered; + * an initialize handler must advertise it itself + * ({@link AcpSchema.AgentAuthCapabilities#withLogout()}). + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface LogoutHandler { + /** + * Answers the client's {@code logout} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the empty answer once the login has been dropped + */ Mono handle(AcpSchema.LogoutRequest request); } /** - * Functional interface for handling new session requests. + * Answers {@code session/new}: the client opens an ACP session for a working directory (an + * absolute path) and names the MCP servers the agent should connect to. The answer carries the + * new session's ID, which every later request for the session names, and optionally the + * session's modes and config options. Register it with + * {@link AsyncAgentBuilder#newSessionHandler}. + * + *

ACP requires every agent to support {@code session/new}. An agent built without this + * handler answers {@code session/new} itself, with a random UUID as the session ID and no modes + * or config options ({@link AcpSchema.NewSessionResponse#withGeneratedId()}), as an annotated + * agent without a {@code @NewSession} method does. Register a handler to keep per-session state + * or offer modes. ACP requires the session ID to be unique. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface NewSessionHandler { + /** + * Answers the client's {@code session/new} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the new session's ID, and optionally its modes and + * config options + */ Mono handle(AcpSchema.NewSessionRequest request); } /** - * Functional interface for handling load session requests. + * Answers {@code session/load}: the client reopens a session the agent kept, and the agent + * replays the whole conversation to the client before it answers. Register it with + * {@link AsyncAgentBuilder#loadSessionHandler}. To reopen a session without the replay, clients + * use {@code session/resume} instead ({@link ResumeSessionHandler}). + * + *

Unlike {@link PromptHandler}, it receives no context for sending updates. Send the replay + * as session updates through the agent ({@link AcpAsyncAgent#sendSessionUpdate}), which an + * {@link AgentAwareHandler} registered with the same setter receives, and + * complete the {@code Mono} only after they have been sent: ACP requires every update to + * precede the answer. Clients send {@code session/load} only to an agent that advertises + * {@code loadSession}; the default {@code initialize} answer does when this handler is + * registered. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface LoadSessionHandler { + /** + * Answers the client's {@code session/load} request, after replaying the conversation. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the answer, optionally with the session's modes and + * config options, once the replay has been sent + */ Mono handle(AcpSchema.LoadSessionRequest request); } /** - * Functional interface for handling prompt requests with full agent context. + * Answers {@code session/prompt}: one prompt turn of an ACP session. The handler receives the + * request (the session ID and the user's content blocks) and a {@link PromptContext} for + * sending session updates and calling the client during the turn, and returns a {@code Mono} + * that emits the {@link AcpSchema.PromptResponse} whose stop reason ends the turn. Register it + * with {@link AsyncAgentBuilder#promptHandler}; every agent needs one, and {@code build()} + * fails without it. For handler code that blocks, use {@link SyncPromptHandler} on the + * synchronous builder. * - *

- * The handler receives a {@link PromptContext} that provides access to all agent - * capabilities including file operations, permission requests, terminal operations, - * and session updates. + *

The SDK runs one turn per session at a time: a second prompt for a session whose turn has + * not ended is answered {@code -32600} (invalid request) without calling the handler. Send the + * turn's updates before the answer; once the prompt has been answered, its context drops + * further updates. + * + *

After {@code session/cancel} for the session, the context reports the cancel + * ({@link PromptContext#isCancelled()}, {@link PromptContext#whenCancelled()}): stop the work, + * send any last updates and answer {@link AcpSchema.PromptResponse#cancelled()}. If the handler + * has not answered within the builder's cancel grace period (60 seconds by default), the SDK + * disposes its {@code Mono} and answers {@code cancelled} itself; the builder's maximum prompt + * duration (off by default) bounds the whole turn the same way. ACP requires a cancelled + * prompt to answer {@code cancelled}, so the SDK answers {@code cancelled} once + * {@code session/cancel} was received: also when the handler then answers another stop reason + * (its {@code _meta} is kept), and when it fails with any exception, such as the one a model + * client throws when its call is aborted (the failure is logged at DEBUG). * - *

Example usage: *

{@code
 	 * AcpAgent.async(transport)
-	 *     .promptHandler((request, context) -> {
-	 *         // Read a file
-	 *         var file = context.readTextFile(new ReadTextFileRequest(...)).block();
-	 *
-	 *         // Send progress update
-	 *         context.sendUpdate(sessionId, new AgentThoughtChunk(...));
-	 *
-	 *         return Mono.just(new PromptResponse(StopReason.END_TURN));
-	 *     })
+	 *     .promptHandler((request, context) -> model.stream(request.prompt())  // Flux
+	 *         .concatMap(context::sendMessage)
+	 *         .takeUntilOther(context.whenCancelled())
+	 *         .then(Mono.fromSupplier(() -> context.isCancelled()
+	 *             ? AcpSchema.PromptResponse.cancelled()
+	 *             : AcpSchema.PromptResponse.endTurn())))
 	 *     .build();
 	 * }
+ * + *

Implementations are called on the transport's thread that delivered the request and must + * not block it (no {@code block()} inside); return a {@code Mono} that completes later. One + * handler serves every session of the connection, and turns of different sessions can run at + * the same time, so state they share must be thread-safe. These rules hold for every handler + * interface of {@link AcpAgent}: the value the {@code Mono} emits is the result. A failure with + * an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} is answered with its code, + * message and data, and one with the {@link com.agentclientprotocol.sdk.spec.AcpError} of a + * call to the client passes the client's error back unchanged. A + * {@link java.util.concurrent.CancellationException} is answered {@code -32800} (request + * cancelled). Any other failure, a throw or a {@code null} return included, is answered + * {@code -32603} with the message "Internal error" and logged at the agent. An empty + * {@code Mono} is answered {@code -32603} too. */ @FunctionalInterface interface PromptHandler { /** - * Handles a prompt request with full access to agent capabilities. - * @param request The prompt request - * @param context Context providing all agent capabilities (file ops, permissions, updates, etc.) - * @return A Mono containing the prompt response + * Runs one prompt turn and answers it. + * @param request the prompt: the session ID and the user's content blocks; never null + * @param context the turn's way back to the client: session updates, client requests and + * cancellation, for this turn + * @return a {@code Mono} that emits the response whose stop reason ends the turn, for + * example {@link AcpSchema.PromptResponse#endTurn()} or + * {@link AcpSchema.PromptResponse#cancelled()} */ Mono handle(AcpSchema.PromptRequest request, PromptContext context); } /** - * Functional interface for handling set session mode requests. + * Answers {@code session/set_mode}: the client switches a session to one of the modes the agent + * offered in the session's {@code modes} (for example "ask" or "code"). Register it with + * {@link AsyncAgentBuilder#setSessionModeHandler}, and answer with an empty + * {@link AcpSchema.SetSessionModeResponse} once the mode is in effect. + * + *

The SDK does not check the mode ID against the modes offered; answer an unknown one with + * an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. When the agent + * changes a session's mode on its own, it tells the client with a + * {@link AcpSchema.CurrentModeUpdate} session update instead. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SetSessionModeHandler { + /** + * Answers the client's {@code session/set_mode} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the empty answer once the mode is in effect + */ Mono handle(AcpSchema.SetSessionModeRequest request); } /** - * Functional interface for handling set session model requests. + * Answers {@code session/list}: the client asks for the sessions the agent knows, optionally + * only those of one working directory, a page at a time. Register it with + * {@link AsyncAgentBuilder#listSessionsHandler}. Answer with a + * {@link AcpSchema.ListSessionsResponse} holding one page and, when more remain, an opaque + * cursor the client sends back for the next one. * - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@code session/set_config_option} with a {@code "model"} category - * config option instead. Slated for removal. - */ - @Deprecated(forRemoval = true) - @UnstableAcpApi - @FunctionalInterface - @SuppressWarnings("removal") - interface SetSessionModelHandler { - - Mono handle(AcpSchema.SetSessionModelRequest request); - - } - - /** - * Functional interface for handling list sessions requests. + *

Clients send {@code session/list} only to an agent that advertises + * {@code sessionCapabilities.list}; the default {@code initialize} answer does when this + * handler is registered. An agent with no sessions answers an empty list, and ACP asks for an + * error answer to a cursor the agent does not recognize. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface ListSessionsHandler { + /** + * Answers the client's {@code session/list} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits one page of sessions and, when more remain, the next + * cursor + */ Mono handle(AcpSchema.ListSessionsRequest request); } /** - * Functional interface for handling close session requests. + * Answers {@code session/close}: the client ends an active session, and the agent frees what + * the session holds. Register it with {@link AsyncAgentBuilder#closeSessionHandler}, and answer + * with an empty {@link AcpSchema.CloseSessionResponse}. + * + *

The SDK first cancels the session's work, as ACP requires: it calls the + * {@link CancelHandler} if one is registered, cancels a running prompt (whose context reports + * the cancel), and waits for that prompt's turn to end. Only then does it call this handler. + * Clients send {@code session/close} only to an agent that advertises + * {@code sessionCapabilities.close}; the default {@code initialize} answer does when this + * handler is registered. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface CloseSessionHandler { + /** + * Answers the client's {@code session/close} request, after the session's work has been + * cancelled. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the empty answer once the session's resources are freed + */ Mono handle(AcpSchema.CloseSessionRequest request); } /** - * Functional interface for handling delete session requests. + * Answers {@code session/delete}: the client removes a stored session for good, so that + * {@code session/list} no longer returns it. Register it with + * {@link AsyncAgentBuilder#deleteSessionHandler}, and answer with an empty + * {@link AcpSchema.DeleteSessionResponse}. + * + *

Deleting is not closing: unlike for {@link CloseSessionHandler}, the SDK does not cancel + * the session's work first. Clients send {@code session/delete} only to an agent that + * advertises {@code sessionCapabilities.delete}; the default {@code initialize} answer does + * when this handler is registered. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface DeleteSessionHandler { + /** + * Answers the client's {@code session/delete} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the empty answer once the session is deleted + */ Mono handle(AcpSchema.DeleteSessionRequest request); } /** - * Functional interface for handling resume session requests. + * Answers {@code session/resume}: the client reopens a session the agent kept, without a replay + * of its history. The agent restores the session, connects to the MCP servers the request + * names, and answers once the session can take prompts. Register it with + * {@link AsyncAgentBuilder#resumeSessionHandler}. To reopen a session with the replay, clients + * use {@code session/load} ({@link LoadSessionHandler}). + * + *

ACP forbids sending the history as session updates before the answer. Clients send + * {@code session/resume} only to an agent that advertises {@code sessionCapabilities.resume}; + * the default {@code initialize} answer does when this handler is registered. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface ResumeSessionHandler { + /** + * Answers the client's {@code session/resume} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits the answer, optionally with the session's modes and + * config options, once the session is ready + */ Mono handle(AcpSchema.ResumeSessionRequest request); } @@ -279,6 +479,7 @@ interface ResumeSessionHandler { /** * Functional interface for handling fork session requests. */ + @UnstableAcpApi @FunctionalInterface interface ForkSessionHandler { @@ -287,11 +488,32 @@ interface ForkSessionHandler { } /** - * Functional interface for handling set config option requests. + * Answers {@code session/set_config_option}: the client changes one of a session's config + * options (a select or a boolean the agent offered). Register it with + * {@link AsyncAgentBuilder#setSessionConfigOptionHandler}. Answer with a + * {@link AcpSchema.SetSessionConfigOptionResponse} that lists all of the session's config + * options with their current values, not only the changed one. + * + *

The request's value is a {@code String} for a select option and a {@code Boolean} for a + * boolean option, and the SDK does not check it against the options offered: answer an unknown + * option or value with an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} + * carrying {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. When the + * agent changes an option on its own, it tells the client with a + * {@link AcpSchema.ConfigOptionUpdate} session update instead. + * + *

Implementations follow the rules on {@link PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SetSessionConfigOptionHandler { + /** + * Answers the client's {@code session/set_config_option} request. + * @param request the request, as the client sent it; never null + * @return a {@code Mono} that emits every config option of the session, with its current + * value + */ Mono handle(AcpSchema.SetSessionConfigOptionRequest request); } @@ -330,179 +552,458 @@ interface DisableProviderHandler { } /** - * Functional interface for handling cancel notifications. + * Receives {@code session/cancel}: the client's notification that it wants a session's prompt + * turn to stop. Register it with {@link AsyncAgentBuilder#cancelHandler} when work outside the + * prompt handler's {@code Mono} must be stopped, for example a separate process. Most agents + * need none: the running prompt's {@link PromptContext} already reports the cancel. + * + *

Before the handler runs, the SDK marks the session's turn as cancelling and its prompt + * context as cancelled. A cancel does not end the turn: the prompt handler does, by answering + * with stop reason {@code cancelled}, and until then the session refuses a new prompt. If the + * prompt handler has not answered within the cancel grace period (60 seconds unless set with + * {@code cancelGracePeriod}), the SDK cancels it and answers {@code cancelled} itself. The + * handler is called for every {@code session/cancel}, also for a session with no prompt + * running, and when the client closes a session ({@code session/close}), before the + * {@link CloseSessionHandler}. + * + *

Implementations run on the transport's thread and must not block it. A notification gets + * no answer: a handler that fails is logged and the failure dropped. */ @FunctionalInterface interface CancelHandler { + /** + * Handles a {@code session/cancel} for one session. + * @param notification the cancel, naming the session; never null + * @return a {@code Mono} that completes when the handler has done its part + */ Mono handle(AcpSchema.CancelNotification notification); } + /** + * Answers a custom extension request from the client: a method whose name starts with {@code _} + * (ACP v1 Extensibility), such as {@code _example.com/workspace/buffers}. Register it with + * {@link AsyncAgentBuilder#extRequestHandler(String, TypeRef, ExtRequestHandler)} to read the + * params as a type of your own, or with + * {@link AsyncAgentBuilder#extRequestHandler(String, ExtRequestHandler)} to get the raw JSON + * value. A request for an extension method without a handler is answered {@code -32601} (method + * not found). + * + *

Unlike the protocol handlers, it has no schema record: the params are converted to + * {@code T} but not checked, and the result can be any value the JSON mapper can write. + * Implementations follow the rules on {@link PromptHandler}: they run on the transport's thread + * and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. + * @param the type the params are read as; {@code Object} for the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}) + */ + @FunctionalInterface + interface ExtRequestHandler { + + /** + * Answers the request. + * @param params the request's params; an omitted params arrives as an empty object + * @return a {@code Mono} that emits the result, any value the JSON mapper can write; an + * empty {@code Mono} is answered with an internal error ({@code -32603}), so emit an empty + * map when there is nothing to return + */ + Mono handle(T params); + + } + + /** + * Receives a custom extension notification from the client: a method whose name starts with + * {@code _} (ACP v1 Extensibility). Register it with + * {@link AsyncAgentBuilder#extNotificationHandler(String, TypeRef, ExtNotificationHandler)} to + * read the params as a type of your own, or with + * {@link AsyncAgentBuilder#extNotificationHandler(String, ExtNotificationHandler)} to get the + * raw JSON value. An extension notification without a handler is ignored, as ACP asks. + * + *

Implementations run on the transport's thread and must not block it. A notification gets + * no answer: a handler that fails is logged and the failure dropped. + * @param the type the params are read as; {@code Object} for the raw JSON value + */ + @FunctionalInterface + interface ExtNotificationHandler { + + /** + * Handles the notification. + * @param params the notification's params; an omitted params arrives as an empty object + * @return a {@code Mono} that completes when the notification is handled + */ + Mono handle(T params); + + } + // ======================================================================== // Synchronous Handler Interfaces (for SyncAgentBuilder) // ======================================================================== /** - * Synchronous functional interface for handling initialize requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code initialize}, the client's first request on a connection: it carries the + * protocol version the client wants and the client's capabilities, and the answer carries the + * protocol version for the connection, the agent's capabilities, its authentication methods and + * its name. Register it with {@link SyncAgentBuilder#initializeHandler} when the agent needs to + * choose that answer itself, for example to list authentication methods or advertise prompt + * capabilities. + * + *

The handler is optional. Without one, the built agent answers {@code initialize} itself, + * with the negotiated protocol version, the builder's {@code agentInfo} and the capabilities + * its other handlers imply ({@code session/load} advertises {@code loadSession}, + * {@code session/list} advertises {@code sessionCapabilities.list}, {@code logout} advertises + * {@code auth.logout}, and so on). A handler's answer is sent as it is: nothing is derived from + * the other handlers, so it must advertise them itself, or clients will not call them. ACP asks + * the agent to answer with the client's protocol version if it supports it, and otherwise with + * the latest version it supports; the handler chooses the version, and the SDK does not check + * it. The client's capabilities are recorded before the handler runs + * ({@link AcpSyncAgent#getClientCapabilities()}). + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncInitializeHandler { + /** + * Answers the client's {@code initialize} request. It may block. + * @param request the request, as the client sent it; never null + * @return the agent's answer: protocol version, capabilities, authentication methods and + * agent info + */ AcpSchema.InitializeResponse handle(AcpSchema.InitializeRequest request); } /** - * Synchronous functional interface for handling authenticate requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code authenticate}: the client logs in with one of the authentication methods the + * agent listed in its {@code initialize} answer, named by its ID. Register it with + * {@link SyncAgentBuilder#authenticateHandler} when the agent lists authentication methods. + * Answer with an empty {@link AcpSchema.AuthenticateResponse} once the login has succeeded. + * + *

The SDK checks neither the method ID nor whether a client has logged in. The handler + * should reject an ID the agent did not list, and refuse a failed login by throwing an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#AUTHENTICATION_REQUIRED}. Handlers + * that need a login check for one themselves and fail the same way. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncAuthenticateHandler { + /** + * Answers the client's {@code authenticate} request. It may block. + * @param request the request, as the client sent it; never null + * @return the empty answer, once the login has succeeded + */ AcpSchema.AuthenticateResponse handle(AcpSchema.AuthenticateRequest request); } /** - * Synchronous functional interface for handling logout requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code logout}: the client ends its authenticated state with the agent. Register it + * with {@link SyncAgentBuilder#logoutHandler}, and answer with an empty + * {@link AcpSchema.LogoutResponse} once the agent has dropped the login. + * + *

Clients send {@code logout} only to an agent that advertises {@code auth.logout}. The + * agent's default {@code initialize} answer advertises it whenever this handler is registered; + * an initialize handler must advertise it itself + * ({@link AcpSchema.AgentAuthCapabilities#withLogout()}). + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncLogoutHandler { + /** + * Answers the client's {@code logout} request. It may block. + * @param request the request, as the client sent it; never null + * @return the empty answer, once the login has been dropped + */ AcpSchema.LogoutResponse handle(AcpSchema.LogoutRequest request); } /** - * Synchronous functional interface for handling new session requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/new}: the client opens an ACP session for a working directory (an + * absolute path) and names the MCP servers the agent should connect to. The answer carries the + * new session's ID, which every later request for the session names, and optionally the + * session's modes and config options. Register it with + * {@link SyncAgentBuilder#newSessionHandler}. + * + *

ACP requires every agent to support {@code session/new}. An agent built without this + * handler answers {@code session/new} itself, with a random UUID as the session ID and no modes + * or config options ({@link AcpSchema.NewSessionResponse#withGeneratedId()}), as an annotated + * agent without a {@code @NewSession} method does. Register a handler to keep per-session state + * or offer modes. ACP requires the session ID to be unique. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncNewSessionHandler { + /** + * Answers the client's {@code session/new} request. It may block. + * @param request the request, as the client sent it; never null + * @return the new session's ID, and optionally its modes and config options + */ AcpSchema.NewSessionResponse handle(AcpSchema.NewSessionRequest request); } /** - * Synchronous functional interface for handling load session requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/load}: the client reopens a session the agent kept, and the agent + * replays the whole conversation to the client before it answers. Register it with + * {@link SyncAgentBuilder#loadSessionHandler}. To reopen a session without the replay, clients + * use {@code session/resume} instead ({@link SyncResumeSessionHandler}). + * + *

Unlike {@link SyncPromptHandler}, it receives no context for sending updates. Send the + * replay as session updates through the agent + * ({@link AcpSyncAgent#sendSessionUpdate(String, AcpSchema.SessionUpdate)}), which a + * {@link SyncAgentAwareHandler} registered with the same setter receives, and return only + * after they have been sent: ACP requires every update to precede the answer. Clients send + * {@code session/load} only to an agent that advertises {@code loadSession}; the default + * {@code initialize} answer does when this handler is registered. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncLoadSessionHandler { + /** + * Answers the client's {@code session/load} request, after replaying the conversation. It + * may block. + * @param request the request, as the client sent it; never null + * @return the answer, optionally with the session's modes and config options, once the + * replay has been sent + */ AcpSchema.LoadSessionResponse handle(AcpSchema.LoadSessionRequest request); } /** - * Synchronous functional interface for handling prompt requests with full agent context. + * Answers {@code session/prompt}: one prompt turn of an ACP session. The handler receives the + * request (the session ID and the user's content blocks) and a {@link SyncPromptContext} for + * sending session updates and calling the client during the turn, and returns the + * {@link AcpSchema.PromptResponse} whose stop reason ends the turn. Register it with + * {@link SyncAgentBuilder#promptHandler}; every agent needs one, and {@code build()} fails + * without it. For handler code that returns a {@link Mono}, use {@link PromptHandler} on the + * asynchronous builder. + * + *

The SDK runs one turn per session at a time: a second prompt for a session whose turn has + * not ended is answered {@code -32600} (invalid request) without calling the handler. Send the + * turn's updates before the answer; once the prompt has been answered, its context drops + * further updates. * - *

- * The handler receives a {@link SyncPromptContext} that provides blocking access to all - * agent capabilities including file operations, permission requests, terminal operations, - * and session updates. + *

After {@code session/cancel} for the session, the context reports the cancel + * ({@link SyncPromptContext#isCancelled()}, {@link SyncPromptContext#onCancel(Runnable)}): stop + * the work, send any last updates and return {@link AcpSchema.PromptResponse#cancelled()}. If + * the handler has not answered within the builder's cancel grace period (60 seconds by + * default), the SDK answers {@code cancelled} itself and interrupts the handler's thread. The + * builder's maximum prompt duration (off by default) and a {@code $/cancel_request} that names + * the prompt also end the turn: the SDK answers {@code -32800} (request cancelled), or + * {@code cancelled} if the client had sent {@code session/cancel}, and interrupts the thread. + * What the handler returns after that is discarded. ACP requires a cancelled prompt to answer + * {@code cancelled}, so the SDK answers {@code cancelled} once {@code session/cancel} was + * received: also when the handler then returns another stop reason (its {@code _meta} is + * kept), and when it throws any exception, such as the one a model client throws when its call + * is aborted (the failure is logged at DEBUG). * - *

Example usage: *

{@code
 	 * AcpAgent.sync(transport)
 	 *     .promptHandler((request, context) -> {
-	 *         // Read a file (blocks)
-	 *         var file = context.readTextFile(new ReadTextFileRequest(...));
-	 *
-	 *         // Send progress update (blocks)
-	 *         context.sendUpdate(sessionId, new AgentThoughtChunk(...));
-	 *
-	 *         return new PromptResponse(StopReason.END_TURN);
+	 *         for (String step : List.of("plan", "edit", "test")) {
+	 *             if (context.isCancelled()) {
+	 *                 return AcpSchema.PromptResponse.cancelled();
+	 *             }
+	 *             context.sendThought("Running " + step);   // blocks until handed to the transport
+	 *         }
+	 *         context.sendMessage("Done");
+	 *         return AcpSchema.PromptResponse.endTurn();
 	 *     })
 	 *     .build();
 	 * }
+ * + *

Implementations run on the builder's handler executor + * ({@link SyncAgentBuilder#handlerExecutor(ExecutorService)}), by default a virtual thread per + * call on JDK 21 and later (before, a pool of daemon threads shared by the synchronous agents + * in the JVM), not on the transport's thread, so they may block. The context's calls block until the client answers: a call waits at most the + * agent's request timeout and then throws + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}, and a call blocked when the + * SDK interrupts the thread throws {@link java.util.concurrent.CancellationException}. One + * handler serves every session of the connection, and turns of different sessions can run at + * the same time on different threads, so state they share must be thread-safe. These rules hold + * for every synchronous handler interface of {@link AcpAgent}: the returned value is the + * result. An {@link com.agentclientprotocol.sdk.error.AcpProtocolException} thrown by the + * handler is answered with its code, message and data, and the + * {@link com.agentclientprotocol.sdk.spec.AcpError} of a call to the client passes the client's + * error back unchanged. A {@link java.util.concurrent.CancellationException} is answered + * {@code -32800} (request cancelled). Any other exception is answered {@code -32603} with the + * message "Internal error" and logged at the agent; a {@code null} return is answered + * {@code -32603} too. */ @FunctionalInterface interface SyncPromptHandler { /** - * Handles a prompt request with full access to agent capabilities. - * @param request The prompt request - * @param context Context providing blocking access to all agent capabilities - * @return The prompt response + * Runs one prompt turn and answers it. It may block. + * @param request the prompt: the session ID and the user's content blocks; never null + * @param context the turn's way back to the client: session updates, client requests and + * cancellation, for this turn + * @return the response whose stop reason ends the turn, for example + * {@link AcpSchema.PromptResponse#endTurn()} or + * {@link AcpSchema.PromptResponse#cancelled()} */ AcpSchema.PromptResponse handle(AcpSchema.PromptRequest request, SyncPromptContext context); } /** - * Synchronous functional interface for handling set session mode requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/set_mode}: the client switches a session to one of the modes the agent + * offered in the session's {@code modes} (for example "ask" or "code"). Register it with + * {@link SyncAgentBuilder#setSessionModeHandler}, and answer with an empty + * {@link AcpSchema.SetSessionModeResponse} once the mode is in effect. + * + *

The SDK does not check the mode ID against the modes offered; answer an unknown one by + * throwing an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. When the agent + * changes a session's mode on its own, it tells the client with a + * {@link AcpSchema.CurrentModeUpdate} session update instead. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncSetSessionModeHandler { + /** + * Answers the client's {@code session/set_mode} request. It may block. + * @param request the request, as the client sent it; never null + * @return the empty answer, once the mode is in effect + */ AcpSchema.SetSessionModeResponse handle(AcpSchema.SetSessionModeRequest request); } /** - * Synchronous functional interface for handling set session model requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/list}: the client asks for the sessions the agent knows, optionally + * only those of one working directory, a page at a time. Register it with + * {@link SyncAgentBuilder#listSessionsHandler}. Answer with a + * {@link AcpSchema.ListSessionsResponse} holding one page and, when more remain, an opaque + * cursor the client sends back for the next one. * - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@code session/set_config_option} with a {@code "model"} category - * config option instead. Slated for removal. - */ - @Deprecated(forRemoval = true) - @UnstableAcpApi - @FunctionalInterface - @SuppressWarnings("removal") - interface SyncSetSessionModelHandler { - - AcpSchema.SetSessionModelResponse handle(AcpSchema.SetSessionModelRequest request); - - } - - /** - * Synchronous functional interface for handling list sessions requests. - * Returns a plain value instead of Mono for use with sync agents. + *

Clients send {@code session/list} only to an agent that advertises + * {@code sessionCapabilities.list}; the default {@code initialize} answer does when this + * handler is registered. An agent with no sessions answers an empty list, and ACP asks for an + * error answer to a cursor the agent does not recognize. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncListSessionsHandler { + /** + * Answers the client's {@code session/list} request. It may block. + * @param request the request, as the client sent it; never null + * @return one page of sessions and, when more remain, the next cursor + */ AcpSchema.ListSessionsResponse handle(AcpSchema.ListSessionsRequest request); } /** - * Synchronous functional interface for handling close session requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/close}: the client ends an active session, and the agent frees what + * the session holds. Register it with {@link SyncAgentBuilder#closeSessionHandler}, and answer + * with an empty {@link AcpSchema.CloseSessionResponse}. + * + *

The SDK first cancels the session's work, as ACP requires: it calls the + * {@link SyncCancelHandler} if one is registered, cancels a running prompt (whose context + * reports the cancel), and waits for that prompt's turn to end. Only then does it call this + * handler. Clients send {@code session/close} only to an agent that advertises + * {@code sessionCapabilities.close}; the default {@code initialize} answer does when this + * handler is registered. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncCloseSessionHandler { + /** + * Answers the client's {@code session/close} request, after the session's work has been + * cancelled. It may block. + * @param request the request, as the client sent it; never null + * @return the empty answer, once the session's resources are freed + */ AcpSchema.CloseSessionResponse handle(AcpSchema.CloseSessionRequest request); } /** - * Synchronous functional interface for handling delete session requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/delete}: the client removes a stored session for good, so that + * {@code session/list} no longer returns it. Register it with + * {@link SyncAgentBuilder#deleteSessionHandler}, and answer with an empty + * {@link AcpSchema.DeleteSessionResponse}. + * + *

Deleting is not closing: unlike for {@link SyncCloseSessionHandler}, the SDK does not + * cancel the session's work first. Clients send {@code session/delete} only to an agent that + * advertises {@code sessionCapabilities.delete}; the default {@code initialize} answer does + * when this handler is registered. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncDeleteSessionHandler { + /** + * Answers the client's {@code session/delete} request. It may block. + * @param request the request, as the client sent it; never null + * @return the empty answer, once the session is deleted + */ AcpSchema.DeleteSessionResponse handle(AcpSchema.DeleteSessionRequest request); } /** - * Synchronous functional interface for handling resume session requests. - * Returns a plain value instead of Mono for use with sync agents. + * Answers {@code session/resume}: the client reopens a session the agent kept, without a replay + * of its history. The agent restores the session, connects to the MCP servers the request + * names, and answers once the session can take prompts. Register it with + * {@link SyncAgentBuilder#resumeSessionHandler}. To reopen a session with the replay, clients + * use {@code session/load} ({@link SyncLoadSessionHandler}). + * + *

ACP forbids sending the history as session updates before the answer. Clients send + * {@code session/resume} only to an agent that advertises {@code sessionCapabilities.resume}; + * the default {@code initialize} answer does when this handler is registered. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncResumeSessionHandler { + /** + * Answers the client's {@code session/resume} request. It may block. + * @param request the request, as the client sent it; never null + * @return the answer, optionally with the session's modes and config options, once the + * session is ready + */ AcpSchema.ResumeSessionResponse handle(AcpSchema.ResumeSessionRequest request); } @@ -510,6 +1011,7 @@ interface SyncResumeSessionHandler { /** * Synchronous functional interface for handling fork session requests. */ + @UnstableAcpApi @FunctionalInterface interface SyncForkSessionHandler { @@ -518,11 +1020,31 @@ interface SyncForkSessionHandler { } /** - * Synchronous functional interface for handling set config option requests. + * Answers {@code session/set_config_option}: the client changes one of a session's config + * options (a select or a boolean the agent offered). Register it with + * {@link SyncAgentBuilder#setSessionConfigOptionHandler}. Answer with a + * {@link AcpSchema.SetSessionConfigOptionResponse} that lists all of the session's config + * options with their current values, not only the changed one. + * + *

The request's value is a {@code String} for a select option and a {@code Boolean} for a + * boolean option, and the SDK does not check it against the options offered: answer an unknown + * option or value by throwing an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} + * carrying {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. When the + * agent changes an option on its own, it tells the client with a + * {@link AcpSchema.ConfigOptionUpdate} session update instead. + * + *

Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. */ @FunctionalInterface interface SyncSetSessionConfigOptionHandler { + /** + * Answers the client's {@code session/set_config_option} request. It may block. + * @param request the request, as the client sent it; never null + * @return every config option of the session, with its current value + */ AcpSchema.SetSessionConfigOptionResponse handle(AcpSchema.SetSessionConfigOptionRequest request); } @@ -561,61 +1083,124 @@ interface SyncDisableProviderHandler { } /** - * Synchronous functional interface for handling cancel notifications. - * Returns void instead of Mono for use with sync agents. + * Receives {@code session/cancel}: the client's notification that it wants a session's prompt + * turn to stop. Register it with {@link SyncAgentBuilder#cancelHandler} when work outside the + * prompt handler must be stopped, for example a separate process. Most agents need none: the + * running prompt's {@link SyncPromptContext} already reports the cancel + * ({@link SyncPromptContext#isCancelled()}, {@link SyncPromptContext#onCancel(Runnable)}). + * + *

Before the handler runs, the SDK marks the session's turn as cancelling and its prompt + * context as cancelled. A cancel does not end the turn: the prompt handler does, by returning + * stop reason {@code cancelled}, and until then the session refuses a new prompt. If the prompt + * handler has not answered within the cancel grace period (60 seconds unless set with + * {@code cancelGracePeriod}), the SDK answers {@code cancelled} itself and interrupts the + * prompt handler's thread. The handler is called for every {@code session/cancel}, also for a + * session with no prompt running, and when the client closes a session ({@code session/close}), + * before the {@link SyncCloseSessionHandler}. + * + *

Implementations run on the builder's handler executor and may block. A cancel handler can + * run on another thread while the prompt handler is still running, so share state with it + * through something thread-safe. With a bounded executor it can instead wait behind the prompt + * handler it is meant to stop, so the prompt handler should not depend on it. A notification + * gets no answer: a handler that throws is logged and the failure dropped. */ @FunctionalInterface interface SyncCancelHandler { + /** + * Handles a {@code session/cancel} for one session. It may block. + * @param notification the cancel, naming the session; never null + */ void handle(AcpSchema.CancelNotification notification); } /** - * Builder for creating asynchronous ACP agents. + * Answers a custom extension request from the client: a method whose name starts with {@code _} + * (ACP v1 Extensibility), such as {@code _example.com/workspace/buffers}. Register it with + * {@link SyncAgentBuilder#extRequestHandler(String, TypeRef, SyncExtRequestHandler)} to read + * the params as a type of your own, or with + * {@link SyncAgentBuilder#extRequestHandler(String, SyncExtRequestHandler)} to get the raw JSON + * value. A request for an extension method without a handler is answered {@code -32601} (method + * not found). + * + *

Unlike the protocol handlers, it has no schema record: the params are converted to + * {@code T} but not checked, and the result can be any value the JSON mapper can write. + * Implementations follow the rules on {@link SyncPromptHandler}: they run on the builder's + * handler executor and may block, return the answer and never {@code null}, and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. + * @param the type the params are read as; {@code Object} for the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}) */ - class AsyncAgentBuilder { - - private final AcpAgentTransport transport; - - private Duration requestTimeout = DEFAULT_REQUEST_TIMEOUT; - - private InitializeHandler initializeHandler; - - private AuthenticateHandler authenticateHandler; - - private LogoutHandler logoutHandler; - - private NewSessionHandler newSessionHandler; - - private LoadSessionHandler loadSessionHandler; - - private PromptHandler promptHandler; + @FunctionalInterface + interface SyncExtRequestHandler { - private SetSessionModeHandler setSessionModeHandler; + /** + * Answers the request. It may block. + * @param params the request's params; an omitted params arrives as an empty object + * @return the result, any value the JSON mapper can write; returning {@code null} answers + * the request with an internal error ({@code -32603}), so return an empty map when there is + * nothing to return + */ + Object handle(T params); - @SuppressWarnings("removal") - private SetSessionModelHandler setSessionModelHandler; + } - private ListSessionsHandler listSessionsHandler; + /** + * Receives a custom extension notification from the client: a method whose name starts with + * {@code _} (ACP v1 Extensibility). Register it with + * {@link SyncAgentBuilder#extNotificationHandler(String, TypeRef, SyncExtNotificationHandler)} + * to read the params as a type of your own, or with + * {@link SyncAgentBuilder#extNotificationHandler(String, SyncExtNotificationHandler)} to get + * the raw JSON value. An extension notification without a handler is ignored, as ACP asks. + * + *

Implementations run on the builder's handler executor and may block. A notification gets + * no answer: a handler that throws is logged and the failure dropped. + * @param the type the params are read as; {@code Object} for the raw JSON value + */ + @FunctionalInterface + interface SyncExtNotificationHandler { - private CloseSessionHandler closeSessionHandler; + /** + * Handles the notification. It may block. + * @param params the notification's params; an omitted params arrives as an empty object + */ + void handle(T params); - private DeleteSessionHandler deleteSessionHandler; + } - private ResumeSessionHandler resumeSessionHandler; + /** + * Configures and builds an {@link AcpAsyncAgent}: one handler for each ACP method the agent + * serves, each returning a Reactor {@link Mono}, plus the request timeout and the prompt + * timeouts. Get one from {@link AcpAgent#async(AcpAgentTransport)}. Use it when the handlers do + * not block; for handler code that blocks, use {@link SyncAgentBuilder}. + * + *

Handlers are called on the transport's thread that delivered the message, so they must not + * block: a handler that waits there holds up the whole connection. Return a {@code Mono} that + * completes later instead. + * + *

Each setter registers the handler for one ACP method. A null handler fails with + * {@link IllegalArgumentException}, and registering a method a second time fails with + * {@link IllegalStateException} naming the setter. A request handler's {@code Mono} must emit + * the response: a failed {@code Mono} is sent to the client as an error answer (an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} with its own code, anything + * else as {@code -32603}), and an empty one is answered {@code -32603}. A builder is not + * thread-safe; configure it on one thread. + */ + final class AsyncAgentBuilder extends AsyncAgentAwareSetters { - private ForkSessionHandler forkSessionHandler; + private final AcpAgentTransport transport; - private SetSessionConfigOptionHandler setSessionConfigOptionHandler; + /** How long the agent waits for the client's answers unless {@code requestTimeout} is set. */ + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(60); - private ListProvidersHandler listProvidersHandler; + private Duration requestTimeout = DEFAULT_REQUEST_TIMEOUT; - private SetProviderHandler setProviderHandler; + private PromptTimeouts promptTimeouts = PromptTimeouts.DEFAULTS; - private DisableProviderHandler disableProviderHandler; + private final AgentHandlers handlers = new AgentHandlers(); - private CancelHandler cancelHandler; + private AcpSchema.@Nullable Implementation agentInfo; AsyncAgentBuilder(AcpAgentTransport transport) { Assert.notNull(transport, "Transport must not be null"); @@ -623,9 +1208,15 @@ class AsyncAgentBuilder { } /** - * Sets the timeout for requests sent to the client. - * @param timeout The request timeout duration - * @return This builder for chaining + * Sets how long the agent waits for the client to answer a request the agent sent: a file + * read or write, a permission or elicitation request, a terminal call or an extension + * request. When it passes, the call fails with a + * {@link java.util.concurrent.TimeoutException} and the agent sends the client a + * {@code $/cancel_request}. Default: 60 seconds. + * It does not limit the agent's own handlers; for prompts, see {@code maxPromptDuration}. + * @param timeout the timeout + * @return this builder + * @throws IllegalArgumentException if {@code timeout} is null */ public AsyncAgentBuilder requestTimeout(Duration timeout) { Assert.notNull(timeout, "Timeout must not be null"); @@ -634,239 +1225,475 @@ public AsyncAgentBuilder requestTimeout(Duration timeout) { } /** - * Sets the handler for initialize requests. - * @param handler The initialize handler - * @return This builder for chaining + * Sets how long a prompt handler has to answer after {@code session/cancel}. When it + * passes, the agent cancels the handler and answers the prompt itself with stop reason + * {@code cancelled}, as ACP requires of a cancelled prompt; that ends the turn, so the + * session accepts a new prompt. Updates the handler sent before that answer reach the + * client first. Default: 60 seconds ({@link PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD}); + * {@link Duration#ZERO} turns it off, and a handler that never answers then keeps its + * session busy. This limit is Java SDK policy; ACP defines none. + * @param gracePeriod the grace period; zero for none + * @return this builder + * @throws IllegalArgumentException if {@code gracePeriod} is null or negative */ - public AsyncAgentBuilder initializeHandler(InitializeHandler handler) { - this.initializeHandler = handler; + public AsyncAgentBuilder cancelGracePeriod(Duration gracePeriod) { + this.promptTimeouts = this.promptTimeouts.withCancelGracePeriod(gracePeriod); return this; } /** - * Sets the handler for authenticate requests. - * @param handler The authenticate handler - * @return This builder for chaining + * Sets the agent's name and version, sent as {@code agentInfo} in the {@code initialize} + * response the agent answers when no initialize handler is registered. An initialize + * handler sets {@code agentInfo} in its own response. + * @param agentInfo the agent's name, version and optional title + * @return this builder */ - public AsyncAgentBuilder authenticateHandler(AuthenticateHandler handler) { - this.authenticateHandler = handler; + public AsyncAgentBuilder agentInfo(AcpSchema.Implementation agentInfo) { + Assert.notNull(agentInfo, "agentInfo must not be null"); + this.agentInfo = agentInfo; return this; } /** - * Sets the handler for logout requests. - * @param handler The logout handler - * @return This builder for chaining + * Sets how long a prompt may run. When it passes, the agent cancels the handler and answers + * the prompt with JSON-RPC error {@code -32800} (request cancelled; ACP answers an + * internally cancelled request, an internal timeout included, with this code), or with stop + * reason {@code cancelled} if the client had cancelled the prompt, and the turn ends. + * Default: none ({@link Duration#ZERO}), since a prompt turn can legitimately run for a + * long time. This limit is Java SDK policy; ACP defines none. + * @param maxDuration the maximum prompt duration; zero for none + * @return this builder + * @throws IllegalArgumentException if {@code maxDuration} is null or negative */ - public AsyncAgentBuilder logoutHandler(LogoutHandler handler) { - this.logoutHandler = handler; + public AsyncAgentBuilder maxPromptDuration(Duration maxDuration) { + this.promptTimeouts = this.promptTimeouts.withMaxPromptDuration(maxDuration); return this; } /** - * Sets the handler for new session requests. - * @param handler The new session handler - * @return This builder for chaining + * Sets the handler for {@code initialize}, the client's first request: it carries the + * protocol version and the client's capabilities, and the answer carries the agent's. + * {@link AcpSchema.InitializeResponse#ok()} is the simplest answer. The client's + * capabilities are recorded before the handler runs, so {@code getClientCapabilities()} on + * the agent already returns them inside it. + * @param handler the handler; must not be null + * @return this builder */ - public AsyncAgentBuilder newSessionHandler(NewSessionHandler handler) { - this.newSessionHandler = handler; - return this; + public AsyncAgentBuilder initializeHandler(InitializeHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_INITIALIZE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for load session requests. - * @param handler The load session handler - * @return This builder for chaining + * Sets the handler for {@code authenticate}: the client logs in with one of the + * authentication methods the initialize answer listed. To refuse, fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#AUTHENTICATION_REQUIRED}. + * @param handler the handler; must not be null + * @return this builder */ - public AsyncAgentBuilder loadSessionHandler(LoadSessionHandler handler) { - this.loadSessionHandler = handler; - return this; + public AsyncAgentBuilder authenticateHandler(AuthenticateHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_AUTHENTICATE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for prompt requests. - * @param handler The prompt handler - * @return This builder for chaining + * Sets the handler for {@code logout}, which ends the client's authenticated state. + * Clients send {@code logout} only to an agent that advertises {@code auth.logout}; the + * default {@code initialize} answer advertises it, and an initialize handler must + * advertise it itself ({@code AgentAuthCapabilities.withLogout()}). + * @param handler the handler; must not be null + * @return this builder */ - public AsyncAgentBuilder promptHandler(PromptHandler handler) { - this.promptHandler = handler; - return this; + public AsyncAgentBuilder logoutHandler(LogoutHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_LOGOUT, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for set session mode requests. - * @param handler The set session mode handler - * @return This builder for chaining + * Sets the handler for {@code session/new}, which creates an ACP session for a working + * directory. The answer carries the new {@code sessionId}, and optionally the session's + * modes and config options. Without this handler the agent answers {@code session/new} + * with a random UUID as the session ID and no modes or config options. + * @param handler the handler; must not be null + * @return this builder */ - public AsyncAgentBuilder setSessionModeHandler(SetSessionModeHandler handler) { - this.setSessionModeHandler = handler; - return this; + public AsyncAgentBuilder newSessionHandler(NewSessionHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_NEW, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for set session model requests. - * @param handler The set session model handler - * @return This builder for chaining - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@code session/set_config_option} with a {@code "model"} category - * config option instead. Slated for removal. + * Sets the handler for {@code session/load}, which reopens a session the agent kept: the + * agent replays the conversation to the client as session updates, then answers. Clients + * send {@code session/load} only to an agent that advertises the {@code loadSession} + * capability; the default {@code initialize} answer advertises it, and an initialize + * handler must advertise it itself. + * @param handler the handler; must not be null + * @return this builder */ - @Deprecated(forRemoval = true) - public AsyncAgentBuilder setSessionModelHandler(SetSessionModelHandler handler) { - this.setSessionModelHandler = handler; - return this; + public AsyncAgentBuilder loadSessionHandler(LoadSessionHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_LOAD, new TypeRef() { + }, (request, agent) -> handler.handle(request)); + } + + /** + * Sets the handler for {@code session/prompt}: one prompt turn. The handler receives the + * request and a {@link PromptContext} for sending updates and calling the client, and + * answers with a {@link AcpSchema.PromptResponse} whose stop reason ends the turn. The SDK + * keeps one turn per session at a time (a second prompt for a busy session is answered + * {@code -32600}), and applies the cancel grace period and the maximum prompt duration. + * @param handler the handler; must not be null + * @return this builder + */ + public AsyncAgentBuilder promptHandler(PromptHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_PROMPT, new TypeRef() { + }, (request, agent) -> handler.handle(request, new DefaultPromptContext(agent, request.sessionId()))); } /** - * Sets the handler for list sessions requests. - * @param handler The list sessions handler - * @return This builder for chaining + * Sets the handler for {@code session/set_mode}, which switches a session to one of the + * modes the agent offered. + * @param handler the handler; must not be null + * @return this builder + */ + public AsyncAgentBuilder setSessionModeHandler(SetSessionModeHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_SET_MODE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); + } + + /** + * Sets the handler for {@code session/list}, which lists the sessions the agent knows, + * optionally only those of one working directory, a page at a time. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder listSessionsHandler(ListSessionsHandler handler) { - this.listSessionsHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_LIST, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for close session requests. - * @param handler The close session handler - * @return This builder for chaining + * Sets the handler for {@code session/close}, which ends an active session and frees what + * it holds. The session first cancels its work as for {@code session/cancel} (the cancel + * handler is called and a running prompt answers {@code cancelled}), then calls this + * handler. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder closeSessionHandler(CloseSessionHandler handler) { - this.closeSessionHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_CLOSE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for delete session requests. - * @param handler The delete session handler - * @return This builder for chaining + * Sets the handler for {@code session/delete}, which removes a stored session so that it no + * longer appears in {@code session/list}. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder deleteSessionHandler(DeleteSessionHandler handler) { - this.deleteSessionHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_DELETE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for resume session requests. - * @param handler The resume session handler - * @return This builder for chaining + * Sets the handler for {@code session/resume}, which reopens a session without replaying + * its history to the client. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder resumeSessionHandler(ResumeSessionHandler handler) { - this.resumeSessionHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_RESUME, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for fork session requests. - * @param handler The fork session handler - * @return This builder for chaining + * Sets the handler for {@code session/fork}, which creates a new session branched from an + * existing one. + * @param handler the handler; must not be null + * @return this builder */ + @UnstableAcpApi public AsyncAgentBuilder forkSessionHandler(ForkSessionHandler handler) { - this.forkSessionHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_FORK, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for set config option requests. - * @param handler The set config option handler - * @return This builder for chaining + * Sets the handler for {@code session/set_config_option}, which changes one of a session's + * config options. Answer with the full list of the session's options, not only the changed + * one. The request's value is a {@code String} for a select option and a {@code Boolean} + * for a boolean option, and the SDK does not check it against the options offered: answer + * an unknown option or value with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder setSessionConfigOptionHandler(SetSessionConfigOptionHandler handler) { - this.setSessionConfigOptionHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for {@code providers/list} requests (UNSTABLE). - * @param handler The list providers handler - * @return This builder for chaining + * Sets the handler for {@code providers/list}, which lists the providers the agent can + * route to. + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public AsyncAgentBuilder listProvidersHandler(ListProvidersHandler handler) { - this.listProvidersHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_LIST, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for {@code providers/set} requests (UNSTABLE). - * @param handler The set provider handler - * @return This builder for chaining + * Sets the handler for {@code providers/set}, which configures how the agent reaches a + * provider (protocol, base URL, headers). + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public AsyncAgentBuilder setProviderHandler(SetProviderHandler handler) { - this.setProviderHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_SET, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for {@code providers/disable} requests (UNSTABLE). - * @param handler The disable provider handler - * @return This builder for chaining + * Sets the handler for {@code providers/disable}, which disables a provider by ID. + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public AsyncAgentBuilder disableProviderHandler(DisableProviderHandler handler) { - this.disableProviderHandler = handler; - return this; + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_DISABLE, new TypeRef() { + }, (request, agent) -> handler.handle(request)); } /** - * Sets the handler for cancel notifications. - * @param handler The cancel handler - * @return This builder for chaining + * Sets the handler for {@code session/cancel}, the client's notification that it wants a + * session's prompt turn to stop. The SDK marks the turn as cancelling before the handler + * runs. The handler should make the prompt's work stop; the prompt handler then answers + * with stop reason {@code cancelled}, which ends the turn. A notification gets no answer, + * so a handler that fails is only logged. + * @param handler the handler; must not be null + * @return this builder */ public AsyncAgentBuilder cancelHandler(CancelHandler handler) { - this.cancelHandler = handler; + Assert.notNull(handler, "Handler must not be null"); + handlers.notification(AcpSchema.METHOD_SESSION_CANCEL, new TypeRef() { + }, (notification, agent) -> handler.handle(notification)); + return this; + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the client, its params read as the given type. A request for an + * extension method without a handler is answered with "Method not found" ({@code -32601}). + * The handler's {@code Mono} emits the result, any value the JSON mapper can write; an + * empty {@code Mono} answers the request with an internal error ({@code -32603}). Answer + * with an empty map when there is nothing to return. + * @param the params type + * @param method the method name, which must start with {@code _} (for example + * {@code _example.com/workspace/buffers}) + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public AsyncAgentBuilder extRequestHandler(String method, TypeRef paramsType, + ExtRequestHandler handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return request(method, paramsType, (params, agent) -> handler.handle(params)); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the client, its params delivered as the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}). + * @param method the method name, which must start with {@code _} + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, ExtRequestHandler) + */ + public AsyncAgentBuilder extRequestHandler(String method, ExtRequestHandler handler) { + return extRequestHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the client, its params read as the given type. An extension notification + * without a handler is ignored, as the protocol asks, and a handler that fails is only + * logged. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public AsyncAgentBuilder extNotificationHandler(String method, TypeRef paramsType, + ExtNotificationHandler handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return notification(method, paramsType, (params, agent) -> handler.handle(params)); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the client, its params delivered as the raw JSON value. + * @param method the method name, which must start with {@code _} + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, ExtNotificationHandler) + */ + public AsyncAgentBuilder extNotificationHandler(String method, ExtNotificationHandler handler) { + return extNotificationHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + + @Override + AsyncAgentBuilder request(String method, TypeRef requestType, + AgentHandlers.RequestHandler handler) { + handlers.request(method, requestType, handler); + return this; + } + + @Override + AsyncAgentBuilder notification(String method, TypeRef notificationType, + AgentHandlers.NotificationHandler handler) { + handlers.notification(method, notificationType, handler); return this; } /** - * Builds the asynchronous ACP agent. - * @return A new AcpAsyncAgent instance + * Builds the agent on the builder's transport, with the handlers registered so far; + * handlers registered afterwards do not reach it. The agent does nothing until + * {@link AcpAsyncAgent#start()}. A transport serves one agent, so build once per transport. + * Without an initialize handler, the agent answers {@code initialize} with the protocol + * version negotiated with the client and the capabilities its handlers imply + * ({@code loadSessionHandler} advertises {@code loadSession}, {@code listSessionsHandler} + * {@code sessionCapabilities.list}, {@code logoutHandler} {@code auth.logout}, and so on). + * Without a new-session handler, the agent answers {@code session/new} with a random UUID + * as the session ID ({@link AcpSchema.NewSessionResponse#withGeneratedId()}), since ACP + * requires every agent to support it. + * @return the new agent + * @throws IllegalStateException if no prompt handler is registered */ public AcpAsyncAgent build() { - return new DefaultAcpAsyncAgent(transport, requestTimeout, initializeHandler, authenticateHandler, - logoutHandler, newSessionHandler, loadSessionHandler, promptHandler, setSessionModeHandler, - setSessionModelHandler, listSessionsHandler, closeSessionHandler, deleteSessionHandler, - resumeSessionHandler, forkSessionHandler, setSessionConfigOptionHandler, listProvidersHandler, - setProviderHandler, disableProviderHandler, cancelHandler); + java.util.Set methods = handlers.requestMethods(); + if (!methods.contains(AcpSchema.METHOD_SESSION_PROMPT)) { + throw new IllegalStateException("An agent needs a prompt handler: call promptHandler(..) before build()"); + } + AgentHandlers built = handlers.copy(); + if (!methods.contains(AcpSchema.METHOD_INITIALIZE)) { + built.request(AcpSchema.METHOD_INITIALIZE, new TypeRef() { + }, (request, agent) -> Mono.just(DefaultInitialize.respond(methods, agentInfo, request))); + } + if (!methods.contains(AcpSchema.METHOD_SESSION_NEW)) { + // Every agent must support session/new (ACP v1, initialization, baseline). + built.request(AcpSchema.METHOD_SESSION_NEW, new TypeRef() { + }, (request, agent) -> Mono.fromSupplier(AcpSchema.NewSessionResponse::withGeneratedId)); + } + return new DefaultAcpAsyncAgent(transport, requestTimeout, promptTimeouts, built); } } /** - * Builder for creating synchronous ACP agents. - *

- * This builder accepts synchronous handler interfaces that return plain values - * instead of Mono. Internally, handlers are converted to async handlers using - * the MCP SDK pattern (Mono.fromCallable + boundedElastic scheduler). - *

- * - *

Example usage:

+ * Configures and builds an {@link AcpSyncAgent}: one handler for each ACP method the agent + * serves, each returning a plain value, plus the request timeout and the prompt timeouts. Get + * one from {@link AcpAgent#sync(AcpAgentTransport)}. Use it when handler code blocks, for + * example on file or network I/O, or on {@link SyncPromptContext} calls to the client. + * + *

Every handler runs on the builder's handler executor ({@link #handlerExecutor}), by + * default a virtual thread per call on JDK 21 and later (before, a pool of daemon threads + * shared by the synchronous agents in the JVM), not on the transport's thread, so it may block. Handlers for different requests run at the same time on + * different threads, so state they share must be thread-safe. The builder turns each handler + * into its asynchronous counterpart on an {@link AsyncAgentBuilder} and builds the agent from + * it, so the rules described there apply: a null handler or a second handler for a method fails + * at the setter, and a handler that throws is answered with an error. A request handler that + * returns {@code null} is answered {@code -32603} (internal error). A builder is not + * thread-safe; configure it on one thread. + * *

{@code
 	 * AcpSyncAgent agent = AcpAgent.sync(transport)
-	 *     .initializeHandler(req -> new InitializeResponse(1, capabilities, List.of()))
-	 *     .newSessionHandler(req -> new NewSessionResponse(sessionId, null, null))
-	 *     .promptHandler((req, updater) -> {
-	 *         updater.sendUpdate(sessionId, thought);  // blocks, void return
-	 *         updater.sendUpdate(sessionId, message);  // blocks, void return
-	 *         return new PromptResponse(StopReason.END_TURN);  // plain return
+	 *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+	 *     .newSessionHandler(request -> new AcpSchema.NewSessionResponse(
+	 *         UUID.randomUUID().toString(), null, null))
+	 *     .promptHandler((request, context) -> {
+	 *         context.sendThought("Thinking...");   // blocks until handed to the transport
+	 *         context.sendMessage("Done");
+	 *         return AcpSchema.PromptResponse.endTurn();
 	 *     })
 	 *     .build();
 	 * }
*/ - class SyncAgentBuilder { + final class SyncAgentBuilder extends SyncAgentAwareSetters { private final AsyncAgentBuilder asyncBuilder; + /** Where the handlers run; read when a handler is called. */ + private Scheduler handlerScheduler = SyncHandlerScheduler.DEFAULT; + SyncAgentBuilder(AcpAgentTransport transport) { this.asyncBuilder = new AsyncAgentBuilder(transport); } /** - * Sets the timeout for requests sent to the client. - * @param timeout The request timeout duration - * @return This builder for chaining + * Sets the executor the handlers run on, for example + * {@code Executors.newVirtualThreadPerTaskExecutor()} or a framework's worker pool. Without + * it each handler call runs on a virtual thread of its own on JDK 21 and later, and before + * that on a pool of daemon threads, shared by every synchronous agent in the JVM, which has + * no size limit: each handler running at the same time takes a thread of its own. Either + * way the threads are named {@code acp-agent-sync-handler}. On JDK 21 to 23 a virtual + * thread that blocks inside a {@code synchronized} block pins its carrier thread (JDK 24 + * removed that, JEP 491); handlers that do so heavily may want a pool of platform threads + * here. The executor must allow blocking. + * The SDK cancels a handler by interrupting its thread (cancelling the task submitted to + * the executor), and never shuts the executor down; that is the application's job. + * @param executor the executor the handlers run on + * @return this builder + * @throws IllegalArgumentException if {@code executor} is null + */ + public SyncAgentBuilder handlerExecutor(ExecutorService executor) { + Assert.notNull(executor, "Executor must not be null"); + this.handlerScheduler = Schedulers.fromExecutorService(executor, "acp-agent-handlers"); + return this; + } + + /** + * Sets how long the agent waits for the client to answer a request the agent sent: a file + * read or write, a permission or elicitation request, a terminal call or an extension + * request. When it passes, the blocking call throws an + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}, whose cause is the + * {@link java.util.concurrent.TimeoutException}, and the agent sends the client a + * {@code $/cancel_request}. Default: 60 seconds. + * It does not limit the agent's own handlers; for prompts, see {@code maxPromptDuration}. + * @param timeout the timeout + * @return this builder + * @throws IllegalArgumentException if {@code timeout} is null */ public SyncAgentBuilder requestTimeout(Duration timeout) { asyncBuilder.requestTimeout(timeout); @@ -874,343 +1701,393 @@ public SyncAgentBuilder requestTimeout(Duration timeout) { } /** - * Sets the synchronous handler for initialize requests. - * @param handler The sync initialize handler (returns plain value) - * @return This builder for chaining + * Sets how long a prompt handler has to answer after {@code session/cancel}. When it + * passes, the agent cancels the handler, which interrupts its thread if it is blocked (updates + * it sends through its prompt context if it keeps running are dropped), and answers the + * prompt itself with stop reason + * {@code cancelled}, as ACP requires of a cancelled prompt; that ends the turn, so the + * session accepts a new prompt. Updates the handler sent before that answer reach the + * client first. Default: 60 seconds ({@link PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD}); + * {@link Duration#ZERO} turns it off, and a handler that never answers then keeps its + * session busy. This limit is Java SDK policy; ACP defines none. + * @param gracePeriod the grace period; zero for none + * @return this builder + * @throws IllegalArgumentException if {@code gracePeriod} is null or negative + */ + public SyncAgentBuilder cancelGracePeriod(Duration gracePeriod) { + asyncBuilder.cancelGracePeriod(gracePeriod); + return this; + } + + /** + * Sets the agent's name and version, sent as {@code agentInfo} in the {@code initialize} + * response the agent answers when no initialize handler is registered. An initialize + * handler sets {@code agentInfo} in its own response. + * @param agentInfo the agent's name, version and optional title + * @return this builder + */ + public SyncAgentBuilder agentInfo(AcpSchema.Implementation agentInfo) { + asyncBuilder.agentInfo(agentInfo); + return this; + } + + /** + * Sets how long a prompt may run. When it passes, the agent cancels the handler and answers + * the prompt with JSON-RPC error {@code -32800} (request cancelled; ACP answers an + * internally cancelled request, an internal timeout included, with this code), or with stop + * reason {@code cancelled} if the client had cancelled the prompt, and the turn ends. + * Default: none ({@link Duration#ZERO}), since a prompt turn can legitimately run for a + * long time. This limit is Java SDK policy; ACP defines none. + * @param maxDuration the maximum prompt duration; zero for none + * @return this builder + * @throws IllegalArgumentException if {@code maxDuration} is null or negative + */ + public SyncAgentBuilder maxPromptDuration(Duration maxDuration) { + asyncBuilder.maxPromptDuration(maxDuration); + return this; + } + + /** + * Sets the handler for {@code initialize}, the client's first request: it carries the + * protocol version and the client's capabilities, and the answer carries the agent's. + * {@link AcpSchema.InitializeResponse#ok()} is the simplest answer. The client's + * capabilities are recorded before the handler runs, so {@code getClientCapabilities()} on + * the agent already returns them inside it. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder initializeHandler(SyncInitializeHandler handler) { - asyncBuilder.initializeHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.initializeHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for authenticate requests. - * @param handler The sync authenticate handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code authenticate}: the client logs in with one of the + * authentication methods the initialize answer listed. To refuse, fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#AUTHENTICATION_REQUIRED}. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder authenticateHandler(SyncAuthenticateHandler handler) { - asyncBuilder.authenticateHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.authenticateHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for logout requests. - * @param handler The sync logout handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code logout}, which ends the client's authenticated state. + * Clients send {@code logout} only to an agent that advertises {@code auth.logout}; the + * default {@code initialize} answer advertises it, and an initialize handler must + * advertise it itself ({@code AgentAuthCapabilities.withLogout()}). + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder logoutHandler(SyncLogoutHandler handler) { - asyncBuilder.logoutHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.logoutHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for new session requests. - * @param handler The sync new session handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/new}, which creates an ACP session for a working + * directory. The answer carries the new {@code sessionId}, and optionally the session's + * modes and config options. Without this handler the agent answers {@code session/new} + * with a random UUID as the session ID and no modes or config options. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder newSessionHandler(SyncNewSessionHandler handler) { - asyncBuilder.newSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.newSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for load session requests. - * @param handler The sync load session handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/load}, which reopens a session the agent kept: the + * agent replays the conversation to the client as session updates, then answers. Clients + * send {@code session/load} only to an agent that advertises the {@code loadSession} + * capability; the default {@code initialize} answer advertises it, and an initialize + * handler must advertise it itself. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder loadSessionHandler(SyncLoadSessionHandler handler) { - asyncBuilder.loadSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.loadSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for prompt requests. - * @param handler The sync prompt handler (returns plain value, receives SyncPromptContext) - * @return This builder for chaining + * Sets the handler for {@code session/prompt}: one prompt turn. The handler receives the + * request and a {@link SyncPromptContext} for sending updates and calling the client, may + * block, and returns a {@link AcpSchema.PromptResponse} whose stop reason ends the turn. + * The SDK keeps one turn per session at a time (a second prompt for a busy session is + * answered {@code -32600}), and applies the cancel grace period and the maximum prompt + * duration. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder promptHandler(SyncPromptHandler handler) { - asyncBuilder.promptHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.promptHandler((request, context) -> onSyncHandlerThread( + () -> handler.handle(request, new DefaultSyncPromptContext(context)))); return this; } /** - * Sets the synchronous handler for set session mode requests. - * @param handler The sync set session mode handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/set_mode}, which switches a session to one of the + * modes the agent offered. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder setSessionModeHandler(SyncSetSessionModeHandler handler) { - asyncBuilder.setSessionModeHandler(fromSync(handler)); - return this; - } - - /** - * Sets the synchronous handler for set session model requests. - * @param handler The sync set session model handler (returns plain value) - * @return This builder for chaining - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@code session/set_config_option} with a {@code "model"} category - * config option instead. Slated for removal. - */ - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - public SyncAgentBuilder setSessionModelHandler(SyncSetSessionModelHandler handler) { - asyncBuilder.setSessionModelHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.setSessionModeHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for list sessions requests. - * @param handler The sync list sessions handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/list}, which lists the sessions the agent knows, + * optionally only those of one working directory, a page at a time. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder listSessionsHandler(SyncListSessionsHandler handler) { - asyncBuilder.listSessionsHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.listSessionsHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for close session requests. - * @param handler The sync close session handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/close}, which ends an active session and frees what + * it holds. The session first cancels its work as for {@code session/cancel} (the cancel + * handler is called and a running prompt answers {@code cancelled}), then calls this + * handler. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder closeSessionHandler(SyncCloseSessionHandler handler) { - asyncBuilder.closeSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.closeSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for delete session requests. - * @param handler The sync delete session handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/delete}, which removes a stored session so that it no + * longer appears in {@code session/list}. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder deleteSessionHandler(SyncDeleteSessionHandler handler) { - asyncBuilder.deleteSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.deleteSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for resume session requests. - * @param handler The sync resume session handler (returns plain value) - * @return This builder for chaining + * Sets the handler for {@code session/resume}, which reopens a session without replaying + * its history to the client. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder resumeSessionHandler(SyncResumeSessionHandler handler) { - asyncBuilder.resumeSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.resumeSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } + /** + * Sets the handler for {@code session/fork}, which creates a new session branched from an + * existing one. + * @param handler the handler; must not be null + * @return this builder + */ + @UnstableAcpApi public SyncAgentBuilder forkSessionHandler(SyncForkSessionHandler handler) { - asyncBuilder.forkSessionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.forkSessionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } + /** + * Sets the handler for {@code session/set_config_option}, which changes one of a session's + * config options. Answer with the full list of the session's options, not only the changed + * one. The request's value is a {@code String} for a select option and a {@code Boolean} + * for a boolean option, and the SDK does not check it against the options offered: answer + * an unknown option or value with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} carrying + * {@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}. + * @param handler the handler; must not be null + * @return this builder + */ public SyncAgentBuilder setSessionConfigOptionHandler(SyncSetSessionConfigOptionHandler handler) { - asyncBuilder.setSessionConfigOptionHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.setSessionConfigOptionHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for {@code providers/list} requests (UNSTABLE). - * @param handler The sync list providers handler - * @return This builder for chaining + * Sets the handler for {@code providers/list}, which lists the providers the agent can + * route to. + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public SyncAgentBuilder listProvidersHandler(SyncListProvidersHandler handler) { - asyncBuilder.listProvidersHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.listProvidersHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for {@code providers/set} requests (UNSTABLE). - * @param handler The sync set provider handler - * @return This builder for chaining + * Sets the handler for {@code providers/set}, which configures how the agent reaches a + * provider (protocol, base URL, headers). + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public SyncAgentBuilder setProviderHandler(SyncSetProviderHandler handler) { - asyncBuilder.setProviderHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.setProviderHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for {@code providers/disable} requests (UNSTABLE). - * @param handler The sync disable provider handler - * @return This builder for chaining + * Sets the handler for {@code providers/disable}, which disables a provider by ID. + * @param handler the handler; must not be null + * @return this builder */ @UnstableAcpApi public SyncAgentBuilder disableProviderHandler(SyncDisableProviderHandler handler) { - asyncBuilder.disableProviderHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.disableProviderHandler(request -> onSyncHandlerThread(() -> handler.handle(request))); return this; } /** - * Sets the synchronous handler for cancel notifications. - * @param handler The sync cancel handler (returns void) - * @return This builder for chaining + * Sets the handler for {@code session/cancel}, the client's notification that it wants a + * session's prompt turn to stop. The SDK marks the turn as cancelling before the handler + * runs. The handler runs on its own thread while the prompt handler is still running, so + * tell the prompt handler to stop through something thread-safe, such as an + * {@code AtomicBoolean}; the prompt handler then returns stop reason {@code cancelled}, + * which ends the turn. A handler that throws is only logged. + * @param handler the handler; must not be null + * @return this builder */ public SyncAgentBuilder cancelHandler(SyncCancelHandler handler) { - asyncBuilder.cancelHandler(fromSync(handler)); + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.cancelHandler(notification -> Mono.fromRunnable(HandlerFailures.guard(() -> handler.handle(notification))) + .subscribeOn(this.handlerScheduler)); return this; } /** - * Builds the synchronous ACP agent. - * @return A new AcpSyncAgent instance + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the client, its params read as the given type. A request for an + * extension method without a handler is answered with "Method not found" ({@code -32601}). + * The handler returns the result, any value the JSON mapper can write; returning + * {@code null} answers the request with an internal error ({@code -32603}). Return an empty + * map when there is nothing to return. + * @param the params type + * @param method the method name, which must start with {@code _} (for example + * {@code _example.com/workspace/buffers}) + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AsyncAgentBuilder#extRequestHandler(String, TypeRef, ExtRequestHandler) */ - public AcpSyncAgent build() { - return new AcpSyncAgent(asyncBuilder.build()); - } - - // ======================================================================== - // fromSync() conversion methods - following MCP SDK pattern - // Wraps sync handlers in Mono.fromCallable() with library-owned daemon scheduler - // ======================================================================== - - private static InitializeHandler fromSync(SyncInitializeHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static AuthenticateHandler fromSync(SyncAuthenticateHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static LogoutHandler fromSync(SyncLogoutHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static NewSessionHandler fromSync(SyncNewSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static LoadSessionHandler fromSync(SyncLoadSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static PromptHandler fromSync(SyncPromptHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return (request, asyncContext) -> Mono.fromCallable(() -> { - // Create a blocking wrapper around the async PromptContext - SyncPromptContext syncContext = new DefaultSyncPromptContext(asyncContext); - return syncHandler.handle(request, syncContext); - }).subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static SetSessionModeHandler fromSync(SyncSetSessionModeHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - private static SetSessionModelHandler fromSync(SyncSetSessionModelHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static ListSessionsHandler fromSync(SyncListSessionsHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static CloseSessionHandler fromSync(SyncCloseSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); - } - - private static DeleteSessionHandler fromSync(SyncDeleteSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + public SyncAgentBuilder extRequestHandler(String method, TypeRef paramsType, + SyncExtRequestHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.extRequestHandler(method, paramsType, + params -> onSyncHandlerThread(() -> handler.handle(params))); + return this; } - private static ResumeSessionHandler fromSync(SyncResumeSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the client, its params delivered as the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}). + * @param method the method name, which must start with {@code _} + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, SyncExtRequestHandler) + */ + public SyncAgentBuilder extRequestHandler(String method, SyncExtRequestHandler handler) { + return extRequestHandler(method, AgentHandlers.RAW_PARAMS, handler); } - private static ForkSessionHandler fromSync(SyncForkSessionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the client, its params read as the given type. An extension notification + * without a handler is ignored, as the protocol asks, and a handler that fails is only + * logged. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AsyncAgentBuilder#extNotificationHandler(String, TypeRef, ExtNotificationHandler) + */ + public SyncAgentBuilder extNotificationHandler(String method, TypeRef paramsType, + SyncExtNotificationHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder.extNotificationHandler(method, paramsType, + params -> runOnSyncHandlerThread(() -> handler.handle(params))); + return this; } - private static SetSessionConfigOptionHandler fromSync(SyncSetSessionConfigOptionHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the client, its params delivered as the raw JSON value. + * @param method the method name, which must start with {@code _} + * @param handler the handler; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, SyncExtNotificationHandler) + */ + public SyncAgentBuilder extNotificationHandler(String method, SyncExtNotificationHandler handler) { + return extNotificationHandler(method, AgentHandlers.RAW_PARAMS, handler); } - private static ListProvidersHandler fromSync(SyncListProvidersHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + /** + * Builds the agent on the builder's transport, with the handlers registered so far; + * handlers registered afterwards do not reach it. The agent does nothing until + * {@link AcpSyncAgent#start()} or {@link AcpSyncAgent#run()}. A transport serves one agent, + * so build once per transport. Without an initialize handler, the agent answers + * {@code initialize} with the capabilities its handlers imply, and without a new-session + * handler it answers {@code session/new} with a fresh session ID (see + * {@link AsyncAgentBuilder#build()}). + * @return the new agent + * @throws IllegalStateException if no prompt handler is registered + */ + public AcpSyncAgent build() { + return remember(new AcpSyncAgent(asyncBuilder.build())); } - private static SetProviderHandler fromSync(SyncSetProviderHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + @Override + AsyncAgentBuilder asyncBuilder() { + return asyncBuilder; } - private static DisableProviderHandler fromSync(SyncDisableProviderHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return request -> Mono.fromCallable(() -> syncHandler.handle(request)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + /** + * Runs a sync handler on the handler executor, so it may block (on + * {@link SyncPromptContext} calls back to the client, for one) without stalling the + * transport. + */ + @Override + Mono onSyncHandlerThread(Callable handler) { + return Mono.fromCallable(HandlerFailures.guard(handler)).subscribeOn(this.handlerScheduler); } - private static CancelHandler fromSync(SyncCancelHandler syncHandler) { - if (syncHandler == null) { - return null; - } - return notification -> Mono.fromRunnable(() -> syncHandler.handle(notification)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + @Override + Mono runOnSyncHandlerThread(Runnable handler) { + return Mono.fromRunnable(HandlerFailures.guard(handler)).subscribeOn(this.handlerScheduler); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java index ec05b061..8ede1b0b 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAgentFactory.java @@ -10,14 +10,29 @@ import com.agentclientprotocol.sdk.util.Assert; /** - * Factory for creating one ACP agent runtime for one agent-side transport. + * Builds one agent for each connection a listener transport accepts. Give it to + * {@code StreamableHttpAcpAgentTransport} or {@code StreamableHttpAcpServlet} (module + * {@code acp-streamable-http-jetty}), which serve many clients at once: each new connection gets + * its own transport and calls {@link #create(AcpAgentTransport)} for an agent bound to it. A stdio + * agent serves a single connection and needs no factory. * - *

- * Listener-backed transports such as remote HTTP transports accept multiple client - * connections over their lifetime. Each accepted connection needs its own - * connection-bound agent runtime while reusing the same agent definition. This factory - * is the explicit public seam for that relationship. - *

+ *

Make one from an {@link AcpAgent} builder call with {@link #sync(Function)} or + * {@link #async(Function)}. For an annotated agent, {@code AcpAgentSupport.Builder.buildFactory()} + * (module {@code acp-agent-support}) returns one. + * + *

{@code
+ * AcpAgentFactory agents = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport)
+ *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+ *     .newSessionHandler(request -> new AcpSchema.NewSessionResponse(
+ *         UUID.randomUUID().toString(), null, null))
+ *     .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn())
+ *     .build());
+ * }
+ * + *

Implementations return a new agent built on the given transport and leave it unstarted: the + * connection starts it, and closes it when the connection closes. Connections can open at the same + * time, so {@code create} may be called from several threads at once, and anything the agents share + * must be thread-safe. * * @author Kaiser Dandangi */ @@ -25,16 +40,18 @@ public interface AcpAgentFactory { /** - * Creates a new asynchronous agent runtime for the supplied transport. - * @param transport per-connection transport - * @return a fresh asynchronous agent runtime + * Creates the agent for one connection. + * @param transport the connection's transport; build the agent on this one + * @return a new agent, not started */ AcpAsyncAgent create(AcpAgentTransport transport); /** - * Creates a factory from an asynchronous agent builder function. - * @param factory function that creates a fresh asynchronous agent per transport - * @return an agent factory + * Returns a factory that calls the given function for each connection. + * @param factory builds a new agent on the transport it is given, typically + * {@code transport -> AcpAgent.async(transport)...build()} + * @return the factory + * @throws IllegalArgumentException if {@code factory} is null */ static AcpAgentFactory async(Function factory) { Assert.notNull(factory, "The async factory can not be null"); @@ -42,15 +59,14 @@ static AcpAgentFactory async(Function factory) } /** - * Creates a factory from a synchronous agent builder function. - * - *

- * Synchronous agents are wrappers around asynchronous agents in this SDK, so the - * transport seam remains asynchronous underneath while callers may still author - * agents with the blocking API. - *

- * @param factory function that creates a fresh synchronous agent per transport - * @return an agent factory + * Returns a factory that calls the given function for each connection and hands the connection + * the asynchronous agent behind the synchronous one ({@link AcpSyncAgent#async()}). The + * handlers still run as the synchronous builder runs them, on its handler executor + * ({@link AcpAgent.SyncAgentBuilder#handlerExecutor}). + * @param factory builds a new agent on the transport it is given, typically + * {@code transport -> AcpAgent.sync(transport)...build()} + * @return the factory + * @throws IllegalArgumentException if {@code factory} is null */ static AcpAgentFactory sync(Function factory) { Assert.notNull(factory, "The sync factory can not be null"); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAsyncAgent.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAsyncAgent.java index f24b9586..560b72ac 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAsyncAgent.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpAsyncAgent.java @@ -5,21 +5,42 @@ package com.agentclientprotocol.sdk.agent; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; /** - * Asynchronous ACP agent interface providing non-blocking operations for - * handling client requests and sending notifications. + * A running ACP agent with a Reactor API: it answers the client with the handlers registered on + * {@link AcpAgent.AsyncAgentBuilder}, and it sends the client requests and notifications (session + * updates, files, terminals, permission and elicitation requests, extension methods). Get one from + * {@link AcpAgent#async(AcpAgentTransport)}, or from {@link AcpSyncAgent#async()}. Use + * {@link AcpSyncAgent} for the same calls as blocking methods. * - *

- * The agent is the server-side component in the ACP protocol, responsible for: - *

    - *
  • Responding to client initialization and authentication requests
  • - *
  • Managing sessions and processing prompts
  • - *
  • Sending session update notifications during processing
  • - *
  • Requesting client capabilities (file system, terminal, permissions)
  • - *
+ *

Inside a prompt handler, use the {@link PromptContext} it receives: it makes the same calls + * for the prompt's session. Use the agent itself outside a prompt turn, for example to send a + * {@code ConfigOptionUpdate} after a {@code session/set_config_option}; a builder handler receives + * it as a second parameter ({@link AgentAwareHandler}). + * + *

{@link #start()} starts the transport, and the agent answers from then on; + * {@link #awaitTermination()} completes when the transport has ended; {@link #closeGracefully()} + * and {@link #close()} shut the agent down. An agent serves one transport, and a transport can be + * started once. + * + *

Calls to the client return a {@code Mono} and send nothing until it is subscribed. Before + * {@link #start()} they fail with {@link IllegalStateException}. Once the client's + * {@code initialize} request has arrived, a call that needs a capability the client did not + * advertise (reading or writing files, any terminal method, an elicitation mode) fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent; before it, + * nothing is checked. An error answer from the client fails the {@code Mono} with + * {@link com.agentclientprotocol.sdk.spec.AcpError}. If the client does not answer within the + * builder's request timeout (60 seconds by default), the {@code Mono} fails with a + * {@link java.util.concurrent.TimeoutException} and the agent sends the client a + * {@code $/cancel_request}; disposing the {@code Mono} before the answer sends one too. Methods may + * be called from several threads at once. + * + *

Implementations: the SDK supplies the implementation the builder returns; implement this + * interface only for test doubles. * * @author Mark Pollack * @see AcpAgent @@ -28,125 +49,202 @@ public interface AcpAsyncAgent { /** - * Starts the agent, beginning to accept client connections. - * @return A Mono that completes when the agent is started + * Starts the agent: it creates the agent's protocol session, which starts the transport, and + * from then on the agent answers the client. The {@code Mono} does not wait for the client to + * connect or send anything. + * @return a {@code Mono} that completes once the transport has been started; it fails with + * {@link IllegalStateException} if the transport refuses to start, for example because it was + * started before */ Mono start(); /** - * Returns a Mono that completes when the agent terminates. - * This is useful for blocking until the transport closes, particularly - * when using daemon threads. + * Returns a {@code Mono} that completes when the agent's transport has ended. Block on it to + * keep a process alive while the transport's daemon threads do the work. + * + *

On stdio, the transport ends once the client has closed the agent's input and every + * request received before has been answered, bounded by the transport's drain timeout (see + * {@link com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport}), so exiting then + * loses no reply. * - *

Example usage: *

{@code
 	 * agent.start().block();
-	 * agent.awaitTermination().block(); // Block until transport closes
+	 * agent.awaitTermination().block();
 	 * }
* - * @return A Mono that completes when the agent terminates + * @return a {@code Mono} that completes when the transport has ended */ Mono awaitTermination(); /** - * Returns the capabilities negotiated with the client during initialization. - * - *

- * This method returns null if initialization has not been completed yet. - * Use this to check what features the client supports before calling - * methods like {@link #readTextFile} or {@link #createTerminal}. - *

- * @return the negotiated client capabilities, or null if not initialized + * Returns what the client advertised in its {@code initialize} request. Check it before a call + * that needs a capability, for example {@code supportsReadTextFile()} before + * {@link #readTextFile}. It is recorded when the request arrives, before the initialize handler + * runs, so the handler can already read it. + * @return the client's capabilities, or {@code null} if the client has not initialized */ - NegotiatedCapabilities getClientCapabilities(); + @Nullable NegotiatedCapabilities getClientCapabilities(); /** - * Sends a session update notification to the client. - * Used for streaming updates during prompt processing. - * @param sessionId The session ID - * @param update The session update to send - * @return A Mono that completes when the notification is sent + * Sends a {@code session/update} notification to the client, carrying one + * {@link AcpSchema.SessionUpdate}: a message or thought chunk, a tool call, a plan, a config + * option change and so on. During a prompt turn the Java client hands the updates to its + * update handlers in order, before the prompt's answer. Updates may also be sent between turns. + * @param sessionId the ACP session the update belongs to + * @param update the update + * @return a {@code Mono} that completes when the notification has been handed to the transport */ Mono sendSessionUpdate(String sessionId, AcpSchema.SessionUpdate update); /** - * Requests permission from the client for a sensitive operation. - * @param request The permission request - * @return A Mono containing the permission response + * Asks the client to let the user approve a tool call ({@code session/request_permission}). + * Every client handles this request, so no capability is checked. + * @param request the session, the tool call and the permission options + * @return a {@code Mono} emitting the user's choice: the selected option, or a cancelled + * outcome when the client cancelled the prompt turn */ Mono requestPermission(AcpSchema.RequestPermissionRequest request); /** - * Requests the client to read a text file. - * @param request The read file request - * @return A Mono containing the file content + * Asks the client for the content of a text file ({@code fs/read_text_file}), including unsaved + * changes in its editor. The client must have advertised {@code fs.readTextFile}. + * @param request the session, an absolute path, and optionally a 1-based first line and a line + * limit + * @return a {@code Mono} emitting the file content; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability */ Mono readTextFile(AcpSchema.ReadTextFileRequest request); /** - * Requests the client to write a text file. - * @param request The write file request - * @return A Mono that completes when the file is written + * Asks the client to write a text file ({@code fs/write_text_file}); ACP requires the client to + * create the file if it does not exist. The client must have advertised + * {@code fs.writeTextFile}. + * @param request the session, an absolute path and the new content + * @return a {@code Mono} emitting the client's empty answer once the file is written; it fails + * with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability */ Mono writeTextFile(AcpSchema.WriteTextFileRequest request); /** - * Requests the client to create a terminal. - * @param request The create terminal request - * @return A Mono containing the terminal ID + * Asks the client to start a command in a new terminal ({@code terminal/create}). The client + * must have advertised {@code terminal}, and ACP requires the agent to release the terminal + * with {@link #releaseTerminal} when done. {@link PromptContext#execute(Command)} does the + * whole sequence. + * @param request the session, the command, its arguments and optionally a working directory, + * environment variables and an output limit + * @return a {@code Mono} emitting the new terminal's ID; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability */ Mono createTerminal(AcpSchema.CreateTerminalRequest request); /** - * Requests terminal output from the client. - * @param request The terminal output request - * @return A Mono containing the terminal output + * Asks the client for a terminal's output so far ({@code terminal/output}), without waiting for + * the command to end. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the output, whether it was truncated, and the exit status if + * the command has ended */ Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request); /** - * Requests the client to release a terminal. - * @param request The release terminal request - * @return A Mono that completes when the terminal is released + * Asks the client to release a terminal ({@code terminal/release}), which kills its command if + * it is still running. The terminal ID is invalid afterwards. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the client's empty answer once the terminal is released */ Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request); /** - * Waits for a terminal to exit. - * @param request The wait for exit request - * @return A Mono containing the exit status + * Asks the client to answer when a terminal's command has ended + * ({@code terminal/wait_for_exit}). The wait counts against the request timeout. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the exit code or the signal that ended the command */ Mono waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request); /** - * Requests the client to kill a terminal. - * @param request The kill terminal request - * @return A Mono that completes when the terminal is killed + * Asks the client to kill a terminal's command ({@code terminal/kill}) without releasing the + * terminal: its output and exit status can still be read, and it must still be released. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the client's empty answer once the command is killed */ Mono killTerminal(AcpSchema.KillTerminalCommandRequest request); /** - * Requests structured user input from the client via a form or URL. - * @param request The elicitation request - * @return A Mono containing the user's response + * Asks the client to collect structured input from the user ({@code elicitation/create}), with + * a form built from a schema or by sending the user to a URL. The client must have advertised + * the request's mode; a mode this SDK does not know only needs elicitation to be advertised at + * all. + * @param request the elicitation, made with {@link AcpSchema.CreateElicitationRequest#form} or + * {@link AcpSchema.CreateElicitationRequest#url} + * @return a {@code Mono} emitting the user's answer: accept (with the form content), decline or + * cancel; it fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the + * client did not advertise the mode */ Mono createElicitation(AcpSchema.CreateElicitationRequest request); /** - * Notifies the client that a URL-mode elicitation has completed. - * @param notification The completion notification - * @return A Mono that completes when the notification is sent + * Tells the client that the outside interaction of a URL-mode elicitation has finished + * ({@code elicitation/complete}). + * @param notification the ID of the elicitation that finished + * @return a {@code Mono} that completes when the notification has been handed to the transport */ Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification); /** - * Closes the agent gracefully, allowing pending operations to complete. - * @return A Mono that completes when the agent is closed + * Sends a custom extension request ({@code _}-prefixed method name, ACP v1 Extensibility) to + * the client and reads its result as the given type. A client that does not handle the method + * answers "Method not found" ({@code -32601}), which fails the {@code Mono} with + * {@link com.agentclientprotocol.sdk.spec.AcpError}. The SDK checks no capability for extension + * methods: advertise and check them in the {@code _meta} of the capability objects. + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return a {@code Mono} emitting the result, or completing empty when the client answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + Mono sendExtRequest(String method, Object params, TypeRef resultType); + + /** + * Sends a custom extension request to the client and returns its result as the raw JSON value: + * a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} emitting the result, or completing empty when the client answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #sendExtRequest(String, Object, TypeRef) + */ + Mono sendExtRequest(String method, Object params); + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the client. A + * client without a handler for it ignores it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} that completes when the notification has been handed to the transport + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + Mono sendExtNotification(String method, Object params); + + /** + * Shuts the agent down, then closes the transport gracefully. Requests from the client still + * being handled are cancelled and answered as cancelled ({@code -32800}) as far as the + * transport still delivers, and requests the agent sent the client and is still waiting for + * fail at once. What a graceful close means beyond that is up to the transport. Before + * {@link #start()} it completes at once. + * @return a {@code Mono} that completes when the transport has closed */ Mono closeGracefully(); /** - * Closes the agent immediately. + * Shuts the agent down and closes the transport at once. Requests still being handled are + * cancelled, and requests waiting for the client fail. */ void close(); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpSyncAgent.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpSyncAgent.java index e2571ab8..a92bbbed 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpSyncAgent.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AcpSyncAgent.java @@ -6,41 +6,78 @@ import java.time.Duration; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.SyncCalls; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** - * Synchronous ACP agent that provides blocking operations for handling client requests. + * A running ACP agent with blocking calls: the agent {@link AcpAsyncAgent} describes, wrapped so + * that each call waits for its result. {@link AcpAgent.SyncAgentBuilder#build()} returns one. Use + * it for an agent written as plain blocking Java: {@link #run()} starts it and blocks until the + * client is gone, which is the usual {@code main} of a stdio agent. * - *

- * This is a blocking wrapper around {@link AcpAsyncAgent} that uses {@code block()} - * to convert reactive operations to blocking calls. Use this when you prefer a - * simpler synchronous API and don't need the scalability of non-blocking operations. - *

+ *

Inside a prompt handler, use the {@link SyncPromptContext} it receives. Another handler that + * needs the agent, for example to send a {@code ConfigOptionUpdate}, takes it as a second + * parameter ({@link SyncAgentAwareHandler}): + * + *

{@code
+ * AcpAgent.sync(transport)
+ *     .setSessionConfigOptionHandler((request, self) -> {
+ *         self.sendSessionUpdate(request.sessionId(),
+ *             new AcpSchema.ConfigOptionUpdate(options));
+ *         return new AcpSchema.SetSessionConfigOptionResponse(options);
+ *     })
+ *     .build();
+ * }
+ * + *

Each call blocks on the asynchronous agent's {@code Mono} ({@link #async()} returns that + * agent) for at most the block timeout, 5 minutes unless given to the constructor. Requests to the + * client usually end sooner, at the builder's request timeout (60 seconds by default). Either + * timeout throws {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}, whose cause is the + * {@link java.util.concurrent.TimeoutException}; an interrupt of the waiting thread throws + * {@link java.util.concurrent.CancellationException} and leaves the interrupt flag set. Other + * failures are thrown as they are: + * {@link IllegalStateException} before {@link #start()}, + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} for a capability the client did + * not advertise, and {@link com.agentclientprotocol.sdk.spec.AcpError} for an error answer from the + * client. Methods may be called from several threads at once, but not from a thread that must not + * block. * * @author Mark Pollack * @see AcpAsyncAgent * @see AcpAgent */ -public class AcpSyncAgent { +public class AcpSyncAgent implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(AcpSyncAgent.class); private static final Duration DEFAULT_BLOCK_TIMEOUT = Duration.ofMinutes(5); + /** The longest {@link #close()} waits for a graceful close before closing at once. */ + private static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(10); + private final AcpAsyncAgent asyncAgent; private final Duration blockTimeout; /** - * Creates a new synchronous agent wrapping the given async agent. - * @param asyncAgent The async agent to wrap + * Wraps an asynchronous agent with blocking calls that wait at most 5 minutes. The synchronous + * builder uses this; call it yourself for a blocking view of an agent built with + * {@link AcpAgent#async(AcpAgentTransport)}. Both views share the one agent. + * @param asyncAgent the agent to wrap; must not be null */ public AcpSyncAgent(AcpAsyncAgent asyncAgent) { this(asyncAgent, DEFAULT_BLOCK_TIMEOUT); } /** - * Creates a new synchronous agent wrapping the given async agent. - * @param asyncAgent The async agent to wrap - * @param blockTimeout The timeout for blocking operations + * Wraps an asynchronous agent with blocking calls that wait at most the given time. + * @param asyncAgent the agent to wrap; must not be null + * @param blockTimeout the most each blocking call waits, except {@link #awaitTermination()} and + * {@link #run()}, which wait without limit */ public AcpSyncAgent(AcpAsyncAgent asyncAgent, Duration blockTimeout) { this.asyncAgent = asyncAgent; @@ -48,162 +85,270 @@ public AcpSyncAgent(AcpAsyncAgent asyncAgent, Duration blockTimeout) { } /** - * Starts the agent, beginning to accept client connections. - * This method returns immediately after setup is complete. - * Use {@link #await()} or {@link #run()} to block until the transport closes. + * Starts the agent, as {@link AcpAsyncAgent#start()} does, and returns without waiting for the + * client. Call {@link #awaitTermination()} afterwards, or use {@link #run()}, to block until + * the transport has ended. + * @throws IllegalStateException if the transport refuses to start, for example because it was + * started before */ public void start() { - asyncAgent.start().block(blockTimeout); + SyncCalls.block(asyncAgent.start(), blockTimeout); } /** - * Blocks until the agent terminates (transport closes). - * This is useful for keeping the main thread alive when using daemon threads. + * Blocks, without a time limit, until the agent's transport has ended. On stdio that is once + * the client has closed the agent's input and every request received before has been answered. + * Use it to keep the main thread alive when the transport's threads are daemon threads, as the + * stdio transport's are. * - *

Example usage: *

{@code
 	 * agent.start();
-	 * agent.await(); // Blocks until stdin closes
+	 * agent.awaitTermination();
 	 * }
*/ - public void await() { - asyncAgent.awaitTermination().block(); + public void awaitTermination() { + SyncCalls.block(asyncAgent.awaitTermination()); } /** - * Starts the agent and blocks until it terminates. - * This is a convenience method combining {@link #start()} and {@link #await()}. + * Starts the agent and blocks until its transport has ended: {@link #start()}, then + * {@link #awaitTermination()}. * - *

Example usage for a standalone agent: *

{@code
 	 * public static void main(String[] args) {
-	 *     AcpSyncAgent agent = AcpAgent.sync(transport)
-	 *         .promptHandler(...)
+	 *     AcpSyncAgent agent = AcpAgent.sync(new StdioAcpAgentTransport())
+	 *         .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+	 *         .newSessionHandler(request -> new AcpSchema.NewSessionResponse(
+	 *             UUID.randomUUID().toString(), null, null))
+	 *         .promptHandler((request, context) -> {
+	 *             context.sendMessage("Hello");
+	 *             return AcpSchema.PromptResponse.endTurn();
+	 *         })
 	 *         .build();
-	 *     agent.run(); // Start and block until done
+	 *     agent.run();
 	 * }
 	 * }
+ * + * @throws IllegalStateException if the transport refuses to start */ public void run() { start(); - await(); + awaitTermination(); } /** - * Sends a session update notification to the client. - * @param sessionId The session ID - * @param update The session update to send + * Sends a {@code session/update} notification to the client, carrying one + * {@link AcpSchema.SessionUpdate}, and returns once it has been handed to the transport. + * Updates may also be sent between prompt turns. + * @param sessionId the ACP session the update belongs to + * @param update the update + * @throws IllegalStateException if the agent is not started */ public void sendSessionUpdate(String sessionId, AcpSchema.SessionUpdate update) { - asyncAgent.sendSessionUpdate(sessionId, update).block(blockTimeout); + SyncCalls.block(asyncAgent.sendSessionUpdate(sessionId, update), blockTimeout); + } + + /** + * Sends a custom extension request ({@code _}-prefixed method name) to the client and blocks + * for its result, read as the given type. A client that does not handle the method answers + * "Method not found" ({@code -32601}), thrown as + * {@link com.agentclientprotocol.sdk.spec.AcpError}. + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return the result, or {@code null} when the client answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AcpAsyncAgent#sendExtRequest(String, Object, TypeRef) + */ + public @Nullable T sendExtRequest(String method, Object params, TypeRef resultType) { + return SyncCalls.block(asyncAgent.sendExtRequest(method, params, resultType), blockTimeout); } /** - * Requests permission from the client for a sensitive operation. - * @param request The permission request - * @return The permission response + * Sends a custom extension request to the client and blocks for its result, as the raw JSON + * value: a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return the result, or {@code null} when the client answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AcpAsyncAgent#sendExtRequest(String, Object) + */ + public @Nullable Object sendExtRequest(String method, Object params) { + return SyncCalls.block(asyncAgent.sendExtRequest(method, params), blockTimeout); + } + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the client and + * returns once it has been handed to the transport. A client without a handler for it ignores + * it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public void sendExtNotification(String method, Object params) { + SyncCalls.block(asyncAgent.sendExtNotification(method, params), blockTimeout); + } + + /** + * Asks the client to let the user approve a tool call ({@code session/request_permission}) and + * waits for the user's choice. Every client handles this request, so no capability is checked. + * @param request the session, the tool call and the permission options + * @return the user's choice: the selected option, or a cancelled outcome when the client + * cancelled the prompt turn */ public AcpSchema.RequestPermissionResponse requestPermission(AcpSchema.RequestPermissionRequest request) { - return asyncAgent.requestPermission(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.requestPermission(request), blockTimeout); } /** - * Requests the client to read a text file. - * @param request The read file request - * @return The file content + * Asks the client for the content of a text file ({@code fs/read_text_file}), including unsaved + * changes in its editor, and waits for it. + * @param request the session, an absolute path, and optionally a 1-based first line and a line + * limit + * @return the file content + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.readTextFile} */ public AcpSchema.ReadTextFileResponse readTextFile(AcpSchema.ReadTextFileRequest request) { - return asyncAgent.readTextFile(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.readTextFile(request), blockTimeout); } /** - * Requests the client to write a text file. - * @param request The write file request - * @return The write response + * Asks the client to write a text file ({@code fs/write_text_file}) and waits until it is + * written; ACP requires the client to create the file if it does not exist. + * @param request the session, an absolute path and the new content + * @return the client's empty answer + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.writeTextFile} */ public AcpSchema.WriteTextFileResponse writeTextFile(AcpSchema.WriteTextFileRequest request) { - return asyncAgent.writeTextFile(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.writeTextFile(request), blockTimeout); } /** - * Requests the client to create a terminal. - * @param request The create terminal request - * @return The terminal ID response + * Asks the client to start a command in a new terminal ({@code terminal/create}) and waits for + * the terminal's ID. ACP requires the agent to release the terminal with + * {@link #releaseTerminal} when done; {@link SyncPromptContext#execute(Command)} does the whole + * sequence. + * @param request the session, the command, its arguments and optionally a working directory, + * environment variables and an output limit + * @return the new terminal's ID + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code terminal} */ public AcpSchema.CreateTerminalResponse createTerminal(AcpSchema.CreateTerminalRequest request) { - return asyncAgent.createTerminal(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.createTerminal(request), blockTimeout); } /** - * Requests terminal output from the client. - * @param request The terminal output request - * @return The terminal output + * Asks the client for a terminal's output so far ({@code terminal/output}), without waiting for + * the command to end. + * @param request the session and the terminal ID + * @return the output, whether it was truncated, and the exit status if the command has ended */ public AcpSchema.TerminalOutputResponse getTerminalOutput(AcpSchema.TerminalOutputRequest request) { - return asyncAgent.getTerminalOutput(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.getTerminalOutput(request), blockTimeout); } /** - * Requests the client to release a terminal. - * @param request The release terminal request - * @return The release response + * Asks the client to release a terminal ({@code terminal/release}), which kills its command if + * it is still running, and waits until it is released. The terminal ID is invalid afterwards. + * @param request the session and the terminal ID + * @return the client's empty answer */ public AcpSchema.ReleaseTerminalResponse releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { - return asyncAgent.releaseTerminal(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.releaseTerminal(request), blockTimeout); } /** - * Waits for a terminal to exit. - * @param request The wait for exit request - * @return The exit status + * Waits until a terminal's command has ended ({@code terminal/wait_for_exit}). The wait counts + * against the request timeout. + * @param request the session and the terminal ID + * @return the exit code or the signal that ended the command */ public AcpSchema.WaitForTerminalExitResponse waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request) { - return asyncAgent.waitForTerminalExit(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.waitForTerminalExit(request), blockTimeout); } /** - * Requests the client to kill a terminal. - * @param request The kill terminal request - * @return The kill response + * Asks the client to kill a terminal's command ({@code terminal/kill}) without releasing the + * terminal, and waits until it is killed. The output and exit status can still be read, and the + * terminal must still be released. + * @param request the session and the terminal ID + * @return the client's empty answer */ public AcpSchema.KillTerminalCommandResponse killTerminal(AcpSchema.KillTerminalCommandRequest request) { - return asyncAgent.killTerminal(request).block(blockTimeout); + return SyncBlocking.awaitResponse(asyncAgent.killTerminal(request), blockTimeout); } /** - * Returns the capabilities negotiated with the client during initialization. - * - *

- * This method returns null if initialization has not been completed yet. - * Use this to check what features the client supports before calling - * methods like {@link #readTextFile} or {@link #createTerminal}. - *

- * @return the negotiated client capabilities, or null if not initialized - */ - public com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities getClientCapabilities() { + * Asks the client to collect structured input from the user ({@code elicitation/create}), with + * a form or by sending the user to a URL, and waits for the answer. + * @param request the elicitation, made with {@link AcpSchema.CreateElicitationRequest#form} or + * {@link AcpSchema.CreateElicitationRequest#url} + * @return the user's answer: accept (with the form content), decline or cancel + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise the request's mode + */ + public AcpSchema.CreateElicitationResponse createElicitation(AcpSchema.CreateElicitationRequest request) { + return SyncBlocking.awaitResponse(asyncAgent.createElicitation(request), blockTimeout); + } + + /** + * Tells the client that the outside interaction of a URL-mode elicitation has finished + * ({@code elicitation/complete}), and returns once the notification has been handed to the + * transport. + * @param notification the ID of the elicitation that finished + */ + public void completeElicitation(AcpSchema.CompleteElicitationNotification notification) { + SyncCalls.block(asyncAgent.completeElicitation(notification), blockTimeout); + } + + /** + * Returns what the client advertised in its {@code initialize} request. Check it before a call + * that needs a capability, for example {@code supportsTerminal()} before + * {@link #createTerminal}. It is recorded when the request arrives, before the initialize + * handler runs. + * @return the client's capabilities, or {@code null} if the client has not initialized + */ + public com.agentclientprotocol.sdk.capabilities.@Nullable NegotiatedCapabilities getClientCapabilities() { return asyncAgent.getClientCapabilities(); } /** - * Returns the underlying async agent. - * @return The async agent + * Returns the asynchronous agent this one wraps: the same agent, with calls that return + * {@code Mono}s. {@link AcpAgentFactory#sync} hands it to listener transports. + * @return the asynchronous agent */ public AcpAsyncAgent async() { return asyncAgent; } /** - * Closes the agent gracefully, allowing pending operations to complete. + * Shuts the agent down as {@link AcpAsyncAgent#closeGracefully()} does, and waits for the + * transport to close, at most the block timeout. */ public void closeGracefully() { - asyncAgent.closeGracefully().block(blockTimeout); + SyncCalls.block(asyncAgent.closeGracefully(), blockTimeout); } /** - * Closes the agent immediately. + * Closes the agent the way try-with-resources expects: gracefully, as + * {@link #closeGracefully()} does, waiting at most 10 seconds (or the block timeout, if + * shorter), then at once, as {@link AcpAsyncAgent#close()} does, if that failed or took longer. + * To close at once without waiting, call {@code async().close()}. */ + @Override public void close() { - asyncAgent.close(); + Duration bound = this.blockTimeout.compareTo(CLOSE_TIMEOUT) < 0 ? this.blockTimeout : CLOSE_TIMEOUT; + try { + SyncCalls.block(asyncAgent.closeGracefully(), bound); + } + catch (RuntimeException e) { + logger.warn("The agent did not close gracefully, closing it now: {}", e.toString()); + asyncAgent.close(); + } } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentAwareHandler.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentAwareHandler.java new file mode 100644 index 00000000..521964ae --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentAwareHandler.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * A request handler that also receives the agent it serves, so it can act back on the client + * without capturing the built agent in a variable: send session updates (a + * {@code session/load} replay, a {@link AcpSchema.ConfigOptionUpdate} after a config change), + * or call the client. Every typed setter of {@link AcpAgent.AsyncAgentBuilder} except + * {@code promptHandler} (whose {@link PromptContext} already reaches the client) has an + * overload that takes one, for the same request and response types as its own handler + * interface, and so do {@code extRequestHandler} and {@code extNotificationHandler} (an + * {@code AgentAwareHandler}); a two-argument lambda picks it. + * + *
{@code
+ * AcpAsyncAgent agent = AcpAgent.async(transport)
+ *     .setSessionConfigOptionHandler((request, self) -> self
+ *         .sendSessionUpdate(request.sessionId(), new AcpSchema.ConfigOptionUpdate(options))
+ *         .thenReturn(new AcpSchema.SetSessionConfigOptionResponse(options)))
+ *     .build();
+ * }
+ * + *

The second parameter is the agent being built, the same instance {@code build()} returns; a + * lambda parameter must not reuse the name of a local variable in scope, so call it {@code self} + * (or anything but the name the built agent is assigned to), as above. + * + *

Implementations follow the rules on {@link AcpAgent.PromptHandler}: they run on the transport's + * thread and must not block, return a {@code Mono} and never {@code null}, and fail with an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} to answer with a chosen error. + * @param the request type + * @param the response type + * @author Mark Pollack + * @see SyncAgentAwareHandler + */ +@FunctionalInterface +public interface AgentAwareHandler { + + /** + * Answers one request from the client. + * @param request the request, as the client sent it; never null + * @param agent the agent serving the request, the one {@code build()} returned + * @return a {@code Mono} that emits the answer + */ + Mono handle(Q request, AcpAsyncAgent agent); + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentHandlers.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentHandlers.java new file mode 100644 index 00000000..a8caefb4 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AgentHandlers.java @@ -0,0 +1,145 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * The JSON-RPC methods an agent serves, held as data: for each method, the type its params + * are read as and the handler that answers them. The {@link AcpAgent} builders collect one + * registration per handler they are given (a second one for the same method throws + * {@link IllegalStateException}); {@link DefaultAcpAsyncAgent} installs them in its session when + * it starts. + * + * @author Mark Pollack + */ +final class AgentHandlers { + + /** Reads params as the raw JSON value, for the untyped extension handlers. */ + static final TypeRef RAW_PARAMS = new TypeRef<>() { + }; + + /** + * Answers one inbound request, already read into its request type. + * @param the request type + */ + @FunctionalInterface + interface RequestHandler { + + /** + * Answers the request. + * @param request the request + * @param agent the agent serving it, for handlers that act back on the client + * (a prompt handler's {@link PromptContext}) + * @return the response + */ + Mono handle(T request, AcpAsyncAgent agent); + + } + + /** + * Handles one inbound notification, already read into its type. + * @param the notification type + */ + @FunctionalInterface + interface NotificationHandler { + + /** + * Handles the notification. + * @param notification the notification + * @param agent the agent that received it, for handlers that act back on the client + * @return a {@code Mono} that completes when the notification is handled + */ + Mono handle(T notification, AcpAsyncAgent agent); + + } + + /** + * One request method: its name, the type its params are read as, and its handler. + * @param the request type + */ + record Request(String method, TypeRef requestType, RequestHandler handler) { + } + + /** + * One notification method: its name, the type its params are read as, and its handler. + * @param the notification type + */ + record Notification(String method, TypeRef notificationType, NotificationHandler handler) { + } + + private final Map> requests = new LinkedHashMap<>(); + + private final Map> notifications = new LinkedHashMap<>(); + + void request(String method, TypeRef requestType, RequestHandler handler) { + if (requests.putIfAbsent(method, new Request<>(method, requestType, handler)) != null) { + throw alreadyRegistered(method, "extRequestHandler"); + } + } + + void notification(String method, TypeRef notificationType, NotificationHandler handler) { + if (notifications.putIfAbsent(method, new Notification<>(method, notificationType, handler)) != null) { + throw alreadyRegistered(method, "extNotificationHandler"); + } + } + + /** A copy, to which a built agent's defaults are added without changing the builder's. */ + AgentHandlers copy() { + AgentHandlers copy = new AgentHandlers(); + copy.requests.putAll(this.requests); + copy.notifications.putAll(this.notifications); + return copy; + } + + /** The request methods with a registered handler. */ + java.util.Set requestMethods() { + return java.util.Set.copyOf(requests.keySet()); + } + + /** A second handler for a method: a mistake, which would silently replace the first. */ + private static IllegalStateException alreadyRegistered(String method, String extensionSetter) { + String setter = SETTERS.getOrDefault(method, extensionSetter + "(\"" + method + "\", ...)"); + return new IllegalStateException("A handler for " + method + " is already registered; " + setter + + " was called twice on this builder"); + } + + /** The builder setter that registers each ACP method's handler, for error messages. */ + private static final Map SETTERS = Map.ofEntries( + Map.entry(AcpSchema.METHOD_INITIALIZE, "initializeHandler"), + Map.entry(AcpSchema.METHOD_AUTHENTICATE, "authenticateHandler"), + Map.entry(AcpSchema.METHOD_LOGOUT, "logoutHandler"), + Map.entry(AcpSchema.METHOD_SESSION_NEW, "newSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_LOAD, "loadSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_PROMPT, "promptHandler"), + Map.entry(AcpSchema.METHOD_SESSION_SET_MODE, "setSessionModeHandler"), + Map.entry(AcpSchema.METHOD_SESSION_LIST, "listSessionsHandler"), + Map.entry(AcpSchema.METHOD_SESSION_CLOSE, "closeSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_DELETE, "deleteSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_RESUME, "resumeSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_FORK, "forkSessionHandler"), + Map.entry(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, "setSessionConfigOptionHandler"), + Map.entry(AcpSchema.METHOD_PROVIDERS_LIST, "listProvidersHandler"), + Map.entry(AcpSchema.METHOD_PROVIDERS_SET, "setProviderHandler"), + Map.entry(AcpSchema.METHOD_PROVIDERS_DISABLE, "disableProviderHandler"), + Map.entry(AcpSchema.METHOD_SESSION_CANCEL, "cancelHandler")); + + /** A snapshot: registrations made after it do not reach an agent already built. */ + List> requests() { + return List.copyOf(requests.values()); + } + + /** A snapshot: registrations made after it do not reach an agent already built. */ + List> notifications() { + return List.copyOf(notifications.values()); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AsyncAgentAwareSetters.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AsyncAgentAwareSetters.java new file mode 100644 index 00000000..d7f5d9d4 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/AsyncAgentAwareSetters.java @@ -0,0 +1,322 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; +import com.agentclientprotocol.sdk.util.Assert; + +/** + * The setters of {@link AcpAgent.AsyncAgentBuilder} that take an {@link AgentAwareHandler}: one + * overload of each typed request setter but {@code promptHandler}, and of each extension handler + * setter, registering a handler that also receives the agent. Kept apart from the builder only to keep that class small; it is part of the + * builder's API. + */ +abstract class AsyncAgentAwareSetters { + + /** Only {@link AcpAgent.AsyncAgentBuilder} extends this class. */ + AsyncAgentAwareSetters() { + } + + /** Registers the handler of one request method, as the typed setters do. */ + abstract B request(String method, TypeRef requestType, + AgentHandlers.RequestHandler handler); + + /** Registers the handler of one notification method. */ + abstract B notification(String method, TypeRef notificationType, + AgentHandlers.NotificationHandler handler); + + /** + * Sets the handler for {@code initialize}, as {@link AcpAgent.AsyncAgentBuilder#initializeHandler(AcpAgent.InitializeHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B initializeHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_INITIALIZE, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code authenticate}, as {@link AcpAgent.AsyncAgentBuilder#authenticateHandler(AcpAgent.AuthenticateHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B authenticateHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_AUTHENTICATE, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code logout}, as {@link AcpAgent.AsyncAgentBuilder#logoutHandler(AcpAgent.LogoutHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B logoutHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_LOGOUT, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/new}, as {@link AcpAgent.AsyncAgentBuilder#newSessionHandler(AcpAgent.NewSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B newSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_NEW, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/load}, as {@link AcpAgent.AsyncAgentBuilder#loadSessionHandler(AcpAgent.LoadSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B loadSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_LOAD, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/set_mode}, as {@link AcpAgent.AsyncAgentBuilder#setSessionModeHandler(AcpAgent.SetSessionModeHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B setSessionModeHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_SET_MODE, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/list}, as {@link AcpAgent.AsyncAgentBuilder#listSessionsHandler(AcpAgent.ListSessionsHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B listSessionsHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_LIST, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/close}, as {@link AcpAgent.AsyncAgentBuilder#closeSessionHandler(AcpAgent.CloseSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B closeSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_CLOSE, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/delete}, as {@link AcpAgent.AsyncAgentBuilder#deleteSessionHandler(AcpAgent.DeleteSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B deleteSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_DELETE, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/resume}, as {@link AcpAgent.AsyncAgentBuilder#resumeSessionHandler(AcpAgent.ResumeSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B resumeSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_RESUME, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/fork}, as {@link AcpAgent.AsyncAgentBuilder#forkSessionHandler(AcpAgent.ForkSessionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B forkSessionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_FORK, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code session/set_config_option}, as {@link AcpAgent.AsyncAgentBuilder#setSessionConfigOptionHandler(AcpAgent.SetSessionConfigOptionHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B setSessionConfigOptionHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code providers/list}, as {@link AcpAgent.AsyncAgentBuilder#listProvidersHandler(AcpAgent.ListProvidersHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B listProvidersHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_LIST, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code providers/set}, as {@link AcpAgent.AsyncAgentBuilder#setProviderHandler(AcpAgent.SetProviderHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B setProviderHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_SET, new TypeRef() { + }, handler::handle); + } + + /** + * Sets the handler for {@code providers/disable}, as {@link AcpAgent.AsyncAgentBuilder#disableProviderHandler(AcpAgent.DisableProviderHandler)} does, for a handler + * that also needs the agent it serves, for example to send session updates + * ({@link AcpAsyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B disableProviderHandler( + AgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + return request(AcpSchema.METHOD_PROVIDERS_DISABLE, new TypeRef() { + }, handler::handle); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP v1 + * Extensibility) from the client, its params read as the given type, as + * {@link AcpAgent.AsyncAgentBuilder#extRequestHandler(String, TypeRef, AcpAgent.ExtRequestHandler)} + * does, for a handler that also needs the agent it serves, for example to call the client back + * ({@link AcpAsyncAgent#sendExtRequest(String, Object, TypeRef)}) or send session updates. Name + * the agent parameter {@code self}, not the name the built agent is assigned to. + * @param the params type + * @param the result type, any type the JSON mapper can write + * @param method the method name, which must start with {@code _} (for example + * {@code _example.com/workspace/buffers}) + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public B extRequestHandler(String method, TypeRef paramsType, AgentAwareHandler handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return request(method, paramsType, handler::handle); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name) from + * the client, its params delivered as the raw JSON value (a {@code Map}, {@code List}, + * {@code String}, {@code Number} or {@code Boolean}), for a handler that also needs the agent + * it serves. + * @param the result type, any type the JSON mapper can write + * @param method the method name, which must start with {@code _} + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, AgentAwareHandler) + */ + public B extRequestHandler(String method, AgentAwareHandler handler) { + return extRequestHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method name) + * from the client, its params read as the given type, as + * {@link AcpAgent.AsyncAgentBuilder#extNotificationHandler(String, TypeRef, AcpAgent.ExtNotificationHandler)} + * does, for a handler that also needs the agent it serves, for example to answer with a + * notification of its own ({@link AcpAsyncAgent#sendExtNotification(String, Object)}). Its + * {@code Mono} completes when the notification is handled; a handler that fails is only + * logged. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public B extNotificationHandler(String method, TypeRef paramsType, AgentAwareHandler handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return notification(method, paramsType, handler::handle); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method name) + * from the client, its params delivered as the raw JSON value, for a handler that also needs + * the agent it serves. + * @param method the method name, which must start with {@code _} + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, AgentAwareHandler) + */ + public B extNotificationHandler(String method, AgentAwareHandler handler) { + return extNotificationHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/Command.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/Command.java index bd312fb3..524ff8ef 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/Command.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/Command.java @@ -8,32 +8,33 @@ import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; + /** - * Builder for terminal command execution via the convenience API. + * A command for a prompt handler to run in a terminal on the client, with the options + * {@code execute(String...)} cannot express: a working directory, environment variables and an + * output limit. Pass it to {@link SyncPromptContext#execute(Command)} or + * {@link PromptContext#execute(Command)}, which run it and return a {@link CommandResult}. * - *

- * This record allows configuring command execution with options like - * working directory, environment variables, and output limits. + *

Start with {@link #of(String...)} and add options with {@link #cwd(String)}, {@link #env(Map)} + * and {@link #outputByteLimit(long)}; each returns a new command. The values go to the client as + * they are, in a {@code terminal/create} request, so the client decides what they mean: ACP asks + * for an absolute working directory, and a client that reaches the output limit drops output from + * the start. The client must have advertised the {@code terminal} capability. * - *

- * Example usage: *

{@code
- * // Simple command
- * CommandResult result = context.execute("echo", "hello");
- *
- * // Command with options
- * CommandResult result = context.execute(
- *     Command.of("make", "build")
- *         .cwd("/workspace")
- *         .env(Map.of("DEBUG", "true"))
- *         .outputLimit(10000));
+ * CommandResult result = context.execute(Command.of("mvn", "-q", "test")
+ *     .cwd("/workspace/project")
+ *     .env(Map.of("CI", "true"))
+ *     .outputByteLimit(100_000));
  * }
* - * @param executable The command to execute - * @param args The arguments to pass to the command - * @param cwd The working directory (null for default) - * @param env Environment variables to set (null for default) - * @param outputByteLimit Maximum bytes of output to capture (null for default) + * @param executable the program to run + * @param args the arguments after the executable; empty for none + * @param cwd the working directory, an absolute path, or {@code null} for the client's default + * @param env the environment variables to set; empty for none + * @param outputByteLimit the most bytes of output the client keeps, or {@code null} for the + * client's default * @author Mark Pollack * @since 0.9.2 * @see SyncPromptContext#execute(Command) @@ -42,16 +43,18 @@ public record Command( String executable, List args, - String cwd, + @Nullable String cwd, Map env, - Long outputByteLimit + @Nullable Long outputByteLimit ) { /** - * Creates a Command from command-line arguments. - * The first argument is the executable, remaining arguments are passed as args. - * @param commandAndArgs The command and its arguments - * @return A new Command instance + * Creates a command from an executable and its arguments, with no working directory, no + * environment variables and no output limit. The arguments are a view of + * {@code commandAndArgs}, not a copy. + * @param commandAndArgs the executable, then its arguments + * @return the command + * @throws IllegalArgumentException if {@code commandAndArgs} is empty */ public static Command of(String... commandAndArgs) { if (commandAndArgs == null || commandAndArgs.length == 0) { @@ -62,31 +65,32 @@ public static Command of(String... commandAndArgs) { commandAndArgs.length > 1 ? Arrays.asList(commandAndArgs).subList(1, commandAndArgs.length) : List.of(), - null, null, null); + null, Map.of(), null); } /** - * Returns a new Command with the specified working directory. - * @param cwd The working directory - * @return A new Command with the working directory set + * Returns a copy of this command that runs in the given working directory. + * @param cwd the working directory, an absolute path + * @return the new command */ public Command cwd(String cwd) { return new Command(executable, args, cwd, env, outputByteLimit); } /** - * Returns a new Command with the specified environment variables. - * @param env The environment variables - * @return A new Command with the environment variables set + * Returns a copy of this command with these environment variables, in place of any set before. + * @param env the variable names and values + * @return the new command */ public Command env(Map env) { return new Command(executable, args, cwd, env, outputByteLimit); } /** - * Returns a new Command with the specified output byte limit. - * @param limit The maximum bytes of output to capture - * @return A new Command with the output byte limit set + * Returns a copy of this command with an output limit: the most bytes of output the client + * keeps. + * @param limit the limit in bytes + * @return the new command */ public Command outputByteLimit(long limit) { return new Command(executable, args, cwd, env, limit); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/CommandResult.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/CommandResult.java index fd877a53..b176edf9 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/CommandResult.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/CommandResult.java @@ -4,28 +4,35 @@ package com.agentclientprotocol.sdk.agent; +import org.jspecify.annotations.Nullable; + /** - * Result of executing a terminal command via the convenience API. + * What a command run by {@link SyncPromptContext#execute(Command)} or + * {@link PromptContext#execute(Command)} left behind: its output and how it ended, as the client's + * terminal reported them. Check {@link #success()} for exit code 0, and read {@link #output()} for + * what it printed. * - *

- * This record wraps the output and exit code from a terminal command execution, - * providing a clean interface for the common case of running a command and - * checking its result. + *

{@code execute} builds it from two of the client's answers: the exit code or signal from + * {@code terminal/wait_for_exit}, then the output from {@code terminal/output}, read after the + * command ended. A process ends with an exit code or with a signal, so one of the two is normally + * {@code null}. The output is what the client kept: past the command's output limit the client + * drops output from the start, and {@link #truncated()} says whether it did. Whether standard error + * is part of the output is up to the client. * - *

- * Example usage: *

{@code
  * CommandResult result = context.execute("make", "build");
- * if (result.exitCode() == 0) {
- *     context.sendMessage("Build succeeded!");
+ * if (result.success()) {
+ *     context.sendMessage("Build succeeded");
  * } else {
- *     context.sendMessage("Build failed: " + result.output());
+ *     context.sendMessage("Build failed:\n" + result.output());
  * }
  * }
* - * @param output The combined stdout/stderr output from the command - * @param exitCode The exit code (0 typically means success) - * @param timedOut Whether the command was terminated due to timeout + * @param output the terminal output the client returned + * @param exitCode the exit code, or {@code null} if a signal ended the process + * @param signal the signal that ended the process, or {@code null} if it exited + * @param truncated whether the client cut the output to the command's output limit, so that + * {@code output} lacks its beginning * @author Mark Pollack * @since 0.9.2 * @see SyncPromptContext#execute(String...) @@ -33,26 +40,37 @@ */ public record CommandResult( String output, - int exitCode, - boolean timedOut + @Nullable Integer exitCode, + @Nullable String signal, + boolean truncated ) { /** - * Creates a CommandResult with the given output and exit code. - * Sets timedOut to false. - * @param output The command output - * @param exitCode The exit code + * Creates a result whose output is complete (not truncated). + * @param output the terminal output + * @param exitCode the exit code, or {@code null} if a signal ended the process + * @param signal the signal that ended the process, or {@code null} if it exited + */ + public CommandResult(String output, @Nullable Integer exitCode, @Nullable String signal) { + this(output, exitCode, signal, false); + } + + /** + * Creates a result for a process that exited with the given code, with no signal and complete + * output. + * @param output the terminal output + * @param exitCode the exit code */ public CommandResult(String output, int exitCode) { - this(output, exitCode, false); + this(output, exitCode, null, false); } /** - * Returns true if the command completed successfully (exit code 0). - * @return true if exit code is 0 and command did not time out + * Returns whether the process exited with code 0. A process ended by a signal did not succeed. + * @return {@code true} if the exit code is 0 */ public boolean success() { - return exitCode == 0 && !timedOut; + return exitCode != null && exitCode == 0; } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultAcpAsyncAgent.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultAcpAsyncAgent.java index 69fa47f1..e0b6a0d4 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultAcpAsyncAgent.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultAcpAsyncAgent.java @@ -6,15 +6,22 @@ import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; import com.agentclientprotocol.sdk.error.AcpCapabilityException; import com.agentclientprotocol.sdk.spec.AcpAgentSession; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; +import com.agentclientprotocol.sdk.spec.PromptTimeouts; +import com.agentclientprotocol.sdk.util.Assert; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -25,7 +32,8 @@ * *

* This implementation creates an {@link AcpAgentSession} to manage the JSON-RPC - * communication and registers handlers for all ACP protocol methods. + * communication and installs in it the handlers its builder registered + * ({@link AgentHandlers}). *

* * @author Mark Pollack @@ -38,290 +46,134 @@ class DefaultAcpAsyncAgent implements AcpAsyncAgent { private final Duration requestTimeout; - private final AcpAgent.InitializeHandler initializeHandler; + private final PromptTimeouts promptTimeouts; - private final AcpAgent.AuthenticateHandler authenticateHandler; + private final List> requestHandlers; - private final AcpAgent.LogoutHandler logoutHandler; + private final List> notificationHandlers; - private final AcpAgent.NewSessionHandler newSessionHandler; - - private final AcpAgent.LoadSessionHandler loadSessionHandler; - - private final AcpAgent.PromptHandler promptHandler; - - private final AcpAgent.SetSessionModeHandler setSessionModeHandler; - - @SuppressWarnings("removal") - private final AcpAgent.SetSessionModelHandler setSessionModelHandler; - - private final AcpAgent.ListSessionsHandler listSessionsHandler; - - private final AcpAgent.CloseSessionHandler closeSessionHandler; - - private final AcpAgent.DeleteSessionHandler deleteSessionHandler; - - private final AcpAgent.ResumeSessionHandler resumeSessionHandler; - - private final AcpAgent.ForkSessionHandler forkSessionHandler; - - private final AcpAgent.SetSessionConfigOptionHandler setSessionConfigOptionHandler; - - private final AcpAgent.ListProvidersHandler listProvidersHandler; - - private final AcpAgent.SetProviderHandler setProviderHandler; - - private final AcpAgent.DisableProviderHandler disableProviderHandler; - - private final AcpAgent.CancelHandler cancelHandler; - - private volatile AcpAgentSession session; + /** + * The session serving this agent; null until {@link #start()}, then never null again. + * Each method reads it once into a local, so its null check and its call see one value. + */ + private volatile @Nullable AcpAgentSession session; /** * Capabilities negotiated with the client during initialization. */ - private final AtomicReference clientCapabilities = new AtomicReference<>(); - - @SuppressWarnings("removal") // accepts the deprecated-for-removal SetSessionModelHandler - DefaultAcpAsyncAgent(AcpAgentTransport transport, Duration requestTimeout, - AcpAgent.InitializeHandler initializeHandler, AcpAgent.AuthenticateHandler authenticateHandler, - AcpAgent.LogoutHandler logoutHandler, AcpAgent.NewSessionHandler newSessionHandler, - AcpAgent.LoadSessionHandler loadSessionHandler, - AcpAgent.PromptHandler promptHandler, AcpAgent.SetSessionModeHandler setSessionModeHandler, - AcpAgent.SetSessionModelHandler setSessionModelHandler, - AcpAgent.ListSessionsHandler listSessionsHandler, AcpAgent.CloseSessionHandler closeSessionHandler, - AcpAgent.DeleteSessionHandler deleteSessionHandler, - AcpAgent.ResumeSessionHandler resumeSessionHandler, AcpAgent.ForkSessionHandler forkSessionHandler, - AcpAgent.SetSessionConfigOptionHandler setSessionConfigOptionHandler, - AcpAgent.ListProvidersHandler listProvidersHandler, AcpAgent.SetProviderHandler setProviderHandler, - AcpAgent.DisableProviderHandler disableProviderHandler, - AcpAgent.CancelHandler cancelHandler) { + private final AtomicReference<@Nullable NegotiatedCapabilities> clientCapabilities = new AtomicReference<>(); + + /** The cancellation signals of the prompts running, which their contexts read. */ + private final PromptCancellations promptCancellations = new PromptCancellations(); + + DefaultAcpAsyncAgent(AcpAgentTransport transport, Duration requestTimeout, PromptTimeouts promptTimeouts, + AgentHandlers handlers) { this.transport = transport; this.requestTimeout = requestTimeout; - this.initializeHandler = initializeHandler; - this.authenticateHandler = authenticateHandler; - this.logoutHandler = logoutHandler; - this.newSessionHandler = newSessionHandler; - this.loadSessionHandler = loadSessionHandler; - this.promptHandler = promptHandler; - this.setSessionModeHandler = setSessionModeHandler; - this.setSessionModelHandler = setSessionModelHandler; - this.listSessionsHandler = listSessionsHandler; - this.closeSessionHandler = closeSessionHandler; - this.deleteSessionHandler = deleteSessionHandler; - this.resumeSessionHandler = resumeSessionHandler; - this.forkSessionHandler = forkSessionHandler; - this.setSessionConfigOptionHandler = setSessionConfigOptionHandler; - this.listProvidersHandler = listProvidersHandler; - this.setProviderHandler = setProviderHandler; - this.disableProviderHandler = disableProviderHandler; - this.cancelHandler = cancelHandler; + this.promptTimeouts = promptTimeouts; + this.requestHandlers = handlers.requests(); + this.notificationHandlers = handlers.notifications(); } @Override - @SuppressWarnings("removal") // registers the deprecated-for-removal session/set_model handler public Mono start() { return Mono.fromRunnable(() -> { logger.info("Starting ACP async agent"); - - // Build request handlers - Map> requestHandlers = new HashMap<>(); - - // Initialize handler - also captures client capabilities - if (initializeHandler != null) { - requestHandlers.put(AcpSchema.METHOD_INITIALIZE, params -> { - AcpSchema.InitializeRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - // Capture the client capabilities - NegotiatedCapabilities caps = NegotiatedCapabilities.fromClient(request.clientCapabilities()); - clientCapabilities.set(caps); - logger.debug("Negotiated client capabilities: {}", caps); - return initializeHandler.handle(request).cast(Object.class); - }); - } - - // Authenticate handler - if (authenticateHandler != null) { - requestHandlers.put(AcpSchema.METHOD_AUTHENTICATE, params -> { - AcpSchema.AuthenticateRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return authenticateHandler.handle(request).cast(Object.class); - }); - } - - // Logout handler - if (logoutHandler != null) { - requestHandlers.put(AcpSchema.METHOD_LOGOUT, params -> { - AcpSchema.LogoutRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return logoutHandler.handle(request).cast(Object.class); - }); - } - - // New session handler - if (newSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_NEW, params -> { - AcpSchema.NewSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return newSessionHandler.handle(request).cast(Object.class); - }); - } - - // Load session handler - if (loadSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_LOAD, params -> { - AcpSchema.LoadSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return loadSessionHandler.handle(request).cast(Object.class); - }); - } - - // Prompt handler - provides full PromptContext with all agent capabilities - if (promptHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_PROMPT, params -> { - AcpSchema.PromptRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - // Create PromptContext that wraps this agent, giving handler access to all capabilities - PromptContext context = new DefaultPromptContext(this, request.sessionId()); - return promptHandler.handle(request, context) - .cast(Object.class); - }); + Map> requests = new HashMap<>(); + for (AgentHandlers.Request registration : requestHandlers) { + requests.put(registration.method(), sessionHandler(registration)); } - - // Set session mode handler - if (setSessionModeHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_SET_MODE, params -> { - AcpSchema.SetSessionModeRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return setSessionModeHandler.handle(request).cast(Object.class); - }); - } - - // Set session model handler - if (setSessionModelHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_SET_MODEL, params -> { - AcpSchema.SetSessionModelRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return setSessionModelHandler.handle(request).cast(Object.class); - }); - } - - // List sessions handler - if (listSessionsHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_LIST, params -> { - AcpSchema.ListSessionsRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return listSessionsHandler.handle(request).cast(Object.class); - }); - } - - // Close session handler - if (closeSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_CLOSE, params -> { - AcpSchema.CloseSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return closeSessionHandler.handle(request).cast(Object.class); - }); - } - - // Delete session handler - if (deleteSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_DELETE, params -> { - AcpSchema.DeleteSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return deleteSessionHandler.handle(request).cast(Object.class); - }); - } - - // Resume session handler - if (resumeSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_RESUME, params -> { - AcpSchema.ResumeSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return resumeSessionHandler.handle(request).cast(Object.class); - }); - } - - // Fork session handler - if (forkSessionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_FORK, params -> { - AcpSchema.ForkSessionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return forkSessionHandler.handle(request).cast(Object.class); - }); - } - - // Set config option handler - if (setSessionConfigOptionHandler != null) { - requestHandlers.put(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, params -> { - AcpSchema.SetSessionConfigOptionRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return setSessionConfigOptionHandler.handle(request).cast(Object.class); - }); + Map notifications = new HashMap<>(); + for (AgentHandlers.Notification registration : notificationHandlers) { + notifications.put(registration.method(), sessionHandler(registration)); } + notifications.put(AcpSchema.METHOD_SESSION_CANCEL, + signalCancel(notifications.get(AcpSchema.METHOD_SESSION_CANCEL))); + this.session = new AcpAgentSession(requestTimeout, transport, requests, notifications, promptTimeouts); + logger.info("ACP async agent started"); + }); + } - // List providers handler (unstable) - if (listProvidersHandler != null) { - requestHandlers.put(AcpSchema.METHOD_PROVIDERS_LIST, params -> { - AcpSchema.ListProvidersRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return listProvidersHandler.handle(request).cast(Object.class); - }); + /** Reads the params as the registered request type, then calls the registered handler. */ + private AcpAgentSession.RequestHandler sessionHandler(AgentHandlers.Request registration) { + return params -> { + T request = transport.unmarshalParams(params, registration.requestType()); + recordClientCapabilities(request); + if (request instanceof AcpSchema.PromptRequest prompt) { + return handlePrompt(registration, request, prompt.sessionId()); } + return registration.handler().handle(request, this).cast(Object.class); + }; + } - // Set provider handler (unstable) - if (setProviderHandler != null) { - requestHandlers.put(AcpSchema.METHOD_PROVIDERS_SET, params -> { - AcpSchema.SetProviderRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return setProviderHandler.handle(request).cast(Object.class); - }); - } + /** + * Calls a prompt handler with its cancellation signal started, so its context sees it, + * and signals it when the handler's subscription is cancelled: by {@code $/cancel_request}, + * a prompt deadline, or the session closing. {@code session/cancel} signals it through + * {@link #signalCancel}. + */ + private Mono handlePrompt(AgentHandlers.Request registration, T request, String sessionId) { + PromptCancellations.Signal signal = promptCancellations.start(sessionId); + Mono handled; + try { + handled = registration.handler().handle(request, this); + } + catch (RuntimeException | Error ex) { + promptCancellations.end(sessionId, signal); + throw ex; + } + return handled.cast(Object.class) + .doOnCancel(signal::cancel) + .doFinally(ignored -> promptCancellations.end(sessionId, signal)); + } - // Disable provider handler (unstable) - if (disableProviderHandler != null) { - requestHandlers.put(AcpSchema.METHOD_PROVIDERS_DISABLE, params -> { - AcpSchema.DisableProviderRequest request = transport.unmarshalFrom(params, - new TypeRef() { - }); - return disableProviderHandler.handle(request).cast(Object.class); - }); - } + /** + * Handles {@code session/cancel}: signals the session's running prompt, then calls the + * registered cancel handler, if any. + */ + private AcpAgentSession.NotificationHandler signalCancel(AcpAgentSession.@Nullable NotificationHandler registered) { + return params -> { + AcpSchema.CancelNotification cancel = transport.unmarshalParams(params, + new TypeRef() { + }); + promptCancellations.cancel(cancel.sessionId()); + return (registered != null) ? registered.handle(params) : Mono.empty(); + }; + } - // Build notification handlers - Map notificationHandlers = new HashMap<>(); + /** + * The cancellation signal of the prompt running on {@code sessionId}; one that never + * fires when there is none (a context built outside a running prompt). + */ + PromptCancellations.Signal promptSignal(String sessionId) { + PromptCancellations.Signal signal = promptCancellations.current(sessionId); + return (signal != null) ? signal : new PromptCancellations.Signal(); + } - // Cancel handler - if (cancelHandler != null) { - notificationHandlers.put(AcpSchema.METHOD_SESSION_CANCEL, params -> { - AcpSchema.CancelNotification notification = transport.unmarshalFrom(params, - new TypeRef() { - }); - return cancelHandler.handle(notification); - }); - } + /** + * Whether the prompt running on {@code sessionId} has been answered; always false when no + * prompt is running (a context built outside a running prompt). + */ + BooleanSupplier promptAnswered(String sessionId) { + AcpAgentSession current = this.session; + return (current != null) ? current.promptAnswered(sessionId) : () -> false; + } - // Create and start the session - this.session = new AcpAgentSession(requestTimeout, transport, requestHandlers, notificationHandlers); + private AcpAgentSession.NotificationHandler sessionHandler(AgentHandlers.Notification registration) { + return params -> registration.handler() + .handle(transport.unmarshalParams(params, registration.notificationType()), this); + } - logger.info("ACP async agent started"); - }); + /** + * Captures what the client offered in its initialize request, before the initialize + * handler runs, so the capability checks below see it. + */ + private void recordClientCapabilities(Object request) { + if (request instanceof AcpSchema.InitializeRequest initialize) { + NegotiatedCapabilities caps = NegotiatedCapabilities.fromClient(initialize.clientCapabilities()); + clientCapabilities.set(caps); + logger.debug("Negotiated client capabilities: {}", caps); + } } @Override @@ -330,144 +182,149 @@ public Mono awaitTermination() { } @Override - public NegotiatedCapabilities getClientCapabilities() { + public @Nullable NegotiatedCapabilities getClientCapabilities() { return clientCapabilities.get(); } @Override public Mono sendSessionUpdate(String sessionId, AcpSchema.SessionUpdate update) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - AcpSchema.SessionNotification notification = new AcpSchema.SessionNotification(sessionId, update); - return session.sendNotification(AcpSchema.METHOD_SESSION_UPDATE, notification); + return sendNotification(AcpSchema.METHOD_SESSION_UPDATE, new AcpSchema.SessionNotification(sessionId, update)); } @Override public Mono requestPermission(AcpSchema.RequestPermissionRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - return session.sendRequest(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, request, + return sendRequest(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, request, new TypeRef() { }); } @Override public Mono readTextFile(AcpSchema.ReadTextFileRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - // Validate client supports file reading - NegotiatedCapabilities caps = clientCapabilities.get(); - if (caps != null && !caps.supportsReadTextFile()) { - return Mono.error(new AcpCapabilityException("fs.readTextFile")); - } - return session.sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, request, - new TypeRef() { - }); + return sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, request, new TypeRef() { + }, NegotiatedCapabilities::supportsReadTextFile, "fs.readTextFile"); } @Override public Mono writeTextFile(AcpSchema.WriteTextFileRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - // Validate client supports file writing - NegotiatedCapabilities caps = clientCapabilities.get(); - if (caps != null && !caps.supportsWriteTextFile()) { - return Mono.error(new AcpCapabilityException("fs.writeTextFile")); - } - return session.sendRequest(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, request, + return sendRequest(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, request, new TypeRef() { - }); + }, NegotiatedCapabilities::supportsWriteTextFile, "fs.writeTextFile"); } @Override public Mono createTerminal(AcpSchema.CreateTerminalRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - // Validate client supports terminal - NegotiatedCapabilities caps = clientCapabilities.get(); - if (caps != null && !caps.supportsTerminal()) { - return Mono.error(new AcpCapabilityException("terminal")); - } - return session.sendRequest(AcpSchema.METHOD_TERMINAL_CREATE, request, - new TypeRef() { - }); + return sendRequest(AcpSchema.METHOD_TERMINAL_CREATE, request, new TypeRef() { + }, NegotiatedCapabilities::supportsTerminal, "terminal"); } @Override public Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - return session.sendRequest(AcpSchema.METHOD_TERMINAL_OUTPUT, request, - new TypeRef() { - }); + return sendRequest(AcpSchema.METHOD_TERMINAL_OUTPUT, request, new TypeRef() { + }, NegotiatedCapabilities::supportsTerminal, "terminal"); } @Override public Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - return session.sendRequest(AcpSchema.METHOD_TERMINAL_RELEASE, request, + return sendRequest(AcpSchema.METHOD_TERMINAL_RELEASE, request, new TypeRef() { - }); + }, NegotiatedCapabilities::supportsTerminal, "terminal"); } @Override public Mono waitForTerminalExit( AcpSchema.WaitForTerminalExitRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - return session.sendRequest(AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, request, + return sendRequest(AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, request, new TypeRef() { - }); + }, NegotiatedCapabilities::supportsTerminal, "terminal"); } @Override public Mono killTerminal(AcpSchema.KillTerminalCommandRequest request) { - if (session == null) { - return Mono.error(new IllegalStateException("Agent not started")); - } - return session.sendRequest(AcpSchema.METHOD_TERMINAL_KILL, request, + return sendRequest(AcpSchema.METHOD_TERMINAL_KILL, request, new TypeRef() { - }); + }, NegotiatedCapabilities::supportsTerminal, "terminal"); } @Override public Mono createElicitation( AcpSchema.CreateElicitationRequest request) { - if (session == null) { + return switch (request.mode()) { + case AcpSchema.CreateElicitationRequest.MODE_FORM -> sendRequest(AcpSchema.METHOD_ELICITATION_CREATE, + request, ELICITATION_RESPONSE, NegotiatedCapabilities::supportsElicitationForm, "elicitation.form"); + case AcpSchema.CreateElicitationRequest.MODE_URL -> sendRequest(AcpSchema.METHOD_ELICITATION_CREATE, + request, ELICITATION_RESPONSE, NegotiatedCapabilities::supportsElicitationUrl, "elicitation.url"); + // A mode this SDK does not know: the client can only have advertised it in a + // capability this SDK does not model, so only elicitation itself is checked. + default -> sendRequest(AcpSchema.METHOD_ELICITATION_CREATE, request, ELICITATION_RESPONSE, + NegotiatedCapabilities::supportsElicitation, "elicitation"); + }; + } + + private static final TypeRef ELICITATION_RESPONSE = new TypeRef<>() { + }; + + @Override + public Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification) { + return sendNotification(AcpSchema.METHOD_ELICITATION_COMPLETE, notification); + } + + @Override + public Mono sendExtRequest(String method, Object params, TypeRef resultType) { + ExtensionMethods.requireExtension(method); + Assert.notNull(params, "Params must not be null"); + Assert.notNull(resultType, "Result type must not be null"); + return sendRequest(method, params, resultType); + } + + @Override + public Mono sendExtRequest(String method, Object params) { + return sendExtRequest(method, params, AgentHandlers.RAW_PARAMS); + } + + @Override + public Mono sendExtNotification(String method, Object params) { + ExtensionMethods.requireExtension(method); + Assert.notNull(params, "Params must not be null"); + return sendNotification(method, params); + } + + private Mono sendRequest(String method, Object request, TypeRef responseType) { + return sendRequest(method, request, responseType, caps -> true, method); + } + + /** + * Sends a request to the client, unless the agent is not started or the client said + * during initialization that it does not support the capability the request needs. + * Before initialization nothing is known, and the request is sent. + */ + private Mono sendRequest(String method, Object request, TypeRef responseType, + Predicate clientSupports, String capability) { + AcpAgentSession current = this.session; + if (current == null) { return Mono.error(new IllegalStateException("Agent not started")); } NegotiatedCapabilities caps = clientCapabilities.get(); - if (caps != null && !caps.supportsElicitation()) { - return Mono.error(new AcpCapabilityException("elicitation")); + if (caps != null && !clientSupports.test(caps)) { + return Mono.error(new AcpCapabilityException(capability)); } - return session.sendRequest(AcpSchema.METHOD_ELICITATION_CREATE, request, - new TypeRef() { - }); + return current.sendRequest(method, request, responseType); } - @Override - public Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification) { - if (session == null) { + private Mono sendNotification(String method, Object notification) { + AcpAgentSession current = this.session; + if (current == null) { return Mono.error(new IllegalStateException("Agent not started")); } - return session.sendNotification(AcpSchema.METHOD_ELICITATION_COMPLETE, notification); + return current.sendNotification(method, notification); } @Override public Mono closeGracefully() { return Mono.defer(() -> { logger.info("Closing ACP async agent gracefully"); - if (session != null) { - return session.closeGracefully(); + AcpAgentSession current = this.session; + if (current != null) { + return current.closeGracefully(); } return Mono.empty(); }); @@ -476,8 +333,9 @@ public Mono closeGracefully() { @Override public void close() { logger.info("Closing ACP async agent"); - if (session != null) { - session.close(); + AcpAgentSession current = this.session; + if (current != null) { + current.close(); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultInitialize.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultInitialize.java new file mode 100644 index 00000000..d2bb6e2d --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultInitialize.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.Map; +import java.util.Set; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; + +/** + * The {@code initialize} answer of a builder agent without an initialize handler: the protocol + * version negotiated with the client, and the capabilities its registered handlers imply + * ({@code session/load} advertises {@code loadSession}, {@code session/list} advertises + * {@code sessionCapabilities.list}, {@code logout} advertises {@code auth.logout}, any of the three + * {@code providers/*} methods advertises {@code providers}, and so on), + * as an annotated agent derives them from its annotations. + */ +final class DefaultInitialize { + + private static final Map SUPPORTED = Map.of(); + + private DefaultInitialize() { + } + + /** + * The default response for an agent serving {@code methods}. + * @param methods the request methods the agent has handlers for + * @param agentInfo the agent's name and version, or null to send none + * @param request the client's initialize request + * @return the response + */ + static AcpSchema.InitializeResponse respond(Set methods, AcpSchema.@Nullable Implementation agentInfo, + AcpSchema.InitializeRequest request) { + boolean anySession = methods.contains(AcpSchema.METHOD_SESSION_LIST) + || methods.contains(AcpSchema.METHOD_SESSION_CLOSE) || methods.contains(AcpSchema.METHOD_SESSION_RESUME) + || methods.contains(AcpSchema.METHOD_SESSION_DELETE) || methods.contains(AcpSchema.METHOD_SESSION_FORK); + AcpSchema.SessionCapabilities sessions = anySession ? new AcpSchema.SessionCapabilities( + supported(methods, AcpSchema.METHOD_SESSION_LIST), supported(methods, AcpSchema.METHOD_SESSION_CLOSE), + supported(methods, AcpSchema.METHOD_SESSION_RESUME), supported(methods, AcpSchema.METHOD_SESSION_DELETE), + null, supported(methods, AcpSchema.METHOD_SESSION_FORK)) : null; + AcpSchema.AgentCapabilities capabilities = AcpSchema.AgentCapabilities.builder() + .loadSession(methods.contains(AcpSchema.METHOD_SESSION_LOAD)) + .sessionCapabilities(sessions) + .auth(methods.contains(AcpSchema.METHOD_LOGOUT) ? AcpSchema.AgentAuthCapabilities.withLogout() : null) + .providers(methods.contains(AcpSchema.METHOD_PROVIDERS_LIST) + || methods.contains(AcpSchema.METHOD_PROVIDERS_SET) + || methods.contains(AcpSchema.METHOD_PROVIDERS_DISABLE) ? new AcpSchema.ProvidersCapabilities(null) + : null) + .build(); + return new AcpSchema.InitializeResponse(negotiate(request.protocolVersion()), capabilities, null, agentInfo, + null); + } + + /** The client's version when this SDK speaks it, otherwise the latest it speaks. */ + static int negotiate(@Nullable Integer requested) { + return (requested != null && requested == AcpSchema.LATEST_PROTOCOL_VERSION) ? requested + : AcpSchema.LATEST_PROTOCOL_VERSION; + } + + private static @Nullable Object supported(Set methods, String method) { + return methods.contains(method) ? SUPPORTED : null; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultPromptContext.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultPromptContext.java index 6a8fc24e..a96b8426 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultPromptContext.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultPromptContext.java @@ -6,9 +6,14 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.AgentMessageChunk; import com.agentclientprotocol.sdk.spec.AcpSchema.AgentThoughtChunk; @@ -22,11 +27,16 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.RequestPermissionRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.TerminalOutputRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.spec.AcpSchema.ToolCall; import com.agentclientprotocol.sdk.spec.AcpSchema.ToolCallStatus; import com.agentclientprotocol.sdk.spec.AcpSchema.ToolCallUpdate; +import com.agentclientprotocol.sdk.spec.AcpSchema.ToolCallUpdateNotification; import com.agentclientprotocol.sdk.spec.AcpSchema.ToolKind; import com.agentclientprotocol.sdk.spec.AcpSchema.WaitForTerminalExitRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.WriteTextFileRequest; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** @@ -42,10 +52,20 @@ */ class DefaultPromptContext implements PromptContext { + private static final Logger logger = LoggerFactory.getLogger(DefaultPromptContext.class); + private final AcpAsyncAgent agent; private final String sessionId; + private final PromptCancellations.Signal cancellation; + + /** The protocol calls, through the agent. */ + private final SessionClient client; + + /** Whether this context's prompt has been answered: its updates are then dropped. */ + private final BooleanSupplier answered; + /** * Creates a new prompt context wrapping the given agent. * @param agent The agent to delegate to @@ -54,6 +74,11 @@ class DefaultPromptContext implements PromptContext { DefaultPromptContext(AcpAsyncAgent agent, String sessionId) { this.agent = agent; this.sessionId = sessionId; + this.client = new DefaultSessionClient(agent); + this.cancellation = (agent instanceof DefaultAcpAsyncAgent running) ? running.promptSignal(sessionId) + : new PromptCancellations.Signal(); + this.answered = (agent instanceof DefaultAcpAsyncAgent running) ? running.promptAnswered(sessionId) + : () -> false; } // ======================================================================== @@ -61,65 +86,40 @@ class DefaultPromptContext implements PromptContext { // ======================================================================== @Override - public Mono sendUpdate(String sessionId, AcpSchema.SessionUpdate update) { - return agent.sendSessionUpdate(sessionId, update); - } - - @Override - public Mono readTextFile(AcpSchema.ReadTextFileRequest request) { - return agent.readTextFile(request); - } - - @Override - public Mono writeTextFile(AcpSchema.WriteTextFileRequest request) { - return agent.writeTextFile(request); - } - - @Override - public Mono requestPermission(AcpSchema.RequestPermissionRequest request) { - return agent.requestPermission(request); - } - - @Override - public Mono createTerminal(AcpSchema.CreateTerminalRequest request) { - return agent.createTerminal(request); - } - - @Override - public Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request) { - return agent.getTerminalOutput(request); - } - - @Override - public Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { - return agent.releaseTerminal(request); + public Mono sendSessionUpdate(AcpSchema.SessionUpdate update) { + return Mono.defer(() -> { + if (this.answered.getAsBoolean()) { + // ACP: a prompt's session/update notifications must precede its answer. + logger.debug("Dropped a {} update for session {}: its prompt has been answered", + update.getClass().getSimpleName(), sessionId); + return Mono.empty(); + } + return agent.sendSessionUpdate(sessionId, update); + }); } @Override - public Mono waitForTerminalExit( - AcpSchema.WaitForTerminalExitRequest request) { - return agent.waitForTerminalExit(request); + public SessionClient client() { + return client; } @Override - public Mono killTerminal(AcpSchema.KillTerminalCommandRequest request) { - return agent.killTerminal(request); + public @Nullable NegotiatedCapabilities getClientCapabilities() { + return agent.getClientCapabilities(); } - @Override - public Mono createElicitation( - AcpSchema.CreateElicitationRequest request) { - return agent.createElicitation(request); - } + // ======================================================================== + // Cancellation + // ======================================================================== @Override - public Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification) { - return agent.completeElicitation(notification); + public boolean isCancelled() { + return cancellation.isCancelled(); } @Override - public NegotiatedCapabilities getClientCapabilities() { - return agent.getClientCapabilities(); + public Mono whenCancelled() { + return cancellation.whenCancelled(); } // ======================================================================== @@ -133,12 +133,12 @@ public String getSessionId() { @Override public Mono sendMessage(String text) { - return sendUpdate(sessionId, new AgentMessageChunk("agent_message_chunk", new TextContent(text))); + return sendSessionUpdate(new AgentMessageChunk(new TextContent(text))); } @Override public Mono sendThought(String text) { - return sendUpdate(sessionId, new AgentThoughtChunk("agent_thought_chunk", new TextContent(text))); + return sendSessionUpdate(new AgentThoughtChunk(new TextContent(text))); } @Override @@ -147,31 +147,56 @@ public Mono readFile(String path) { } @Override - public Mono readFile(String path, Integer startLine, Integer lineCount) { - return readTextFile(new ReadTextFileRequest(sessionId, path, startLine, lineCount)) + public Mono readFile(String path, @Nullable Integer startLine, @Nullable Integer lineCount) { + return client.readTextFile(new ReadTextFileRequest(sessionId, path, startLine, lineCount)) .map(AcpSchema.ReadTextFileResponse::content); } @Override public Mono writeFile(String path, String content) { - return writeTextFile(new WriteTextFileRequest(sessionId, path, content)).then(); + return client.writeTextFile(new WriteTextFileRequest(sessionId, path, content)).then(); } @Override public Mono askPermission(String action) { - ToolCallUpdate toolCall = new ToolCallUpdate( - UUID.randomUUID().toString(), action, ToolKind.EDIT, ToolCallStatus.PENDING, - null, null, null, null); + return askPermission(action, ToolKind.OTHER); + } + @Override + public Mono askPermission(String action, ToolKind kind) { List options = List.of( new PermissionOption("allow", "Allow", PermissionOptionKind.ALLOW_ONCE), new PermissionOption("deny", "Deny", PermissionOptionKind.REJECT_ONCE)); - return requestPermission(new RequestPermissionRequest(sessionId, toolCall, options)) + return ask(action, kind, options) .map(response -> response.outcome() instanceof PermissionSelected s && "allow".equals(s.optionId())); } + /** + * Asks the user about a tool call the client knows: announces it with a pending + * {@code tool_call} update, sends the permission request naming it, then settles it with a + * {@code tool_call_update}: completed once the user answered, failed if the request was + * cancelled. + */ + private Mono ask(String title, ToolKind kind, List options) { + return Mono.defer(() -> { + String toolCallId = UUID.randomUUID().toString(); + ToolCall announce = new ToolCall("tool_call", toolCallId, title, null, kind, ToolCallStatus.PENDING, null, null, + null, null, null); + ToolCallUpdate toolCall = new ToolCallUpdate(toolCallId, title, kind, ToolCallStatus.PENDING); + return sendSessionUpdate(announce) + .then(client.requestPermission(new RequestPermissionRequest(sessionId, toolCall, options))) + .flatMap(response -> { + ToolCallStatus status = (response.outcome() instanceof PermissionSelected) ? ToolCallStatus.COMPLETED + : ToolCallStatus.FAILED; + return sendSessionUpdate(new ToolCallUpdateNotification("tool_call_update", toolCallId, null, null, null, + status, null, null, null, null, null)) + .thenReturn(response); + }); + }); + } + @Override public Mono askChoice(String question, String... options) { if (options == null || options.length < 2) { @@ -184,17 +209,20 @@ public Mono askChoice(String question, String... options) { String.valueOf(i), options[i], PermissionOptionKind.ALLOW_ONCE)); } - ToolCallUpdate toolCall = new ToolCallUpdate( - UUID.randomUUID().toString(), question, ToolKind.OTHER, - ToolCallStatus.PENDING, null, null, null, null); - - return requestPermission(new RequestPermissionRequest(sessionId, toolCall, permOptions)) - .map(response -> { + return ask(question, ToolKind.OTHER, permOptions) + .flatMap(response -> { if (response.outcome() instanceof PermissionSelected s) { - int idx = Integer.parseInt(s.optionId()); - return options[idx]; + for (int i = 0; i < options.length; i++) { + if (String.valueOf(i).equals(s.optionId())) { + return Mono.just(options[i]); + } + } + return Mono.error(AcpError.rejectedResponse(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, + "unoffered-option", "The client chose option '" + s.optionId() + + "', which askChoice did not offer (0 to " + (options.length - 1) + ")", + Map.of("optionId", s.optionId()))); } - return null; + return Mono.empty(); }); } @@ -205,29 +233,54 @@ public Mono execute(String... commandAndArgs) { @Override public Mono execute(Command command) { - // Convert env map to list of EnvVariable + // Convert env map to list of EnvVariable; no variables leaves the wire field out List envList = null; - if (command.env() != null) { + if (!command.env().isEmpty()) { envList = command.env().entrySet().stream() .map(e -> new EnvVariable(e.getKey(), e.getValue())) .toList(); } - return createTerminal(new CreateTerminalRequest( + return client.createTerminal(new CreateTerminalRequest( sessionId, command.executable(), command.args(), command.cwd(), envList, command.outputByteLimit())) .flatMap(createResp -> { String terminalId = createResp.terminalId(); ReleaseTerminalRequest releaseReq = new ReleaseTerminalRequest(sessionId, terminalId); - - return waitForTerminalExit(new WaitForTerminalExitRequest(sessionId, terminalId)) - .flatMap(exitResp -> getTerminalOutput(new TerminalOutputRequest(sessionId, terminalId)) - .map(outputResp -> new CommandResult(outputResp.output(), exitResp.exitCode(), false))) + // ACP: the agent MUST release every terminal it created (terminals.mdx), so the + // terminal is released exactly once whether the command ends, a step fails, or the + // caller cancels (the prompt was cancelled or timed out). + AtomicBoolean releaseSent = new AtomicBoolean(); + Mono release = Mono.defer(() -> releaseSent.compareAndSet(false, true) + ? client.releaseTerminal(releaseReq).then() : Mono.empty()); + + return client.waitForTerminalExit(new WaitForTerminalExitRequest(sessionId, terminalId)) + .flatMap(exitResp -> client.getTerminalOutput(new TerminalOutputRequest(sessionId, terminalId)) + .map(outputResp -> new CommandResult(outputResp.output(), exitResp.exitCode(), + exitResp.signal(), outputResp.truncated()))) // Release terminal after getting result, then return result - .flatMap(result -> releaseTerminal(releaseReq).thenReturn(result)) + .flatMap(result -> release.thenReturn(result)) // On error, still release terminal before propagating error - .onErrorResume(error -> releaseTerminal(releaseReq).then(Mono.error(error))); + .onErrorResume(error -> release.then(Mono.error(error))) + // Cancelled: nobody waits for the release any more, so send it on its own + .doOnCancel(() -> release.subscribe(v -> { + }, error -> logReleaseFailure(terminalId, error))); }); } + /** + * A client's error answer is logged at WARN by its code and the SDK's description of it: + * the client's message and data are its own text, logged at DEBUG only. + */ + private static void logReleaseFailure(String terminalId, Throwable error) { + if (error instanceof AcpError clientError) { + logger.warn("Could not release terminal {} of a cancelled command: the client answered {} {}", terminalId, + clientError.getCode(), AcpErrorCodes.getDescription(clientError.getCode())); + logger.debug("Could not release terminal {}: {}", terminalId, clientError.toString()); + } + else { + logger.warn("Could not release terminal {} of a cancelled command: {}", terminalId, error.getMessage()); + } + } + } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSessionClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSessionClient.java new file mode 100644 index 00000000..8f76a063 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSessionClient.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * The {@link SessionClient} of a {@link DefaultPromptContext}: each call is the agent's. + */ +final class DefaultSessionClient implements SessionClient { + + private final AcpAsyncAgent agent; + + DefaultSessionClient(AcpAsyncAgent agent) { + this.agent = agent; + } + + @Override + public Mono readTextFile(AcpSchema.ReadTextFileRequest request) { + return agent.readTextFile(request); + } + + @Override + public Mono writeTextFile(AcpSchema.WriteTextFileRequest request) { + return agent.writeTextFile(request); + } + + @Override + public Mono requestPermission(AcpSchema.RequestPermissionRequest request) { + return agent.requestPermission(request); + } + + @Override + public Mono createTerminal(AcpSchema.CreateTerminalRequest request) { + return agent.createTerminal(request); + } + + @Override + public Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request) { + return agent.getTerminalOutput(request); + } + + @Override + public Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { + return agent.releaseTerminal(request); + } + + @Override + public Mono waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request) { + return agent.waitForTerminalExit(request); + } + + @Override + public Mono killTerminal(AcpSchema.KillTerminalCommandRequest request) { + return agent.killTerminal(request); + } + + @Override + public Mono createElicitation(AcpSchema.CreateElicitationRequest request) { + return agent.createElicitation(request); + } + + @Override + public Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification) { + return agent.completeElicitation(notification); + } + + @Override + public Mono sendExtRequest(String method, Object params, TypeRef resultType) { + return agent.sendExtRequest(method, params, resultType); + } + + @Override + public Mono sendExtRequest(String method, Object params) { + return agent.sendExtRequest(method, params); + } + + @Override + public Mono sendExtNotification(String method, Object params) { + return agent.sendExtNotification(method, params); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncPromptContext.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncPromptContext.java index 937e74ca..4b44db46 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncPromptContext.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncPromptContext.java @@ -5,9 +5,13 @@ package com.agentclientprotocol.sdk.agent; import java.util.Optional; +import java.util.concurrent.CancellationException; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.spec.SyncCalls; +import org.jspecify.annotations.Nullable; /** * Default implementation of {@link SyncPromptContext} that wraps an async {@link PromptContext} @@ -36,64 +40,38 @@ class DefaultSyncPromptContext implements SyncPromptContext { // ======================================================================== @Override - public void sendUpdate(String sessionId, AcpSchema.SessionUpdate update) { - asyncContext.sendUpdate(sessionId, update).block(); + public void sendSessionUpdate(AcpSchema.SessionUpdate update) { + SyncCalls.block(asyncContext.sendSessionUpdate(update)); } @Override - public AcpSchema.ReadTextFileResponse readTextFile(AcpSchema.ReadTextFileRequest request) { - return asyncContext.readTextFile(request).block(); + public SyncSessionClient client() { + return new DefaultSyncSessionClient(asyncContext.client()); } @Override - public AcpSchema.WriteTextFileResponse writeTextFile(AcpSchema.WriteTextFileRequest request) { - return asyncContext.writeTextFile(request).block(); - } - - @Override - public AcpSchema.RequestPermissionResponse requestPermission(AcpSchema.RequestPermissionRequest request) { - return asyncContext.requestPermission(request).block(); - } - - @Override - public AcpSchema.CreateTerminalResponse createTerminal(AcpSchema.CreateTerminalRequest request) { - return asyncContext.createTerminal(request).block(); - } - - @Override - public AcpSchema.TerminalOutputResponse getTerminalOutput(AcpSchema.TerminalOutputRequest request) { - return asyncContext.getTerminalOutput(request).block(); - } - - @Override - public AcpSchema.ReleaseTerminalResponse releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { - return asyncContext.releaseTerminal(request).block(); - } - - @Override - public AcpSchema.WaitForTerminalExitResponse waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request) { - return asyncContext.waitForTerminalExit(request).block(); + public @Nullable NegotiatedCapabilities getClientCapabilities() { + return asyncContext.getClientCapabilities(); } @Override - public AcpSchema.KillTerminalCommandResponse killTerminal(AcpSchema.KillTerminalCommandRequest request) { - return asyncContext.killTerminal(request).block(); + public PromptContext async() { + return asyncContext; } - @Override - public AcpSchema.CreateElicitationResponse createElicitation( - AcpSchema.CreateElicitationRequest request) { - return asyncContext.createElicitation(request).block(); - } + // ======================================================================== + // Cancellation + // ======================================================================== @Override - public void completeElicitation(AcpSchema.CompleteElicitationNotification notification) { - asyncContext.completeElicitation(notification).block(); + public boolean isCancelled() { + return asyncContext.isCancelled(); } @Override - public NegotiatedCapabilities getClientCapabilities() { - return asyncContext.getClientCapabilities(); + public void onCancel(Runnable action) { + Assert.notNull(action, "The action must not be null"); + asyncContext.whenCancelled().subscribe(null, null, action); } // ======================================================================== @@ -107,59 +85,68 @@ public String getSessionId() { @Override public void sendMessage(String text) { - asyncContext.sendMessage(text).block(); + SyncCalls.block(asyncContext.sendMessage(text)); } @Override public void sendThought(String text) { - asyncContext.sendThought(text).block(); + SyncCalls.block(asyncContext.sendThought(text)); } @Override public String readFile(String path) { - return asyncContext.readFile(path).block(); + return SyncBlocking.awaitResponse(asyncContext.readFile(path)); } @Override - public String readFile(String path, Integer startLine, Integer lineCount) { - return asyncContext.readFile(path, startLine, lineCount).block(); + public String readFile(String path, @Nullable Integer startLine, @Nullable Integer lineCount) { + return SyncBlocking.awaitResponse(asyncContext.readFile(path, startLine, lineCount)); } @Override public Optional tryReadFile(String path) { try { - String content = readFile(path); - return Optional.ofNullable(content); + return Optional.of(readFile(path)); + } + catch (CancellationException e) { + // The SDK cancels the handler by interrupting it: that is not a missing file. + throw e; } - catch (Exception e) { + catch (RuntimeException e) { return Optional.empty(); } } @Override public void writeFile(String path, String content) { - asyncContext.writeFile(path, content).block(); + SyncCalls.block(asyncContext.writeFile(path, content)); } @Override public boolean askPermission(String action) { - Boolean result = asyncContext.askPermission(action).block(); + Boolean result = SyncCalls.block(asyncContext.askPermission(action)); + return result != null && result; + } + + @Override + public boolean askPermission(String action, AcpSchema.ToolKind kind) { + Boolean result = SyncCalls.block(asyncContext.askPermission(action, kind)); return result != null && result; } @Override - public String askChoice(String question, String... options) { - return asyncContext.askChoice(question, options).block(); + public Optional askChoice(String question, String... options) { + return Optional.ofNullable(SyncCalls.block(asyncContext.askChoice(question, options))); } @Override public CommandResult execute(String... commandAndArgs) { - return asyncContext.execute(commandAndArgs).block(); + return SyncBlocking.awaitResponse(asyncContext.execute(commandAndArgs)); } @Override public CommandResult execute(Command command) { - return asyncContext.execute(command).block(); + return SyncBlocking.awaitResponse(asyncContext.execute(command)); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncSessionClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncSessionClient.java new file mode 100644 index 00000000..d7bc9bc7 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/DefaultSyncSessionClient.java @@ -0,0 +1,89 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.SyncCalls; +import org.jspecify.annotations.Nullable; + +/** + * The {@link SyncSessionClient} of a {@link DefaultSyncPromptContext}: each call blocks on the + * asynchronous one. + */ +final class DefaultSyncSessionClient implements SyncSessionClient { + + private final SessionClient client; + + DefaultSyncSessionClient(SessionClient client) { + this.client = client; + } + + @Override + public AcpSchema.ReadTextFileResponse readTextFile(AcpSchema.ReadTextFileRequest request) { + return SyncBlocking.awaitResponse(client.readTextFile(request)); + } + + @Override + public AcpSchema.WriteTextFileResponse writeTextFile(AcpSchema.WriteTextFileRequest request) { + return SyncBlocking.awaitResponse(client.writeTextFile(request)); + } + + @Override + public AcpSchema.RequestPermissionResponse requestPermission(AcpSchema.RequestPermissionRequest request) { + return SyncBlocking.awaitResponse(client.requestPermission(request)); + } + + @Override + public AcpSchema.CreateTerminalResponse createTerminal(AcpSchema.CreateTerminalRequest request) { + return SyncBlocking.awaitResponse(client.createTerminal(request)); + } + + @Override + public AcpSchema.TerminalOutputResponse getTerminalOutput(AcpSchema.TerminalOutputRequest request) { + return SyncBlocking.awaitResponse(client.getTerminalOutput(request)); + } + + @Override + public AcpSchema.ReleaseTerminalResponse releaseTerminal(AcpSchema.ReleaseTerminalRequest request) { + return SyncBlocking.awaitResponse(client.releaseTerminal(request)); + } + + @Override + public AcpSchema.WaitForTerminalExitResponse waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request) { + return SyncBlocking.awaitResponse(client.waitForTerminalExit(request)); + } + + @Override + public AcpSchema.KillTerminalCommandResponse killTerminal(AcpSchema.KillTerminalCommandRequest request) { + return SyncBlocking.awaitResponse(client.killTerminal(request)); + } + + @Override + public AcpSchema.CreateElicitationResponse createElicitation(AcpSchema.CreateElicitationRequest request) { + return SyncBlocking.awaitResponse(client.createElicitation(request)); + } + + @Override + public void completeElicitation(AcpSchema.CompleteElicitationNotification notification) { + SyncCalls.block(client.completeElicitation(notification)); + } + + @Override + public @Nullable T sendExtRequest(String method, Object params, TypeRef resultType) { + return SyncCalls.block(client.sendExtRequest(method, params, resultType)); + } + + @Override + public @Nullable Object sendExtRequest(String method, Object params) { + return SyncCalls.block(client.sendExtRequest(method, params)); + } + + @Override + public void sendExtNotification(String method, Object params) { + SyncCalls.block(client.sendExtNotification(method, params)); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptCancellations.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptCancellations.java new file mode 100644 index 00000000..f7d0ce50 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptCancellations.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The cancellation signal of each prompt an agent is running, which its {@link PromptContext} + * reads. The session allows one prompt per session id at a time, so a {@code session/cancel} + * names its prompt by session id; a {@code $/cancel_request}, a deadline or a closing + * connection cancels the handler's subscription, which signals too. + */ +final class PromptCancellations { + + /** One prompt's signal: set once, never cleared. */ + static final class Signal { + + private final AtomicBoolean cancelled = new AtomicBoolean(); + + private final Sinks.Empty sink = Sinks.empty(); + + /** Signals the cancel; only the first call does anything. */ + void cancel() { + if (this.cancelled.compareAndSet(false, true)) { + this.sink.tryEmitEmpty(); + } + } + + boolean isCancelled() { + return this.cancelled.get(); + } + + Mono whenCancelled() { + return this.sink.asMono(); + } + + } + + private final ConcurrentHashMap active = new ConcurrentHashMap<>(); + + /** + * Starts the signal of a prompt on {@code sessionId}, replacing that of an earlier prompt + * on the session that has not ended yet. + */ + Signal start(String sessionId) { + Signal signal = new Signal(); + this.active.put(sessionId, signal); + return signal; + } + + /** Ends a prompt's signal, if it is still the session's. */ + void end(String sessionId, Signal signal) { + this.active.remove(sessionId, signal); + } + + /** Signals the prompt running on {@code sessionId}, if there is one. */ + void cancel(@Nullable String sessionId) { + if (sessionId == null) { + return; + } + Signal signal = this.active.get(sessionId); + if (signal != null) { + signal.cancel(); + } + } + + /** The signal of the prompt running on {@code sessionId}, or null. */ + @Nullable Signal current(String sessionId) { + return this.active.get(sessionId); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptContext.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptContext.java index 7655cd0b..942f0503 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptContext.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/PromptContext.java @@ -6,44 +6,52 @@ import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; /** - * Context provided to prompt handlers for accessing agent capabilities. + * A prompt handler's way back to the client during one prompt turn: the session ID, session + * updates, and the requests an agent sends the client (files, terminals, permission and + * elicitation). Every {@link AcpAgent.PromptHandler} receives one with each {@code session/prompt}, + * and every call returns a Reactor {@code Mono}. Handlers on the synchronous builder receive a + * {@link SyncPromptContext} instead, whose calls block. * - *

- * This interface provides prompt handlers with everything they need to: - *

    - *
  • Send session updates back to the client
  • - *
  • Request file operations from the client
  • - *
  • Request permissions from the client
  • - *
  • Create and manage terminals
  • - *
  • Query client capabilities
  • - *
+ *

It has two levels. The context itself holds the convenience calls, which fill in the + * session ID for you: {@link #sendMessage(String)} and {@link #sendThought(String)} send text + * chunks and {@link #sendSessionUpdate} any session update, {@link #readFile(String)} and + * {@link #writeFile(String, String)} wrap the file requests, {@link #askPermission(String)} and + * {@link #askChoice(String, String...)} wrap a permission request, and {@link #execute(Command)} + * runs a command in a client terminal from creation to release. One step down, + * {@link #client()} holds the protocol calls ({@code readTextFile}, {@code createTerminal}, + * {@code requestPermission}, {@code createElicitation} and the rest), which take ACP request + * records and return the client's answers. * - *

- * This follows the MCP SDK's "Exchange" pattern where handlers receive - * a context object with all necessary capabilities, eliminating the need - * for external references to the agent instance. - * - *

- * Example usage: *

{@code
- * agent.promptHandler((request, context) -> {
- *     // Send an update
- *     context.sendUpdate(sessionId, update);
- *
- *     // Read a file (if client supports it)
- *     if (context.getClientCapabilities().supportsReadTextFile()) {
- *         var content = context.readTextFile(new ReadTextFileRequest(...)).block();
- *     }
+ * AcpAgent.async(transport)
+ *     .promptHandler((request, context) -> context.sendThought("Reading the build file")
+ *         .then(context.readFile("/workspace/pom.xml"))
+ *         .flatMap(pom -> context.sendMessage("The build file has " + pom.length() + " chars"))
+ *         .thenReturn(AcpSchema.PromptResponse.endTurn()))
+ *     .build();
+ * }
* - * // Request permission - * var permission = context.requestPermission(new RequestPermissionRequest(...)); + *

Calls go through the agent ({@link AcpAsyncAgent}) and behave as its calls do. Nothing is sent + * until the {@code Mono} is subscribed. Once the client has initialized, a call that needs a + * capability the client did not advertise (reading or writing files, any terminal method, an + * elicitation mode) fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} + * without being sent; check {@link #getClientCapabilities()} first. An error answer fails the + * {@code Mono} with {@link com.agentclientprotocol.sdk.spec.AcpError}, and no answer within the + * agent's request timeout fails it with a {@link java.util.concurrent.TimeoutException} (the + * blocking {@link SyncPromptContext} throws an + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException} wrapping it instead). When the + * SDK cancels the handler (after the cancel grace period, or for a {@code $/cancel_request}), it + * disposes the handler's {@code Mono}: requests still waiting inside it are cancelled, and the + * client is sent a {@code $/cancel_request} for each. Once the prompt has been answered, its + * updates are dropped (see {@link #sendSessionUpdate}); its other calls are not checked against the + * turn. Its methods may be called from several threads at once. * - * return Mono.just(new PromptResponse(StopReason.END_TURN)); - * }); - * } + *

Implementations: the SDK supplies the context handlers receive; implement this interface only + * for test doubles. * * @author Mark Pollack * @since 0.9.1 @@ -57,229 +65,230 @@ public interface PromptContext { // ======================================================================== /** - * Sends a session update notification to the client. - * Used for streaming updates during prompt processing. - * @param sessionId The session ID - * @param update The session update to send - * @return A Mono that completes when the notification is sent + * Sends a {@code session/update} notification to the client, carrying one + * {@link AcpSchema.SessionUpdate}: a message or thought chunk, a tool call or its update, a + * plan, and so on, for this prompt's session ({@link #getSessionId()}). The Java client hands a + * turn's updates to its update handlers in order, before the prompt's answer. Once the prompt has + * been answered, by the handler or by the SDK when a prompt deadline passed, the SDK's context + * drops further updates (logged at DEBUG) and the {@code Mono} completes empty: ACP requires a + * prompt's updates to precede its answer. To update another + * session, use {@link AcpAsyncAgent#sendSessionUpdate(String, AcpSchema.SessionUpdate)}. + * @param update the update + * @return a {@code Mono} that completes when the notification has been handed to the transport */ - Mono sendUpdate(String sessionId, AcpSchema.SessionUpdate update); + Mono sendSessionUpdate(AcpSchema.SessionUpdate update); // ======================================================================== - // File System Operations + // Protocol Layer // ======================================================================== /** - * Requests the client to read a text file. - * @param request The read file request - * @return A Mono containing the file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Returns the raw ACP requests to the client, for what the convenience calls on this context + * do not cover: {@code fs/read_text_file} and {@code fs/write_text_file} with the request + * records, {@code session/request_permission} for a tool call the agent announced itself, the + * five {@code terminal/*} methods one by one, and elicitation. The calls behave as this + * context's do, and are cancelled with the prompt handler. + * @return the client's protocol calls for this prompt */ - Mono readTextFile(AcpSchema.ReadTextFileRequest request); - - /** - * Requests the client to write a text file. - * @param request The write file request - * @return A Mono that completes when the file is written - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file writing - */ - Mono writeTextFile(AcpSchema.WriteTextFileRequest request); + SessionClient client(); // ======================================================================== - // Permission Requests + // Client Capabilities // ======================================================================== /** - * Requests permission from the client for a sensitive operation. - * @param request The permission request - * @return A Mono containing the permission response + * Returns what the client advertised in its {@code initialize} request. Check it before a call + * that needs a capability, for example {@code supportsReadTextFile()} before + * {@link #readFile(String)}. + * @return the client's capabilities, or {@code null} if the client has not initialized */ - Mono requestPermission(AcpSchema.RequestPermissionRequest request); + @Nullable NegotiatedCapabilities getClientCapabilities(); // ======================================================================== - // Terminal Operations + // Cancellation // ======================================================================== /** - * Requests the client to create a terminal. - * @param request The create terminal request - * @return A Mono containing the terminal ID - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals - */ - Mono createTerminal(AcpSchema.CreateTerminalRequest request); - - /** - * Requests terminal output from the client. - * @param request The terminal output request - * @return A Mono containing the terminal output - */ - Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request); - - /** - * Requests the client to release a terminal. - * @param request The release terminal request - * @return A Mono that completes when the terminal is released - */ - Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request); - - /** - * Waits for a terminal to exit. - * @param request The wait for exit request - * @return A Mono containing the exit status - */ - Mono waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request); - - /** - * Requests the client to kill a terminal. - * @param request The kill terminal request - * @return A Mono that completes when the terminal is killed - */ - Mono killTerminal(AcpSchema.KillTerminalCommandRequest request); - - // ======================================================================== - // Elicitation - // ======================================================================== - - /** - * Requests structured user input from the client via a form or URL. - * @param request The elicitation request - * @return A Mono containing the user's response + * Whether this prompt has been cancelled: by {@code session/cancel} or {@code session/close} + * for its session, by {@code $/cancel_request} for its request, or by the agent itself (the cancel grace + * period or the maximum prompt duration passed, or the connection closed). Once true, it + * stays true. + * + *

+ * After {@code session/cancel}, stop the prompt's work, send any last updates, and answer + * with stop reason {@code cancelled} ({@link AcpSchema.PromptResponse#cancelled()}), within + * the cancel grace period ({@code cancelGracePeriod}, 60 seconds by default); once it passes + * the agent answers {@code cancelled} itself and cancels the handler. After + * {@code $/cancel_request} the agent has already answered (error {@code -32800}, or + * {@code cancelled} if the session was cancelled too), so what the handler returns is + * discarded: just stop. + *

+ * @return whether the prompt has been cancelled + * @see #whenCancelled() */ - Mono createElicitation(AcpSchema.CreateElicitationRequest request); + boolean isCancelled(); /** - * Notifies the client that a URL-mode elicitation has completed. - * @param notification The completion notification - * @return A Mono that completes when the notification is sent + * Completes, empty, when this prompt is cancelled (see {@link #isCancelled()}), at once if + * it already has been; it never errors, and never completes for a prompt that is not + * cancelled. Compose it into the handler's {@code Mono}, for example + * {@code work.takeUntilOther(context.whenCancelled())}, to stop on a cancel. + * @return a Mono completing when the prompt is cancelled */ - Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification); - - // ======================================================================== - // Client Capabilities - // ======================================================================== - - /** - * Returns the capabilities negotiated with the client during initialization. - * - *

- * Use this to check what features the client supports before calling - * methods like {@link #readTextFile} or {@link #createTerminal}. - * - * @return the negotiated client capabilities, or null if not yet initialized - */ - NegotiatedCapabilities getClientCapabilities(); + Mono whenCancelled(); // ======================================================================== // Convenience API // ======================================================================== /** - * Returns the session ID for this prompt invocation. - * @return the session ID + * Returns the ID of the ACP session this prompt belongs to. + * @return the session ID from the prompt request */ String getSessionId(); /** - * Sends a message to the client as an agent message chunk. - * This is a convenience method that wraps the text in the appropriate - * session update structure. - * @param text The message text to send - * @return A Mono that completes when the message is sent + * Sends text to the client as an agent message chunk, the visible reply of this prompt's + * session. Call it as often as needed: the client shows the chunks as one growing message. + * @param text the text + * @return a {@code Mono} that completes when the update has been handed to the transport */ Mono sendMessage(String text); /** - * Sends a message to the client as an agent message chunk, tagged with a message ID. - * Chunks sharing the same {@code messageId} belong to the same logical message; a change - * in {@code messageId} signals a new message. - * @param text The message text to send - * @param messageId The message identifier, or {@code null} for none - * @return A Mono that completes when the message is sent + * Sends text to the client as an agent message chunk that belongs to the given message. Chunks + * with the same {@code messageId} make up one message; a new {@code messageId} starts a new + * message. + * + *

Implementations get a default that calls {@link #sendSessionUpdate} with an + * {@link AcpSchema.AgentMessageChunk} holding the text. + * @param text the text + * @param messageId the message ID, or {@code null} for none + * @return a {@code Mono} that completes when the update has been handed to the transport */ - default Mono sendMessage(String text, String messageId) { - return sendUpdate(getSessionId(), - new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent(text), messageId)); + default Mono sendMessage(String text, @Nullable String messageId) { + return sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent(text), messageId)); } /** - * Sends a thought to the client as an agent thought chunk. - * Thoughts are typically displayed differently than messages, - * showing the agent's reasoning process. - * @param text The thought text to send - * @return A Mono that completes when the thought is sent + * Sends text to the client as an agent thought chunk: the agent's reasoning, which clients + * usually show apart from the reply. + * @param text the text + * @return a {@code Mono} that completes when the update has been handed to the transport */ Mono sendThought(String text); /** - * Sends a thought to the client as an agent thought chunk, tagged with a message ID. - * Chunks sharing the same {@code messageId} belong to the same logical message. - * @param text The thought text to send - * @param messageId The message identifier, or {@code null} for none - * @return A Mono that completes when the thought is sent + * Sends text to the client as an agent thought chunk that belongs to the given message. Chunks + * with the same {@code messageId} make up one message. + * + *

Implementations get a default that calls {@link #sendSessionUpdate} with an {@link AcpSchema.AgentThoughtChunk} holding the text. + * @param text the text + * @param messageId the message ID, or {@code null} for none + * @return a {@code Mono} that completes when the update has been handed to the transport */ - default Mono sendThought(String text, String messageId) { - return sendUpdate(getSessionId(), - new AcpSchema.AgentThoughtChunk("agent_thought_chunk", new AcpSchema.TextContent(text), messageId)); + default Mono sendThought(String text, @Nullable String messageId) { + return sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent(text), messageId)); } /** - * Reads a text file from the client's file system. - * @param path The path to the file - * @return A Mono containing the file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Reads a whole text file through the client ({@code fs/read_text_file}) for this session. + * @param path the absolute path of the file + * @return a {@code Mono} emitting the file content; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise {@code fs.readTextFile} */ Mono readFile(String path); /** - * Reads a portion of a text file from the client's file system. - * @param path The path to the file - * @param startLine The line number to start reading from (0-indexed, null for beginning) - * @param lineCount The number of lines to read (null for all remaining) - * @return A Mono containing the file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Reads part of a text file through the client ({@code fs/read_text_file}) for this session. + * The line numbers go to the client unchanged; ACP counts lines from 1. + * @param path the absolute path of the file + * @param startLine the first line to read, counting from 1, or {@code null} for the first line + * @param lineCount the most lines to read, or {@code null} for the rest of the file + * @return a {@code Mono} emitting the content read; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise {@code fs.readTextFile} */ - Mono readFile(String path, Integer startLine, Integer lineCount); + Mono readFile(String path, @Nullable Integer startLine, @Nullable Integer lineCount); /** - * Writes content to a text file on the client's file system. - * @param path The path to the file - * @param content The content to write - * @return A Mono that completes when the file is written - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file writing + * Writes a text file through the client ({@code fs/write_text_file}) for this session. + * @param path the absolute path of the file + * @param content the new content of the whole file + * @return a {@code Mono} that completes once the client has written the file; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise {@code fs.writeTextFile} */ Mono writeFile(String path, String content); /** - * Asks the client for permission to perform an action. - * Presents a simple Allow/Deny choice. - * @param action A description of the action to request permission for - * @return A Mono containing true if allowed, false otherwise + * Asks the user to allow or deny an action, with a permission request of two options: "Allow" + * (allow once) and "Deny" (reject once), as {@link #askPermission(String, AcpSchema.ToolKind)} + * does with the kind {@code other}. + * @param action what the agent wants to do, shown to the user as the tool call's title + * @return a {@code Mono} emitting {@code true} only if the user chose "Allow"; {@code false} if + * the user denied, or the client answered without a selected option (it cancelled the + * request, or sent an outcome this SDK does not know) */ Mono askPermission(String action); /** - * Asks the client to choose from multiple options. - * @param question The question to ask - * @param options The available options (at least 2) - * @return A Mono containing the selected option text, or null if cancelled + * Asks the user to allow or deny an action of the given kind, with a permission request of two + * options: "Allow" (allow once) and "Deny" (reject once). ACP asks permission for a tool call, + * so the context first announces one: a {@code tool_call} session update with a new random ID, + * the action as its title, the kind, and status {@code pending}. The permission request names + * that tool call, and once the user answered, a {@code tool_call_update} sets its status to + * {@code completed} (an option was selected, either way) or {@code failed} (any other + * outcome: the client cancelled the request, or sent an outcome this SDK does not know). For a + * tool call the agent announced itself, send {@link SessionClient#requestPermission} through + * {@link #client()} instead. + * @param action what the agent wants to do, shown to the user as the tool call's title + * @param kind the kind of tool call, which clients use to pick an icon, for example + * {@code execute} for a command + * @return a {@code Mono} emitting {@code true} only if the user chose "Allow"; {@code false} if + * the user denied, or the client answered without a selected option (it cancelled the + * request, or sent an outcome this SDK does not know) + */ + Mono askPermission(String action, AcpSchema.ToolKind kind); + + /** + * Asks the user to pick one of several options, with a permission request whose options are the + * given texts. Like {@link #askPermission(String, AcpSchema.ToolKind)}, it announces a pending + * tool call with a new random ID, the question as its title and the kind {@code other}, and + * settles it once the user answered; every option has the kind "allow once", and its ID is its + * position in {@code options}. + * @param question the question, shown to the user as the tool call's title + * @param options the texts to choose from, at least two + * @return a {@code Mono} emitting the text of the chosen option, or completing empty if the + * client answered without a selected option (it cancelled the request, or sent an outcome + * this SDK does not know); it fails with {@link IllegalArgumentException} if fewer than + * two options are given, and with an + * {@link com.agentclientprotocol.sdk.spec.AcpError} ({@code -32603}) if the client + * answers with an option ID it was not offered */ Mono askChoice(String question, String... options); /** - * Executes a command in a terminal and waits for completion. - * The terminal is automatically released after execution. - * @param commandAndArgs The command and arguments to execute - * @return A Mono containing the command result - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals + * Runs a command in a client terminal and returns its output and exit status, as + * {@link #execute(Command)} does with {@code Command.of(commandAndArgs)}. + * @param commandAndArgs the executable, then its arguments + * @return a {@code Mono} emitting the result; see {@link #execute(Command)} */ Mono execute(String... commandAndArgs); /** - * Executes a command with options and waits for completion. - * The terminal is automatically released after execution. - * @param command The command configuration - * @return A Mono containing the command result - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals + * Runs a command in a client terminal and returns its output and exit status. It creates a + * terminal, waits for the command to end, reads the output, then releases the terminal: always, + * also when a step fails or the returned {@code Mono} is cancelled (for example because the + * prompt was cancelled), as ACP requires of an agent. Waiting counts against the agent's + * request timeout, so a command that runs longer fails with a + * {@link java.util.concurrent.TimeoutException}, and the terminal is still released; raise the + * builder's {@code requestTimeout} for long commands. + * @param command the command and its options + * @return a {@code Mono} emitting the result; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise {@code terminal}, and with the error of the first step that failed */ Mono execute(Command command); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SessionClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SessionClient.java new file mode 100644 index 00000000..c0df8252 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SessionClient.java @@ -0,0 +1,185 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * The protocol layer of a prompt turn: the raw ACP requests an agent sends the client (files, + * terminals, permission and elicitation), each taking the ACP request record and returning the + * client's answer as a Reactor {@code Mono}. Get it from {@link PromptContext#client()}; the + * context itself holds the convenience calls built on these ({@link PromptContext#readFile}, + * {@link PromptContext#askPermission}, {@link PromptContext#execute} and the rest), which fill in + * the session ID for you. Use this one for what the convenience calls do not cover: a line range, + * a permission request for a tool call the agent announced itself, a terminal the handler manages + * step by step, an elicitation, or a custom extension method of the client's ({@link #sendExtRequest}, + * {@link #sendExtNotification}). + * + *

{@code
+ * context.client()
+ *     .readTextFile(new AcpSchema.ReadTextFileRequest(context.getSessionId(), path, 10, 20))
+ *     .map(AcpSchema.ReadTextFileResponse::content)
+ * }
+ * + *

The calls are those of {@link AcpAsyncAgent}, with the same checks: once the client has + * initialized, a call that needs a capability the client did not advertise (reading or writing + * files, any terminal method, an elicitation mode) fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. An error + * answer fails the {@code Mono} with {@link com.agentclientprotocol.sdk.spec.AcpError}, and no + * answer within the agent's request timeout with a {@link java.util.concurrent.TimeoutException}. + * Nothing is sent until the {@code Mono} is subscribed, and a request still waiting when the SDK + * cancels the prompt handler is cancelled with it. The requests carry their own session ID, which + * should be the prompt's ({@link PromptContext#getSessionId()}). + * + *

Implementations: the SDK supplies it; implement this interface only for test doubles. + * + * @author Mark Pollack + * @see PromptContext#client() + * @see SyncSessionClient + */ +public interface SessionClient { + + /** + * Asks the client for the content of a text file ({@code fs/read_text_file}), including unsaved + * changes in its editor. The client must have advertised {@code fs.readTextFile}. + * @param request the session, an absolute path, and optionally a 1-based first line and a line + * limit + * @return a {@code Mono} emitting the file content; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability + */ + Mono readTextFile(AcpSchema.ReadTextFileRequest request); + + /** + * Asks the client to write a text file ({@code fs/write_text_file}); ACP requires the client to + * create the file if it does not exist. The client must have advertised + * {@code fs.writeTextFile}. + * @param request the session, an absolute path and the new content + * @return a {@code Mono} emitting the client's empty answer once the file is written; it fails + * with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability + */ + Mono writeTextFile(AcpSchema.WriteTextFileRequest request); + + /** + * Asks the client to let the user approve a tool call ({@code session/request_permission}). The + * request describes the tool call and the options to choose from; every client handles this + * request, so no capability is checked. + * @param request the session, the tool call and the permission options + * @return a {@code Mono} emitting the user's choice: the selected option, or a cancelled + * outcome when the client cancelled the prompt turn + */ + Mono requestPermission(AcpSchema.RequestPermissionRequest request); + + /** + * Asks the client to start a command in a new terminal ({@code terminal/create}). The client + * must have advertised {@code terminal}. ACP requires the agent to release every terminal it + * creates with {@link #releaseTerminal}; {@link PromptContext#execute(Command)} does the whole + * sequence for you. + * @param request the session, the command, its arguments and optionally a working directory, + * environment variables and an output limit + * @return a {@code Mono} emitting the new terminal's ID; it fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the client did not + * advertise the capability + */ + Mono createTerminal(AcpSchema.CreateTerminalRequest request); + + /** + * Asks the client for a terminal's output so far ({@code terminal/output}), without waiting for + * the command to end. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the output, whether it was truncated, and the exit status if + * the command has ended + */ + Mono getTerminalOutput(AcpSchema.TerminalOutputRequest request); + + /** + * Asks the client to release a terminal ({@code terminal/release}), which kills its command if + * it is still running. The terminal ID is invalid afterwards. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the client's empty answer once the terminal is released + */ + Mono releaseTerminal(AcpSchema.ReleaseTerminalRequest request); + + /** + * Asks the client to answer when a terminal's command has ended + * ({@code terminal/wait_for_exit}). The wait counts against the agent's request timeout, so a + * long command can fail it with a {@link java.util.concurrent.TimeoutException}. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the exit code or the signal that ended the command + */ + Mono waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request); + + /** + * Asks the client to kill a terminal's command ({@code terminal/kill}) without releasing the + * terminal: its output and exit status can still be read, and it must still be released. + * @param request the session and the terminal ID + * @return a {@code Mono} emitting the client's empty answer once the command is killed + */ + Mono killTerminal(AcpSchema.KillTerminalCommandRequest request); + + /** + * Asks the client to collect structured input from the user ({@code elicitation/create}), + * either with a form built from a schema or by sending the user to a URL. The client must have + * advertised the request's mode in its elicitation capabilities. + * @param request the elicitation, made with {@link AcpSchema.CreateElicitationRequest#form} or + * {@link AcpSchema.CreateElicitationRequest#url} + * @return a {@code Mono} emitting the user's answer: accept (with the form content), decline or + * cancel; it fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} if the + * client did not advertise the mode + */ + Mono createElicitation(AcpSchema.CreateElicitationRequest request); + + /** + * Tells the client that the outside interaction of a URL-mode elicitation has finished + * ({@code elicitation/complete}). + * @param notification the ID of the elicitation that finished + * @return a {@code Mono} that completes when the notification has been handed to the transport + */ + Mono completeElicitation(AcpSchema.CompleteElicitationNotification notification); + + /** + * Sends a custom extension request to the client and returns its result as the raw JSON value: + * a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} emitting the result, or completing empty when the client answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #sendExtRequest(String, Object, TypeRef) + */ + Mono sendExtRequest(String method, Object params); + + /** + * Sends a custom extension request ({@code _}-prefixed method name, ACP v1 Extensibility) to + * the client and reads its result as the given type, as + * {@link AcpAsyncAgent#sendExtRequest(String, Object, TypeRef)} does. A client that does not + * handle the method answers "Method not found" ({@code -32601}), which fails the {@code Mono} + * with {@link com.agentclientprotocol.sdk.spec.AcpError}. The SDK checks no capability for + * extension methods: advertise and check them in the {@code _meta} of the capability objects. + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return a {@code Mono} emitting the result, or completing empty when the client answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + Mono sendExtRequest(String method, Object params, TypeRef resultType); + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the client, as + * {@link AcpAsyncAgent#sendExtNotification(String, Object)} does. A client without a handler + * for it ignores it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} that completes when the notification has been handed to the transport + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + Mono sendExtNotification(String method, Object params); + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareHandler.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareHandler.java new file mode 100644 index 00000000..5b4586ce --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareHandler.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.spec.AcpSchema; + +/** + * The blocking counterpart of {@link AgentAwareHandler}: a request handler on the + * {@link AcpAgent.SyncAgentBuilder} that also receives the agent it serves, the {@link AcpSyncAgent} + * {@code build()} returned, so it can send session updates or call the client with blocking + * calls. Every typed setter of the synchronous builder except {@code promptHandler} has an + * overload that takes one, and so does {@code extRequestHandler} ({@code extNotificationHandler} + * takes a {@link java.util.function.BiConsumer} of the params and the agent); a two-argument lambda + * picks it. + * + *
{@code
+ * AcpSyncAgent agent = AcpAgent.sync(transport)
+ *     .loadSessionHandler((request, self) -> {
+ *         for (AcpSchema.SessionUpdate update : history.get(request.sessionId())) {
+ *             self.sendSessionUpdate(request.sessionId(), update);   // the replay
+ *         }
+ *         return new AcpSchema.LoadSessionResponse(null, null);
+ *     })
+ *     .build();
+ * }
+ * + *

The second parameter is the agent being built, the same instance {@code build()} returns; a + * lambda parameter must not reuse the name of a local variable in scope, so call it {@code self} + * (or anything but the name the built agent is assigned to), as above. + * + *

Implementations run on the builder's handler executor and may block. + * @param the request type + * @param the response type + * @author Mark Pollack + * @see AgentAwareHandler + */ +@FunctionalInterface +public interface SyncAgentAwareHandler { + + /** + * Answers one request from the client. It may block. + * @param request the request, as the client sent it; never null + * @param agent the agent serving the request, the one {@code build()} returned + * @return the answer; {@code null} is answered {@code -32603} + */ + R handle(Q request, AcpSyncAgent agent); + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareSetters.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareSetters.java new file mode 100644 index 00000000..de264900 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncAgentAwareSetters.java @@ -0,0 +1,379 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.concurrent.Callable; +import java.util.function.BiConsumer; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * The setters of {@link AcpAgent.SyncAgentBuilder} that take a {@link SyncAgentAwareHandler}: one + * overload of each typed request setter but {@code promptHandler}, and of each extension handler + * setter, registering a blocking handler that also receives the agent {@code build()} returned. Kept apart from the builder only to keep + * that class small; it is part of the builder's API. + */ +abstract class SyncAgentAwareSetters { + + /** Only {@link AcpAgent.SyncAgentBuilder} extends this class. */ + SyncAgentAwareSetters() { + } + + /** The agent {@code build()} returned, handed to the agent-aware handlers. */ + private volatile @Nullable AcpSyncAgent built; + + /** The asynchronous builder the synchronous one registers its handlers on. */ + abstract AsyncAgentAwareSetters asyncBuilder(); + + /** Runs a handler on the builder's handler executor. */ + abstract Mono onSyncHandlerThread(Callable handler); + + /** Runs a handler that returns nothing on the builder's handler executor. */ + abstract Mono runOnSyncHandlerThread(Runnable handler); + + /** Records the agent {@code build()} returns, for the agent-aware handlers. */ + AcpSyncAgent remember(AcpSyncAgent agent) { + this.built = agent; + return agent; + } + + /** The synchronous agent this builder built around the given agent (the same instance). */ + @SuppressWarnings("ReferenceEquality") + AcpSyncAgent syncView(AcpAsyncAgent agent) { + AcpSyncAgent agentBuilt = this.built; + return (agentBuilt != null && agentBuilt.async() == agent) ? agentBuilt : new AcpSyncAgent(agent); + } + + @SuppressWarnings("unchecked") + private B self() { + return (B) this; + } + + /** + * Sets the handler for {@code initialize}, as {@link AcpAgent.SyncAgentBuilder#initializeHandler(AcpAgent.SyncInitializeHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B initializeHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().initializeHandler((AcpSchema.InitializeRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code authenticate}, as {@link AcpAgent.SyncAgentBuilder#authenticateHandler(AcpAgent.SyncAuthenticateHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B authenticateHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().authenticateHandler((AcpSchema.AuthenticateRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code logout}, as {@link AcpAgent.SyncAgentBuilder#logoutHandler(AcpAgent.SyncLogoutHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B logoutHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().logoutHandler((AcpSchema.LogoutRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/new}, as {@link AcpAgent.SyncAgentBuilder#newSessionHandler(AcpAgent.SyncNewSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B newSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().newSessionHandler((AcpSchema.NewSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/load}, as {@link AcpAgent.SyncAgentBuilder#loadSessionHandler(AcpAgent.SyncLoadSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B loadSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().loadSessionHandler((AcpSchema.LoadSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/set_mode}, as {@link AcpAgent.SyncAgentBuilder#setSessionModeHandler(AcpAgent.SyncSetSessionModeHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B setSessionModeHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().setSessionModeHandler((AcpSchema.SetSessionModeRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/list}, as {@link AcpAgent.SyncAgentBuilder#listSessionsHandler(AcpAgent.SyncListSessionsHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B listSessionsHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().listSessionsHandler((AcpSchema.ListSessionsRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/close}, as {@link AcpAgent.SyncAgentBuilder#closeSessionHandler(AcpAgent.SyncCloseSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B closeSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().closeSessionHandler((AcpSchema.CloseSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/delete}, as {@link AcpAgent.SyncAgentBuilder#deleteSessionHandler(AcpAgent.SyncDeleteSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B deleteSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().deleteSessionHandler((AcpSchema.DeleteSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/resume}, as {@link AcpAgent.SyncAgentBuilder#resumeSessionHandler(AcpAgent.SyncResumeSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B resumeSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().resumeSessionHandler((AcpSchema.ResumeSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/fork}, as {@link AcpAgent.SyncAgentBuilder#forkSessionHandler(AcpAgent.SyncForkSessionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B forkSessionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().forkSessionHandler((AcpSchema.ForkSessionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code session/set_config_option}, as {@link AcpAgent.SyncAgentBuilder#setSessionConfigOptionHandler(AcpAgent.SyncSetSessionConfigOptionHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + public B setSessionConfigOptionHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().setSessionConfigOptionHandler((AcpSchema.SetSessionConfigOptionRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code providers/list}, as {@link AcpAgent.SyncAgentBuilder#listProvidersHandler(AcpAgent.SyncListProvidersHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B listProvidersHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().listProvidersHandler((AcpSchema.ListProvidersRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code providers/set}, as {@link AcpAgent.SyncAgentBuilder#setProviderHandler(AcpAgent.SyncSetProviderHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B setProviderHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().setProviderHandler((AcpSchema.SetProviderRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Sets the handler for {@code providers/disable}, as {@link AcpAgent.SyncAgentBuilder#disableProviderHandler(AcpAgent.SyncDisableProviderHandler)} + * does, for a + * handler that also needs the agent it serves, for example to send session updates + * ({@link AcpSyncAgent#sendSessionUpdate}) or call the client. + * @param handler the handler; it receives the request and the agent; must not be null + * @return this builder + */ + @UnstableAcpApi + public B disableProviderHandler( + SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().disableProviderHandler((AcpSchema.DisableProviderRequest request, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(request, syncView(agent)))); + return self(); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP v1 + * Extensibility) from the client, its params read as the given type, as + * {@link AcpAgent.SyncAgentBuilder#extRequestHandler(String, TypeRef, AcpAgent.SyncExtRequestHandler)} + * does, for a handler that also needs the agent it serves, for example to call the client back + * ({@link AcpSyncAgent#sendExtRequest(String, Object, TypeRef)}) or send session updates. Name + * the agent parameter {@code self}, not the name the built agent is assigned to. + * @param the params type + * @param the result type, any type the JSON mapper can write; returning {@code null} + * answers the request with an internal error ({@code -32603}) + * @param method the method name, which must start with {@code _} (for example + * {@code _example.com/workspace/buffers}) + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public B extRequestHandler(String method, TypeRef paramsType, SyncAgentAwareHandler handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().extRequestHandler(method, paramsType, (T params, AcpAsyncAgent agent) -> onSyncHandlerThread( + () -> handler.handle(params, syncView(agent)))); + return self(); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name) from + * the client, its params delivered as the raw JSON value (a {@code Map}, {@code List}, + * {@code String}, {@code Number} or {@code Boolean}), for a handler that also needs the agent + * it serves. + * @param the result type, any type the JSON mapper can write + * @param method the method name, which must start with {@code _} + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, SyncAgentAwareHandler) + */ + public B extRequestHandler(String method, SyncAgentAwareHandler handler) { + return extRequestHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method name) + * from the client, its params read as the given type, as + * {@link AcpAgent.SyncAgentBuilder#extNotificationHandler(String, TypeRef, AcpAgent.SyncExtNotificationHandler)} + * does, for a handler that also needs the agent it serves, for example to answer with a + * notification of its own ({@link AcpSyncAgent#sendExtNotification(String, Object)}). It runs + * on the builder's handler executor and may block; a handler that throws is only logged. + * Name the agent parameter {@code self}, not the name the built agent is assigned to. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as; must not be null + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public B extNotificationHandler(String method, TypeRef paramsType, BiConsumer handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncBuilder().extNotificationHandler(method, paramsType, (T params, AcpAsyncAgent agent) -> runOnSyncHandlerThread( + () -> handler.accept(params, syncView(agent)))); + return self(); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method name) + * from the client, its params delivered as the raw JSON value, for a handler that also needs + * the agent it serves. + * @param method the method name, which must start with {@code _} + * @param handler the handler; it receives the params and the agent; must not be null + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, BiConsumer) + */ + public B extNotificationHandler(String method, BiConsumer handler) { + return extNotificationHandler(method, AgentHandlers.RAW_PARAMS, handler); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncBlocking.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncBlocking.java new file mode 100644 index 00000000..7e25eb9f --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncBlocking.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.spec.SyncCalls; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * Blocking for the sync agent API. A request's Mono emits the peer's response or fails: + * the session delivers every result through a Reactor sink, which cannot carry null, so + * it never completes empty. An empty completion would be a broken invariant, and is + * reported as one rather than returned as a null response. + */ +final class SyncBlocking { + + private SyncBlocking() { + } + + static T awaitResponse(Mono response) { + return requireResponse(SyncCalls.block(response)); + } + + static T awaitResponse(Mono response, Duration timeout) { + return requireResponse(SyncCalls.block(response, timeout)); + } + + private static T requireResponse(@Nullable T value) { + if (value == null) { + throw new IllegalStateException("ACP request completed without a response"); + } + return value; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncHandlerScheduler.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncHandlerScheduler.java new file mode 100644 index 00000000..c515296f --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncHandlerScheduler.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.jspecify.annotations.Nullable; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * The default executor of synchronous agents' handlers, shared by every synchronous agent in the + * JVM that was not given a {@code handlerExecutor}, and created on first use: on JDK 21 and later + * a virtual thread per handler call, named {@code acp-agent-sync-handler}; before, a cached pool + * of daemon threads of that name. Its threads may block, and neither kind keeps the JVM alive. On + * JDK 21 to 23 a handler that blocks inside a {@code synchronized} block pins its carrier thread + * (JEP 491 removed that in JDK 24). + */ +final class SyncHandlerScheduler { + + static final Scheduler DEFAULT = Schedulers.fromExecutorService(executor(), "acp-agent-sync-handler"); + + private static ExecutorService executor() { + return executor(VirtualThreads.newPerTaskExecutor("acp-agent-sync-handler")); + } + + /** + * The virtual-thread executor when there is one, else the cached pool; tests give either on + * any JDK. + */ + static ExecutorService executor(@Nullable ExecutorService virtual) { + if (virtual != null) { + return virtual; + } + return Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-agent-sync-handler"); + t.setDaemon(true); + return t; + }); + } + + private SyncHandlerScheduler() { + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncPromptContext.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncPromptContext.java index ca4194e4..0f2af838 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncPromptContext.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncPromptContext.java @@ -8,33 +8,49 @@ import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; /** - * Synchronous context provided to prompt handlers for accessing agent capabilities. + * A prompt handler's way back to the client during one prompt turn, with blocking calls: the + * session ID, session updates, and the requests an agent sends the client (files, terminals, + * permission and elicitation). Every {@link AcpAgent.SyncPromptHandler} receives one with each + * {@code session/prompt}; each call returns once the client has answered. Handlers on the + * asynchronous builder receive a {@link PromptContext} instead. * - *

- * This is the synchronous equivalent of {@link PromptContext}, providing blocking - * methods for use with {@link AcpAgent.SyncPromptHandler}. + *

It offers the same calls as {@link PromptContext}, at the same two levels (the convenience + * calls here, the protocol calls on {@link #client()}), and blocks on that context's {@code Mono}s + * ({@link #async()} returns it). It adds {@link #tryReadFile(String)}, and + * {@link #askChoice(String, String...)} returns an {@link Optional}. Blocking is safe here because + * synchronous handlers run on the builder's handler executor + * ({@link AcpAgent.SyncAgentBuilder#handlerExecutor}), not on the transport's thread. * - *

- * Example usage: *

{@code
  * AcpAgent.sync(transport)
  *     .promptHandler((request, context) -> {
- *         // Send an update (blocks until sent)
- *         context.sendUpdate(sessionId, update);
- *
- *         // Read a file (blocks until complete)
- *         var content = context.readTextFile(new ReadTextFileRequest(...));
- *
- *         // Request permission (blocks until user responds)
- *         var permission = context.requestPermission(new RequestPermissionRequest(...));
- *
- *         return new PromptResponse(StopReason.END_TURN);
+ *         context.sendThought("Running the tests");
+ *         CommandResult result = context.execute("mvn", "-q", "test");
+ *         context.sendMessage(result.success() ? "All tests pass" : result.output());
+ *         return AcpSchema.PromptResponse.endTurn();
  *     })
  *     .build();
  * }
* + *

Failures are thrown. Once the client has initialized, a call that needs a capability the + * client did not advertise (reading or writing files, any terminal method, an elicitation mode) + * throws {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending anything; + * check {@link #getClientCapabilities()} first. An error answer throws + * {@link com.agentclientprotocol.sdk.spec.AcpError}. A call has no time limit of its own: a request + * waits at most the agent's request timeout and then throws + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}, whose cause is the + * {@link java.util.concurrent.TimeoutException}. When the SDK cancels the handler (after the + * cancel grace period, or for a {@code $/cancel_request}), it interrupts the handler's thread, and + * a call blocked at that moment throws {@link java.util.concurrent.CancellationException} and + * leaves the thread's interrupt flag set. Once the prompt has been answered, its updates are + * dropped (see {@link #sendSessionUpdate}); its other calls are not checked against the turn. + * + *

Implementations: the SDK supplies the context handlers receive; implement this interface only + * for test doubles, and include {@link #async()}. + * * @author Mark Pollack * @since 0.9.1 * @see PromptContext @@ -47,239 +63,241 @@ public interface SyncPromptContext { // ======================================================================== /** - * Sends a session update notification to the client. - * Blocks until the notification is sent. - * @param sessionId The session ID - * @param update The session update to send + * Sends a {@code session/update} notification to the client, carrying one + * {@link AcpSchema.SessionUpdate}: a message or thought chunk, a tool call or its update, a + * plan, and so on, for this prompt's session ({@link #getSessionId()}). Returns once the + * notification has been handed to the transport. The Java client hands a turn's updates to its + * update handlers in order, before the prompt's answer. Once the prompt has been answered, by the + * handler or by the SDK when a prompt deadline passed, the SDK's context drops further + * updates (logged at DEBUG) and returns at once: ACP requires a prompt's updates to precede + * its answer. To update another session, use + * {@link AcpSyncAgent#sendSessionUpdate(String, AcpSchema.SessionUpdate)}. + * @param update the update */ - void sendUpdate(String sessionId, AcpSchema.SessionUpdate update); + void sendSessionUpdate(AcpSchema.SessionUpdate update); // ======================================================================== - // File System Operations + // Protocol Layer // ======================================================================== /** - * Requests the client to read a text file. - * Blocks until the response is received. - * @param request The read file request - * @return The file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Returns the raw ACP requests to the client, with blocking calls, for what the convenience + * calls on this context do not cover: {@code fs/read_text_file} and {@code fs/write_text_file} + * with the request records, {@code session/request_permission} for a tool call the agent + * announced itself, the five {@code terminal/*} methods one by one, and elicitation. The calls + * behave as this context's do. + * @return the client's protocol calls for this prompt */ - AcpSchema.ReadTextFileResponse readTextFile(AcpSchema.ReadTextFileRequest request); + SyncSessionClient client(); - /** - * Requests the client to write a text file. - * Blocks until the write is complete. - * @param request The write file request - * @return The write response - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file writing - */ - AcpSchema.WriteTextFileResponse writeTextFile(AcpSchema.WriteTextFileRequest request); - - // ======================================================================== - // Permission Requests // ======================================================================== - - /** - * Requests permission from the client for a sensitive operation. - * Blocks until the user responds. - * @param request The permission request - * @return The permission response - */ - AcpSchema.RequestPermissionResponse requestPermission(AcpSchema.RequestPermissionRequest request); - - // ======================================================================== - // Terminal Operations + // Client Capabilities // ======================================================================== /** - * Requests the client to create a terminal. - * Blocks until the terminal is created. - * @param request The create terminal request - * @return The terminal ID response - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals - */ - AcpSchema.CreateTerminalResponse createTerminal(AcpSchema.CreateTerminalRequest request); - - /** - * Requests terminal output from the client. - * Blocks until the output is received. - * @param request The terminal output request - * @return The terminal output - */ - AcpSchema.TerminalOutputResponse getTerminalOutput(AcpSchema.TerminalOutputRequest request); - - /** - * Requests the client to release a terminal. - * Blocks until the terminal is released. - * @param request The release terminal request - * @return The release response + * Returns what the client advertised in its {@code initialize} request. Check it before a call + * that needs a capability, for example {@code supportsTerminal()} before + * {@link #execute(Command)}. + * @return the client's capabilities, or {@code null} if the client has not initialized */ - AcpSchema.ReleaseTerminalResponse releaseTerminal(AcpSchema.ReleaseTerminalRequest request); + @Nullable NegotiatedCapabilities getClientCapabilities(); /** - * Waits for a terminal to exit. - * Blocks until the terminal exits. - * @param request The wait for exit request - * @return The exit status + * Returns the asynchronous context this one blocks on: the same session, turn and client. Use + * it to start client calls from a synchronous handler without waiting for each. + * @return the asynchronous prompt context */ - AcpSchema.WaitForTerminalExitResponse waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request); - - /** - * Requests the client to kill a terminal. - * Blocks until the terminal is killed. - * @param request The kill terminal request - * @return The kill response - */ - AcpSchema.KillTerminalCommandResponse killTerminal(AcpSchema.KillTerminalCommandRequest request); + PromptContext async(); // ======================================================================== - // Elicitation + // Cancellation // ======================================================================== /** - * Requests structured user input from the client via a form or URL. - * Blocks until the user responds. - * @param request The elicitation request - * @return The user's response - */ - AcpSchema.CreateElicitationResponse createElicitation(AcpSchema.CreateElicitationRequest request); - - /** - * Notifies the client that a URL-mode elicitation has completed. - * @param notification The completion notification + * Whether this prompt has been cancelled: by {@code session/cancel} or {@code session/close} + * for its session, by {@code $/cancel_request} for its request, or by the agent itself (the cancel grace + * period or the maximum prompt duration passed, or the connection closed). Once true, it + * stays true. A long-running handler polls it between steps: + *

{@code
+	 * for (Step step : plan) {
+	 *     if (context.isCancelled()) {
+	 *         return PromptResponse.cancelled();
+	 *     }
+	 *     step.run(context);
+	 * }
+	 * }
+ * + *

+ * After {@code session/cancel}, answer with stop reason {@code cancelled} within the cancel + * grace period ({@code cancelGracePeriod}, 60 seconds by default); once it passes the agent + * answers {@code cancelled} itself and interrupts the handler's thread. After + * {@code $/cancel_request} the agent has already answered and interrupted the thread, so + * what the handler returns is discarded: just stop. + *

+ * @return whether the prompt has been cancelled */ - void completeElicitation(AcpSchema.CompleteElicitationNotification notification); - - // ======================================================================== - // Client Capabilities - // ======================================================================== + boolean isCancelled(); /** - * Returns the capabilities negotiated with the client during initialization. - * - *

- * Use this to check what features the client supports before calling - * methods like {@link #readTextFile} or {@link #createTerminal}. - * - * @return the negotiated client capabilities, or null if not yet initialized + * Runs {@code action} once when this prompt is cancelled (see {@link #isCancelled()}), at + * once on the calling thread if it already has been, for work the handler cannot poll, + * such as a subprocess or an HTTP call to abort. The action runs on whichever thread + * delivers the cancel, so it must be quick and must not block. + * @param action what to run on cancel */ - NegotiatedCapabilities getClientCapabilities(); + void onCancel(Runnable action); // ======================================================================== // Convenience API // ======================================================================== /** - * Returns the session ID for this prompt invocation. - * @return the session ID + * Returns the ID of the ACP session this prompt belongs to. + * @return the session ID from the prompt request */ String getSessionId(); /** - * Sends a message to the client as an agent message chunk. - * This is a convenience method that wraps the text in the appropriate - * session update structure. - * @param text The message text to send + * Sends text to the client as an agent message chunk, the visible reply of this prompt's + * session. Call it as often as needed: the client shows the chunks as one growing message. + * @param text the text */ void sendMessage(String text); /** - * Sends a message to the client as an agent message chunk, tagged with a message ID. - * Chunks sharing the same {@code messageId} belong to the same logical message; a change - * in {@code messageId} signals a new message. - * @param text The message text to send - * @param messageId The message identifier, or {@code null} for none + * Sends text to the client as an agent message chunk that belongs to the given message. Chunks + * with the same {@code messageId} make up one message; a new {@code messageId} starts a new + * message. + * + *

Implementations get a default that calls {@link #sendSessionUpdate} with an + * {@link AcpSchema.AgentMessageChunk} holding the text. + * @param text the text + * @param messageId the message ID, or {@code null} for none */ - default void sendMessage(String text, String messageId) { - sendUpdate(getSessionId(), - new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent(text), messageId)); + default void sendMessage(String text, @Nullable String messageId) { + sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent(text), messageId)); } /** - * Sends a thought to the client as an agent thought chunk. - * Thoughts are typically displayed differently than messages, - * showing the agent's reasoning process. - * @param text The thought text to send + * Sends text to the client as an agent thought chunk: the agent's reasoning, which clients + * usually show apart from the reply. + * @param text the text */ void sendThought(String text); /** - * Sends a thought to the client as an agent thought chunk, tagged with a message ID. - * Chunks sharing the same {@code messageId} belong to the same logical message. - * @param text The thought text to send - * @param messageId The message identifier, or {@code null} for none + * Sends text to the client as an agent thought chunk that belongs to the given message. Chunks + * with the same {@code messageId} make up one message. + * + *

Implementations get a default that calls {@link #sendSessionUpdate} with an {@link AcpSchema.AgentThoughtChunk} holding the text. + * @param text the text + * @param messageId the message ID, or {@code null} for none */ - default void sendThought(String text, String messageId) { - sendUpdate(getSessionId(), - new AcpSchema.AgentThoughtChunk("agent_thought_chunk", new AcpSchema.TextContent(text), messageId)); + default void sendThought(String text, @Nullable String messageId) { + sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent(text), messageId)); } /** - * Reads a text file from the client's file system. - * @param path The path to the file - * @return The file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Reads a whole text file through the client ({@code fs/read_text_file}) for this session. + * @param path the absolute path of the file + * @return the file content + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.readTextFile} */ String readFile(String path); /** - * Reads a portion of a text file from the client's file system. - * @param path The path to the file - * @param startLine The line number to start reading from (0-indexed, null for beginning) - * @param lineCount The number of lines to read (null for all remaining) - * @return The file content - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file reading + * Reads part of a text file through the client ({@code fs/read_text_file}) for this session. + * The line numbers go to the client unchanged; ACP counts lines from 1. + * @param path the absolute path of the file + * @param startLine the first line to read, counting from 1, or {@code null} for the first line + * @param lineCount the most lines to read, or {@code null} for the rest of the file + * @return the content read + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.readTextFile} */ - String readFile(String path, Integer startLine, Integer lineCount); + String readFile(String path, @Nullable Integer startLine, @Nullable Integer lineCount); /** - * Attempts to read a text file, returning empty if the file cannot be read - * or the client doesn't support file reading. - * @param path The path to the file - * @return Optional containing the file content, or empty if unavailable + * Reads a whole text file through the client like {@link #readFile(String)}, but returns empty + * instead of throwing. A failure gives empty: a missing capability, an error answer, a timeout. + * Cancellation does not: when the SDK cancels the handler while it waits, this throws + * {@link java.util.concurrent.CancellationException} with the thread's interrupt flag set, as + * the other calls do, so the handler stops. + * @param path the absolute path of the file + * @return the file content, or empty if it could not be read */ Optional tryReadFile(String path); /** - * Writes content to a text file on the client's file system. - * @param path The path to the file - * @param content The content to write - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support file writing + * Writes a text file through the client ({@code fs/write_text_file}) for this session, and + * waits until it is written. + * @param path the absolute path of the file + * @param content the new content of the whole file + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.writeTextFile} */ void writeFile(String path, String content); /** - * Asks the client for permission to perform an action. - * Presents a simple Allow/Deny choice. - * @param action A description of the action to request permission for - * @return true if the user allowed the action, false otherwise + * Asks the user to allow or deny an action and waits for the answer, as + * {@link PromptContext#askPermission(String)} does: a permission request with the options + * "Allow" and "Deny" for a tool call of kind {@code other} titled with the action, announced + * first with a {@code tool_call} update. + * @param action what the agent wants to do, shown to the user as the tool call's title + * @return {@code true} only if the user chose "Allow"; {@code false} if the user denied, or the + * client answered without a selected option (it cancelled the request, or sent an outcome + * this SDK does not know) */ boolean askPermission(String action); /** - * Asks the client to choose from multiple options. - * @param question The question to ask - * @param options The available options (at least 2) - * @return The selected option text, or null if cancelled + * Asks the user to allow or deny an action of the given kind and waits for the answer, as + * {@link PromptContext#askPermission(String, AcpSchema.ToolKind)} does: it announces a pending + * tool call of that kind, asks permission for it, and settles it once answered. + * @param action what the agent wants to do, shown to the user as the tool call's title + * @param kind the kind of tool call, for example {@code execute} for a command + * @return {@code true} only if the user chose "Allow"; {@code false} if the user denied, or the + * client answered without a selected option (it cancelled the request, or sent an outcome + * this SDK does not know) + */ + boolean askPermission(String action, AcpSchema.ToolKind kind); + + /** + * Asks the user to pick one of several options and waits for the answer, as + * {@link PromptContext#askChoice(String, String...)} does: a permission request whose options + * are the given texts. + * @param question the question, shown to the user as the tool call's title + * @param options the texts to choose from, at least two + * @return the text of the chosen option, or empty if the client answered without a selected + * option (it cancelled the request, or sent an outcome this SDK does not know) + * @throws IllegalArgumentException if fewer than two options are given + * @throws com.agentclientprotocol.sdk.spec.AcpError ({@code -32603}) if the client + * answers with an option ID it was not offered */ - String askChoice(String question, String... options); + Optional askChoice(String question, String... options); /** - * Executes a command in a terminal and waits for completion. - * The terminal is automatically released after execution. - * @param commandAndArgs The command and arguments to execute - * @return The command result containing output and exit code - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals + * Runs a command in a client terminal and waits for its output and exit status, as + * {@link #execute(Command)} does with {@code Command.of(commandAndArgs)}. + * @param commandAndArgs the executable, then its arguments + * @return the result + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code terminal} */ CommandResult execute(String... commandAndArgs); /** - * Executes a command with options and waits for completion. - * The terminal is automatically released after execution. - * @param command The command configuration - * @return The command result containing output and exit code - * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if client doesn't support terminals + * Runs a command in a client terminal and waits for its output and exit status. It creates a + * terminal, waits for the command to end, reads the output, then releases the terminal: always, + * also when a step fails or the handler is cancelled while it waits, as ACP requires of an + * agent. Waiting counts against the agent's request timeout, so a command that runs + * longer throws, and the terminal is still released; raise the builder's {@code requestTimeout} + * for long commands. + * @param command the command and its options + * @return the result + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code terminal} */ CommandResult execute(Command command); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncSessionClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncSessionClient.java new file mode 100644 index 00000000..e79a204d --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/SyncSessionClient.java @@ -0,0 +1,176 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; + +/** + * The protocol layer of a prompt turn, with blocking calls: the raw ACP requests an agent sends + * the client (files, terminals, permission and elicitation), each taking the ACP request record + * and returning once the client has answered. Get it from {@link SyncPromptContext#client()}; the + * context itself holds the convenience calls built on these ({@link SyncPromptContext#readFile}, + * {@link SyncPromptContext#askPermission}, {@link SyncPromptContext#execute} and the rest), which + * fill in the session ID for you. It also sends the client custom extension methods + * ({@link #sendExtRequest}, {@link #sendExtNotification}). + * + *

{@code
+ * String head = context.client()
+ *     .readTextFile(new AcpSchema.ReadTextFileRequest(context.getSessionId(), path, 1, 20))
+ *     .content();
+ * }
+ * + *

Each call blocks on the corresponding {@link SessionClient} call and throws its failure: an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} for a capability the client did + * not advertise (nothing is sent), an {@link com.agentclientprotocol.sdk.spec.AcpError} for an + * error answer, and an {@link com.agentclientprotocol.sdk.error.AcpTimeoutException} once the + * agent's request timeout passes. When the SDK cancels the handler, a call blocked at that moment + * throws {@link java.util.concurrent.CancellationException} and leaves the thread's interrupt flag + * set. + * + *

Implementations: the SDK supplies it; implement this interface only for test doubles. + * + * @author Mark Pollack + * @see SyncPromptContext#client() + * @see SessionClient + */ +public interface SyncSessionClient { + + /** + * Asks the client for the content of a text file ({@code fs/read_text_file}), including unsaved + * changes in its editor, and waits for it. + * @param request the session, an absolute path, and optionally a 1-based first line and a line + * limit + * @return the file content + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.readTextFile} + */ + AcpSchema.ReadTextFileResponse readTextFile(AcpSchema.ReadTextFileRequest request); + + /** + * Asks the client to write a text file ({@code fs/write_text_file}) and waits until it is + * written; ACP requires the client to create the file if it does not exist. + * @param request the session, an absolute path and the new content + * @return the client's empty answer + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code fs.writeTextFile} + */ + AcpSchema.WriteTextFileResponse writeTextFile(AcpSchema.WriteTextFileRequest request); + + /** + * Asks the client to let the user approve a tool call ({@code session/request_permission}) and + * waits for the user's choice, within the agent's request timeout. Every client handles this + * request, so no capability is checked. + * @param request the session, the tool call and the permission options + * @return the user's choice: the selected option, or a cancelled outcome when the client + * cancelled the prompt turn + */ + AcpSchema.RequestPermissionResponse requestPermission(AcpSchema.RequestPermissionRequest request); + + /** + * Asks the client to start a command in a new terminal ({@code terminal/create}) and waits for + * the terminal's ID. ACP requires the agent to release every terminal it creates with + * {@link #releaseTerminal}; {@link SyncPromptContext#execute(Command)} does the whole sequence + * for you. + * @param request the session, the command, its arguments and optionally a working directory, + * environment variables and an output limit + * @return the new terminal's ID + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise {@code terminal} + */ + AcpSchema.CreateTerminalResponse createTerminal(AcpSchema.CreateTerminalRequest request); + + /** + * Asks the client for a terminal's output so far ({@code terminal/output}), without waiting for + * the command to end. + * @param request the session and the terminal ID + * @return the output, whether it was truncated, and the exit status if the command has ended + */ + AcpSchema.TerminalOutputResponse getTerminalOutput(AcpSchema.TerminalOutputRequest request); + + /** + * Asks the client to release a terminal ({@code terminal/release}), which kills its command if + * it is still running, and waits until it is released. The terminal ID is invalid afterwards. + * @param request the session and the terminal ID + * @return the client's empty answer + */ + AcpSchema.ReleaseTerminalResponse releaseTerminal(AcpSchema.ReleaseTerminalRequest request); + + /** + * Waits until a terminal's command has ended ({@code terminal/wait_for_exit}). The wait counts + * against the agent's request timeout, so a long command makes this call throw. + * @param request the session and the terminal ID + * @return the exit code or the signal that ended the command + */ + AcpSchema.WaitForTerminalExitResponse waitForTerminalExit(AcpSchema.WaitForTerminalExitRequest request); + + /** + * Asks the client to kill a terminal's command ({@code terminal/kill}) without releasing the + * terminal, and waits until it is killed. The output and exit status can still be read, and the + * terminal must still be released. + * @param request the session and the terminal ID + * @return the client's empty answer + */ + AcpSchema.KillTerminalCommandResponse killTerminal(AcpSchema.KillTerminalCommandRequest request); + + /** + * Asks the client to collect structured input from the user ({@code elicitation/create}), with + * a form or by sending the user to a URL, and waits for the answer, within the agent's request + * timeout. + * @param request the elicitation, made with {@link AcpSchema.CreateElicitationRequest#form} or + * {@link AcpSchema.CreateElicitationRequest#url} + * @return the user's answer: accept (with the form content), decline or cancel + * @throws com.agentclientprotocol.sdk.error.AcpCapabilityException if the client did not + * advertise the request's mode + */ + AcpSchema.CreateElicitationResponse createElicitation(AcpSchema.CreateElicitationRequest request); + + /** + * Tells the client that the outside interaction of a URL-mode elicitation has finished + * ({@code elicitation/complete}). Returns once the notification has been handed to the + * transport. + * @param notification the ID of the elicitation that finished + */ + void completeElicitation(AcpSchema.CompleteElicitationNotification notification); + + /** + * Sends a custom extension request ({@code _}-prefixed method name) to the client and blocks + * for its result, read as the given type, as + * {@link AcpSyncAgent#sendExtRequest(String, Object, TypeRef)} does. A client that does not + * handle the method answers "Method not found" ({@code -32601}), thrown as + * {@link com.agentclientprotocol.sdk.spec.AcpError}. + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return the result, or {@code null} when the client answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see SessionClient#sendExtRequest(String, Object, TypeRef) + */ + @Nullable T sendExtRequest(String method, Object params, TypeRef resultType); + + /** + * Sends a custom extension request to the client and blocks for its result, as the raw JSON + * value: a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return the result, or {@code null} when the client answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see SessionClient#sendExtRequest(String, Object) + */ + @Nullable Object sendExtRequest(String method, Object params); + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the client and + * returns once it has been handed to the transport. A client without a handler for it ignores + * it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + void sendExtNotification(String method, Object params); + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/package-info.java new file mode 100644 index 00000000..9c151d14 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The ACP agent: the async and sync agents, their builder, and the prompt context handlers use to talk back to the client. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java index cdad6437..62057fd1 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnection.java @@ -5,6 +5,7 @@ package com.agentclientprotocol.sdk.agent.transport; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Function; @@ -14,9 +15,9 @@ import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; -import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.Disposable; @@ -24,20 +25,24 @@ import reactor.core.publisher.Sinks; /** - * Shared per-connection core for listener-backed remote ACP agent transports. + * One client connection on a network listener for agents: it owns the agent an + * {@link AcpAgentFactory} creates for the connection, and the connection-bound + * {@link AcpAgentTransport} that agent talks through. Use it when you write a network + * transport of your own: create one per accepted connection, pass each message from the + * client to {@link #acceptInbound}, and send the client each message the outbound consumer + * given to the constructor receives. The {@code acp-streamable-http-jetty} module builds its + * HTTP and WebSocket connections this way; an application that only serves agents does not + * use this class. * - *

- * Remote transports such as Streamable HTTP and WebSocket have different wire-level - * framing, but they both need the same agent-side shape once a remote ACP connection - * exists: one connection-bound {@link AcpAgentTransport}, one fresh agent runtime from - * {@link AcpAgentFactory}, inbound JSON-RPC delivery to the agent, and outbound JSON-RPC - * delivery back to the wire adapter. - *

+ *

It knows nothing about the wire: HTTP headers, SSE streams, WebSocket sessions and + * routing stay in the adapter. Its life is: construct it, {@link #start} it with the factory, + * feed it messages and forward what it emits, then {@link #closeGracefully()} or + * {@link #close()} it when the client goes away or the listener shuts down. * - *

- * This class intentionally does not know about HTTP headers, SSE streams, WebSocket - * sessions, or route maps. Those remain transport-adapter concerns. - *

+ *

{@link #acceptInbound} may be called from several threads at once (Streamable HTTP + * delivers one connection's POSTs on different server threads); the calls are serialized. The + * outbound consumer is called from whichever thread the agent sends on, possibly several at + * once, so it must be thread-safe. * * @author Kaiser Dandangi */ @@ -55,35 +60,77 @@ public final class RemoteAcpConnection { private final AtomicBoolean closing = new AtomicBoolean(false); - private volatile AcpAsyncAgent agent; + /** Set by {@link #close()}, which may follow a graceful close that has not finished. */ + private final AtomicBoolean closed = new AtomicBoolean(false); /** - * Creates a new remote ACP connection core. - * @param id stable transport connection id - * @param jsonMapper JSON mapper used by the connection transport - * @param outboundConsumer callback that receives agent-originated outbound messages + * Orders {@link #start} against a close: either the start sees the close and creates no + * agent, or the close sees the agent and closes it. Without it a close that ran between a + * listener's shutdown check and the start closed only the transport, and the agent created + * after it was never closed. A lock, not a monitor: callers may be virtual threads. + */ + private final ReentrantLock lifecycleLock = new ReentrantLock(); + + /** The connection's agent runtime; null until {@link #start} creates it. */ + private volatile @Nullable AcpAsyncAgent agent; + + /** Signals when the agent's start has ended, so a graceful close closes a started agent. */ + private final Sinks.Empty agentStartEnded = Sinks.empty(); + + /** + * Creates a connection whose transport errors are logged at ERROR. + * @param id the connection's id, unique among the listener's connections; the Streamable + * HTTP transport sends it to the client in its Acp-Connection-Id header + * @param jsonMapper the mapper the agent's transport converts params and results with + * @param outboundConsumer receives every message the agent sends to the client, responses + * included + * @throws IllegalArgumentException if {@code id} is empty or an argument is null */ public RemoteAcpConnection(String id, AcpJsonMapper jsonMapper, Consumer outboundConsumer) { + this(id, jsonMapper, outboundConsumer, error -> logger.error("Remote ACP transport error", error)); + } + + /** + * Creates a connection that reports its transport errors to the listener that owns it. + * The agent installs no exception handler on its transport, so without this one the + * listener would never see them; the agent factory may still replace it through + * {@link AcpAgentTransport#setExceptionHandler}. + * @param id the connection's id, unique among the listener's connections; the Streamable + * HTTP transport sends it to the client in its Acp-Connection-Id header + * @param jsonMapper the mapper the agent's transport converts params and results with + * @param outboundConsumer receives every message the agent sends to the client, responses + * included + * @param exceptionHandler receives the connection's transport errors + * @throws IllegalArgumentException if {@code id} is empty or an argument is null + */ + public RemoteAcpConnection(String id, AcpJsonMapper jsonMapper, Consumer outboundConsumer, + Consumer exceptionHandler) { Assert.hasText(id, "The id can not be empty"); Assert.notNull(jsonMapper, "The jsonMapper can not be null"); Assert.notNull(outboundConsumer, "The outboundConsumer can not be null"); + Assert.notNull(exceptionHandler, "The exceptionHandler can not be null"); this.id = id; this.jsonMapper = jsonMapper; - this.transport = new ConnectionTransport(outboundConsumer); + this.transport = new ConnectionTransport(outboundConsumer, exceptionHandler); } /** - * Returns the transport-level connection id. - * @return connection id + * Returns the connection's id, as given to the constructor. + * @return the connection id */ public String id() { return id; } /** - * Starts a fresh agent runtime for this connection. - * @param agentFactory factory used to create the connection-bound agent runtime - * @return mono that completes when the agent runtime is started + * Creates this connection's agent with {@code agentFactory}, on the connection's + * transport, and starts it. The agent is created when the returned Mono is subscribed; a + * failure, such as a factory that throws, is also reported to the exception handler. + * @param agentFactory creates the agent for this connection + * @return a Mono that completes once the agent has started; it errors with an + * {@link IllegalStateException} if the connection was started before, and with an + * {@link AcpConnectionException}, creating no agent, if the connection is already closing + * @throws IllegalArgumentException if {@code agentFactory} is null */ public Mono start(AcpAgentFactory agentFactory) { Assert.notNull(agentFactory, "The agentFactory can not be null"); @@ -91,42 +138,80 @@ public Mono start(AcpAgentFactory agentFactory) { // the second time and one that is never subscribed does not consume the start. return Mono.defer(() -> { if (!started.compareAndSet(false, true)) { - return Mono.error(new IllegalStateException("Already started")); + return Mono.error(new IllegalStateException("Already started")).doOnError(this::signalException); + } + AcpAsyncAgent created; + lifecycleLock.lock(); + try { + if (closing.get()) { + // Closed before its agent existed, as when a listener shuts down while the + // connection opens: an agent created now would never be closed. Not + // reported: the close was the owner's own. + return Mono.error(new AcpConnectionException("Remote ACP connection is closing")); + } + created = agentFactory.create(transport); + this.agent = created; + } + catch (RuntimeException e) { + return Mono.error(e).doOnError(this::signalException); + } + finally { + lifecycleLock.unlock(); } - this.agent = agentFactory.create(transport); - return this.agent.start(); - }).doOnError(this::signalException); + return created.start() + .doOnError(this::signalException) + .doFinally(signal -> agentStartEnded.tryEmitEmpty()); + }); } /** - * Accepts one client-originated JSON-RPC message for delivery to the connection's - * agent runtime. - * @param message inbound message + * Hands one message from the client to the connection's agent. It returns once the + * message is queued; the agent's answer, if any, reaches the outbound consumer later. + * Messages accepted before {@link #start} are kept and delivered once the agent runs. + * @param message the message from the client + * @throws AcpConnectionException if the connection is closing, or the message cannot be + * queued */ public void acceptInbound(JSONRPCMessage message) { transport.acceptInbound(message); } /** - * Reports a transport adapter exception to the agent transport exception handler. - * @param error exception to report + * Reports an error of the wire adapter, such as a failed write or a dropped stream, to the + * connection's exception handler: the one given to the constructor, unless the agent + * factory replaced it on the transport. + * @param error the error to report */ public void signalException(Throwable error) { transport.signalException(error); } /** - * Closes the connection and its agent runtime gracefully. - * @return mono that completes when close work has been requested + * Closes the connection's agent gracefully, then its transport. The agent's in-flight + * handlers are cancelled and the outbound consumer receives nothing more. An error from + * the agent's close is reported to the exception handler, not returned. Only the first + * call has an effect. + * @return a Mono that completes when the agent and the transport are closed */ public Mono closeGracefully() { return Mono.defer(() -> { - if (!closing.compareAndSet(false, true)) { - return Mono.empty(); + AcpAsyncAgent currentAgent; + lifecycleLock.lock(); + try { + if (!closing.compareAndSet(false, true)) { + return Mono.empty(); + } + currentAgent = this.agent; + } + finally { + lifecycleLock.unlock(); } - AcpAsyncAgent currentAgent = this.agent; if (currentAgent != null) { - return currentAgent.closeGracefully() + // Closed once its start has ended: an agent closed while it starts, such as the + // default one, has nothing to close yet and would then start a session that + // nothing closes. + return agentStartEnded.asMono() + .then(Mono.defer(currentAgent::closeGracefully)) .onErrorResume(error -> { signalException(error); return Mono.empty(); @@ -138,13 +223,23 @@ public Mono closeGracefully() { } /** - * Closes the connection and its agent runtime immediately. + * Closes the connection and its agent runtime immediately, also when a graceful close + * has begun but not finished (an agent that does not finish closing gracefully is then + * closed at once). Only the first call has an effect. */ public void close() { - if (!closing.compareAndSet(false, true)) { - return; + AcpAsyncAgent currentAgent; + lifecycleLock.lock(); + try { + closing.set(true); + if (!closed.compareAndSet(false, true)) { + return; + } + currentAgent = this.agent; + } + finally { + lifecycleLock.unlock(); } - AcpAsyncAgent currentAgent = this.agent; if (currentAgent != null) { currentAgent.close(); } @@ -160,10 +255,14 @@ private final class ConnectionTransport implements AcpAgentTransport { /* * Streamable HTTP can deliver multiple POST requests for one ACP connection on * different server threads. Reactor unicast sinks require serialized producers, - * so all transport-adapter ingress is funneled through this monitor before - * emission. + * so all transport-adapter ingress is funneled through this lock before emission. + * A lock, not a monitor: emitting runs the agent's handler chain on the caller's + * thread, a virtual thread on a listener that serves on them, and a virtual thread + * that parks inside a synchronized block pins its carrier on JDK 21 to 23. Enough + * pinned carriers waiting on a lock held by an unmounted virtual thread deadlock + * the listener. */ - private final Object inboundEmitMonitor = new Object(); + private final ReentrantLock inboundEmitLock = new ReentrantLock(); private final Sinks.One terminationSink = Sinks.one(); @@ -176,12 +275,13 @@ private final class ConnectionTransport implements AcpAgentTransport { * every in-flight handler: completing the inbound sink alone lets flatMap's active * inner publishers (a running prompt, for instance) run to completion after DELETE. */ - private volatile Disposable inboundSubscription; + private volatile @Nullable Disposable inboundSubscription; - private volatile Consumer exceptionHandler = t -> logger.error("Remote ACP transport error", t); + private volatile Consumer exceptionHandler; - ConnectionTransport(Consumer outboundConsumer) { + ConnectionTransport(Consumer outboundConsumer, Consumer exceptionHandler) { this.outboundConsumer = outboundConsumer; + this.exceptionHandler = exceptionHandler; } @Override @@ -194,13 +294,12 @@ public Mono start(Function, Mono> han this.inboundSubscription = inboundSink.asFlux() .flatMap(message -> Mono.just(message).transform(handler)) .doOnNext(response -> { - if (response != null) { - outboundConsumer.accept(response); - } + outboundConsumer.accept(response); }) - .doOnError(this::signalException) .doFinally(signal -> terminationSink.tryEmitValue(null)) - .subscribe(); + // Reported to the exception handler, not dropped to Reactor's ERROR hook. + .subscribe(ignored -> { + }, this::signalException); return Mono.empty(); }); } @@ -210,12 +309,16 @@ void acceptInbound(JSONRPCMessage message) { if (transportClosing.get()) { throw new AcpConnectionException("Remote ACP connection is closing"); } - synchronized (inboundEmitMonitor) { + inboundEmitLock.lock(); + try { Sinks.EmitResult result = inboundSink.tryEmitNext(message); if (result.isFailure()) { throw new AcpConnectionException("Failed to enqueue inbound message: " + result); } } + finally { + inboundEmitLock.unlock(); + } } void signalException(Throwable error) { diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransport.java index 2b344f54..0b33f700 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransport.java @@ -12,43 +12,99 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Function; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.AcpSchedulers; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.OutboundSinks; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Flux; +import reactor.core.Disposable; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import reactor.core.publisher.SynchronousSink; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; /** - * Implementation of the ACP Stdio transport for agents that communicates with clients - * using standard input/output streams. Messages are exchanged as newline-delimited JSON-RPC - * messages over stdin/stdout, with errors and debug information sent to stderr. + * The agent side of the stdio transport: serves the one client that started this agent + * process, reading ACP messages as lines of JSON from standard input and writing them to + * standard output. Use it in an agent's {@code main} when a client such as an editor launches + * the agent; to serve clients over the network, use the {@code acp-streamable-http-jetty} + * module's {@code StreamableHttpAcpAgentTransport} instead. Pass it to + * {@link com.agentclientprotocol.sdk.agent.AcpAgent#sync(AcpAgentTransport) + * AcpAgent.sync(transport)} or + * {@link com.agentclientprotocol.sdk.agent.AcpAgent#async(AcpAgentTransport) + * AcpAgent.async(transport)}, or to the builder of an annotated agent + * ({@code AcpAgentSupport}), and run the agent: * - *

- * This is the agent-side counterpart to {@code StdioAcpClientTransport}. While the client - * spawns an agent process and connects to its stdin/stdout, the agent transport reads from - * the process's System.in and writes to System.out. - *

+ *
{@code
+ * AcpSyncAgent agent = AcpAgent.sync(new StdioAcpAgentTransport())
+ *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
+ *     .newSessionHandler(request ->
+ *         new AcpSchema.NewSessionResponse(UUID.randomUUID().toString(), null, null))
+ *     .promptHandler((request, context) -> {
+ *         context.sendMessage("Hello");
+ *         return AcpSchema.PromptResponse.endTurn();
+ *     })
+ *     .build();
+ * agent.run(); // returns once the client has closed standard input and every reply is written
+ * }
* - *

- * Key features: - *

    - *
  • Thread-safe message processing with dedicated schedulers
  • - *
  • Proper resource management and graceful shutdown
  • - *
  • Backpressure support via Reactor Sinks
  • - *
+ *

Standard output carries the protocol, so the agent must log to standard error: a single + * log line on standard output corrupts the stream. A line on standard input that is not a + * JSON-RPC message is reported to the exception handler, answered with a JSON-RPC error and + * skipped. The transport is thread-safe: messages may be sent from any thread, and are written + * one at a time. It reads and writes on two daemon threads of its own + * ({@code acp-agent-inbound} and {@code acp-agent-outbound}), so an agent's {@code main} must + * wait for {@link #awaitTermination()}, as {@code AcpSyncAgent.run()} does. + * + *

The end of standard input. A client that closes the agent's standard input sends + * nothing more, but may still read standard output until it ends (a script that writes its + * requests and closes the pipe does). So the end of standard input is not a cancellation: + * every request already received is still answered, and the notifications its handler sends + * are written, in the order they are sent. Requests the agent sends to the client can no + * longer be answered: one still waiting fails at once with a JSON-RPC error ({@code -32603}), + * and one sent later fails with an {@link AcpConnectionException}. Once every request received + * has been answered, standard output is closed and {@link #awaitTermination()} completes, so a + * {@code main} waiting on it returns. The drain is bounded: a request still unanswered after + * the drain timeout ({@link #DEFAULT_DRAIN_TIMEOUT} unless given) is answered with + * {@code -32800} (request cancelled), the transport terminates without waiting for it, and its + * handler's late answer is dropped. + * + *

Closing does not release {@code System.in}. The transport reads standard input on + * its {@code acp-agent-inbound} thread, and a read of {@code System.in} cannot be interrupted. + * Closing the transport stops it handing messages on, but a reader blocked in a read stays + * blocked, holding {@code System.in}'s lock, until the next line arrives or standard input + * ends; it then discards that line and ends. The transport never closes {@code System.in}, + * which belongs to the process. In an agent process that is harmless: the process owns its + * standard input and exits with it. Elsewhere it is not: under Maven Surefire, for instance, + * standard input carries the test fork's commands, and a reader left on it takes those bytes + * and can stall the fork's exit. Embedders and tests should therefore use the + * {@linkplain #StdioAcpAgentTransport(AcpJsonMapper, InputStream, OutputStream) constructor + * that takes explicit streams}, or the in-memory transport of the {@code acp-test} module, and + * keep the {@code System.in} constructors for an agent's {@code main}. A test suite that cannot + * avoid them (an application whose configuration builds this transport by default) can + * install an empty standard input before any test runs, for instance with + * {@code System.setIn(new java.io.ByteArrayInputStream(new byte[0]))} in a JUnit launcher + * session listener. The transport takes {@code System.in} when it is constructed, so its + * reader then sees the end of input at once and ends (and, as at any end of input, the + * transport closes the output stream it was given), while Surefire keeps its own reference to + * the real standard input. * * @author Mark Pollack */ @@ -56,6 +112,17 @@ public class StdioAcpAgentTransport implements AcpAgentTransport { private static final Logger logger = LoggerFactory.getLogger(StdioAcpAgentTransport.class); + /** + * How long, by default, the requests received before standard input ended may take to be + * answered: 60 seconds, as long as + * {@link com.agentclientprotocol.sdk.spec.PromptTimeouts#DEFAULT_CANCEL_GRACE_PERIOD} gives + * a cancelled prompt. Pass another value to the four-argument constructor. + */ + public static final Duration DEFAULT_DRAIN_TIMEOUT = Duration.ofSeconds(60); + + /** The message of the error that fails a request to the client once its input has ended. */ + static final String CLIENT_INPUT_ENDED = "The ACP client closed its input; it can no longer answer requests"; + private final AcpJsonMapper jsonMapper; private final InputStream inputStream; @@ -72,48 +139,91 @@ public class StdioAcpAgentTransport implements AcpAgentTransport { private final Sinks.One terminationSink = Sinks.one(); - private Scheduler inboundScheduler; + private final Scheduler inboundScheduler; - private Scheduler outboundScheduler; + private final Scheduler outboundScheduler; private final AtomicBoolean isClosing = new AtomicBoolean(false); private final AtomicBoolean isStarted = new AtomicBoolean(false); + private final AtomicBoolean terminated = new AtomicBoolean(false); + + /** How long the requests received before standard input ended may take to be answered. */ + private final Duration drainTimeout; + + /** The ids of the client's requests not answered yet. */ + private final Set awaitingAgent = ConcurrentHashMap.newKeySet(); + + /** The agent's requests the client has not answered yet. */ + private final UnansweredRequests awaitingClient = new UnansweredRequests(); + + /** The session's handler, which also takes the errors failing requests to the client. */ + private volatile @Nullable Function, Mono> handler; + + private volatile @Nullable Disposable drainTimer; + private Consumer exceptionHandler = t -> logger.error("Transport error", t); /** - * Creates a new StdioAcpAgentTransport with the default JsonMapper using - * System.in and System.out for communication. + * Creates a transport on {@code System.in} and {@code System.out}, with the JSON mapper + * found on the classpath ({@link AcpJsonMapper#createDefault()}), for an agent process's + * {@code main}. Closing it does not release {@code System.in} (see the class + * documentation); embedders and tests pass explicit streams instead. */ public StdioAcpAgentTransport() { this(AcpJsonMapper.createDefault()); } /** - * Creates a new StdioAcpAgentTransport with the specified JsonMapper using - * System.in and System.out for communication. - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization + * Creates a transport on {@code System.in} and {@code System.out}, with the given JSON + * mapper, for an agent process's {@code main}. Closing it does not release + * {@code System.in} (see the class documentation); embedders and tests pass explicit + * streams instead. + * @param jsonMapper the mapper that reads and writes the messages + * @throws IllegalArgumentException if {@code jsonMapper} is null */ + @SuppressWarnings("SystemOut") // the stdio transport is the one owner of System.out public StdioAcpAgentTransport(AcpJsonMapper jsonMapper) { this(jsonMapper, System.in, System.out); } /** - * Creates a new StdioAcpAgentTransport with the specified JsonMapper and streams. - * This constructor allows for custom streams (useful for testing). - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization - * @param inputStream The input stream to read messages from (client → agent) - * @param outputStream The output stream to write messages to (agent → client) + * Creates a transport on the given streams, with the default drain timeout, for an agent + * embedded in another program or under test. + * @param jsonMapper the mapper that reads and writes the messages + * @param inputStream the stream the client's messages are read from + * @param outputStream the stream the agent's messages are written to; closed once the + * input has ended and every request has been answered + * @throws IllegalArgumentException if an argument is null */ public StdioAcpAgentTransport(AcpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream) { + this(jsonMapper, inputStream, outputStream, DEFAULT_DRAIN_TIMEOUT); + } + + /** + * Creates a transport on the given streams with its own drain timeout: how long the + * requests received before the input ends may take to be answered. + * @param jsonMapper the mapper that reads and writes the messages + * @param inputStream the stream the client's messages are read from + * @param outputStream the stream the agent's messages are written to; closed once the + * input has ended and every request has been answered + * @param drainTimeout the drain timeout; positive + * @throws IllegalArgumentException if an argument is null or {@code drainTimeout} is not + * positive + */ + public StdioAcpAgentTransport(AcpJsonMapper jsonMapper, InputStream inputStream, OutputStream outputStream, + Duration drainTimeout) { Assert.notNull(jsonMapper, "The JsonMapper can not be null"); Assert.notNull(inputStream, "The InputStream can not be null"); Assert.notNull(outputStream, "The OutputStream can not be null"); + Assert.notNull(drainTimeout, "The drainTimeout can not be null"); + Assert.isTrue(!drainTimeout.isNegative() && !drainTimeout.isZero(), "The drainTimeout must be positive"); this.jsonMapper = jsonMapper; this.inputStream = inputStream; this.outputStream = outputStream; + this.drainTimeout = drainTimeout; this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); @@ -133,11 +243,12 @@ public StdioAcpAgentTransport(AcpJsonMapper jsonMapper, InputStream inputStream, }), "agent-outbound"); } - @Override - public List protocolVersions() { - return List.of(AcpSchema.LATEST_PROTOCOL_VERSION); - } - + /** + * {@inheritDoc} + *

Starts the thread that reads the input stream and the thread that writes the output + * stream, when the returned Mono is subscribed. A second call fails with an + * {@link IllegalStateException}, even when the Mono of the first was never subscribed. + */ @Override public Mono start(Function, Mono> handler) { if (!isStarted.compareAndSet(false, true)) { @@ -146,6 +257,7 @@ public Mono start(Function, Mono> han return Mono.fromRunnable(() -> { logger.info("ACP agent transport starting"); + this.handler = handler; handleIncomingMessages(handler); startInboundProcessing(); startOutboundProcessing(); @@ -154,26 +266,7 @@ public Mono start(Function, Mono> han } private void handleIncomingMessages(Function, Mono> handler) { - this.inboundSink.asFlux() - .flatMap(message -> Mono.just(message).transform(handler)) - .doOnNext(response -> { - if (response != null) { - // Responses are emitted from the inbound thread while sendMessage emits - // from user threads on the same sink; both must go through the serialising - // busy-loop or a collision drops the response (FAIL_NON_SERIALIZED, #14). - try { - this.outboundSink.emitNext(response, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - } - catch (Sinks.EmissionException e) { - logger.error("Dropped response {}: {}", response, e.getReason()); - } - } - }) - .doOnTerminate(() -> { - this.outboundSink.tryEmitComplete(); - this.inboundScheduler.dispose(); - }) - .subscribe(); + OutboundSinks.replyThrough(this.inboundSink, handler, this.outboundSink, this.inboundScheduler::dispose); } /** @@ -183,115 +276,307 @@ private void handleIncomingMessages(Function, Mono { inboundReady.tryEmitValue(null); - BufferedReader reader = null; try { - reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); - while (!isClosing.get()) { - try { - String line = reader.readLine(); - if (line == null || isClosing.get()) { - break; - } - - logger.debug("Received JSON message ({} characters)", line.length()); - - try { - JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, line); - if (!this.inboundSink.tryEmitNext(message).isSuccess()) { - logIfNotClosing("Failed to enqueue inbound message"); - break; - } - } - catch (Exception e) { - logIfNotClosing("Error processing inbound message", e); - exceptionHandler.accept(e); - break; - } - } - catch (IOException e) { - logIfNotClosing("Error reading from stdin", e); - exceptionHandler.accept(e); - break; - } - } + readMessages(new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))); } catch (Exception e) { logIfNotClosing("Error in inbound processing", e); exceptionHandler.accept(e); } finally { - isClosing.set(true); - inboundSink.tryEmitComplete(); - terminationSink.tryEmitValue(null); // Signal termination for awaitTermination() - logger.debug("Agent transport terminated"); + endInput(); } }); } + /** + * Standard input has ended (or failed): fails the requests to the client still waiting, + * starts the drain timeout, and ends the inbound stream. The replies still to come are + * written; the transport terminates once the last is (see {@link #terminate()}). + */ + private void endInput() { + logger.debug("Agent transport input ended"); + for (Object id : this.awaitingClient.end()) { + failRequestToClient(id); + } + if (!this.isClosing.get()) { + this.drainTimer = AcpSchedulers.after(this.drainTimeout).subscribe(tick -> abandonDrain()); + } + this.inboundSink.tryEmitComplete(); + } + + /** + * Fails a request to the client that can no longer be answered, as the session fails any + * request: with an error response, here one the client did not send. + */ + private void failRequestToClient(Object id) { + Function, Mono> sessionHandler = this.handler; + if (sessionHandler == null) { + return; + } + JSONRPCMessage failure = new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, + new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, CLIENT_INPUT_ENDED, null)); + Mono.just(failure) + .transform(sessionHandler) + .subscribe(reply -> logger.debug("Unexpected reply to a failed request: {}", reply), + error -> logger.debug("Failing request {} to the client failed", id, error)); + } + + /** + * The drain timeout has passed: on the writer's thread, so that no reply is written after + * it, answers each request still unanswered with -32800 and terminates. + */ + private void abandonDrain() { + try { + this.outboundScheduler.schedule(this::cancelUnanswered); + } + catch (RejectedExecutionException e) { + logger.debug("Drain timeout after the transport terminated"); + } + } + + private void cancelUnanswered() { + if (this.terminated.get()) { + return; + } + this.isClosing.set(true); + logger.warn("{} request(s) received before the client closed its input are still unanswered after {};" + + " answering them as cancelled", this.awaitingAgent.size(), this.drainTimeout); + for (Object id : List.copyOf(this.awaitingAgent)) { + if (this.awaitingAgent.remove(id)) { + writeQuietly(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, + new AcpSchema.JSONRPCError(AcpErrorCodes.REQUEST_CANCELLED, + "Request cancelled: not answered within " + this.drainTimeout + + " after the client closed its input", + null))); + } + } + terminate(); + try { + this.outboundSink.emitComplete(Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + } + catch (Sinks.EmissionException e) { + logger.debug("Outbound stream already ended", e); + } + } + + private void writeQuietly(JSONRPCMessage message) { + try { + writeLine(message); + } + catch (IOException e) { + logger.debug("Stream closed while cancelling unanswered requests", e); + } + } + + /** + * Ends the transport, once: after an end of input, flushes and closes standard output, + * so the client reading it sees the end; then completes {@link #awaitTermination()}. + */ + private void terminate() { + if (!this.terminated.compareAndSet(false, true)) { + return; + } + Disposable timer = this.drainTimer; + if (timer != null) { + timer.dispose(); + } + if (this.awaitingClient.ended()) { + synchronized (this.outputStream) { + try { + this.outputStream.flush(); + this.outputStream.close(); + } + catch (IOException e) { + logger.debug("Closing the output stream failed", e); + } + } + } + this.terminationSink.tryEmitValue(null); + logger.debug("Agent transport terminated"); + } + + /** + * Reads one message per line until stdin ends, the transport closes, or the inbound + * sink refuses a message. A line that is not a JSON-RPC message does not stop it. + */ + private void readMessages(BufferedReader reader) { + boolean reading = true; + while (reading && !isClosing.get()) { + reading = readMessage(reader); + } + } + + /** + * Reads the next line and emits its message to the inbound sink. + * @return whether to go on reading + */ + private boolean readMessage(BufferedReader reader) { + String line; + try { + line = reader.readLine(); + } + catch (IOException e) { + // Closing interrupts the read: that is the expected end, not an error to report. + if (!isClosing.get()) { + logger.error("Error reading from stdin", e); + exceptionHandler.accept(e); + } + return false; + } + if (line == null || isClosing.get()) { + return false; + } + if (line.isBlank()) { + return true; + } + logger.debug("Received JSON message ({} characters)", line.length()); + JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, line); + } + catch (Exception e) { + rejectUnreadable(line, e); + return true; + } + recordInbound(message); + if (!this.inboundSink.tryEmitNext(message).isSuccess()) { + logIfNotClosing("Failed to enqueue inbound message"); + return false; + } + return true; + } + + /** Records a request from the client as awaiting its answer, and an answer from the client. */ + private void recordInbound(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null) { + this.awaitingAgent.add(request.id()); + } + else if (message instanceof AcpSchema.JSONRPCResponse response && response.id() != null) { + this.awaitingClient.answered(response.id()); + } + } + + /** + * Reports a line that is not a JSON-RPC message, answers it with the JSON-RPC error for + * an unreadable message (its id is unknown, so null), and lets reading go on, as the + * WebSocket and Streamable HTTP transports do. + */ + private void rejectUnreadable(String line, Exception e) { + logIfNotClosing("Skipped an inbound line that is not a JSON-RPC message", e); + exceptionHandler.accept(e); + try { + OutboundSinks.emit(outboundSink, AcpSchema.unreadableMessageResponse(jsonMapper, line)); + } + catch (Sinks.EmissionException emission) { + logIfNotClosing("Failed to answer an unreadable inbound line", emission); + } + } + /** * Starts the outbound processing thread that writes JSON-RPC messages to stdout. * Messages are serialized to JSON and written with a newline delimiter. */ private void startOutboundProcessing() { - Function, Flux> outboundConsumer = messages -> messages + outboundSink.asFlux() .doOnSubscribe(subscription -> outboundReady.tryEmitValue(null)) .publishOn(outboundScheduler) - .handle((message, sink) -> { - if (message != null && !isClosing.get()) { - try { - String jsonMessage = jsonMapper.writeValueAsString(message); - // Escape any embedded newlines in the JSON message as per spec: - // Messages are delimited by newlines, and MUST NOT contain embedded newlines. - jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n"); - - synchronized (outputStream) { - outputStream.write(jsonMessage.getBytes(StandardCharsets.UTF_8)); - outputStream.write("\n".getBytes(StandardCharsets.UTF_8)); - outputStream.flush(); - } - logger.debug("Sent JSON message ({} characters)", jsonMessage.length()); - sink.next(message); - } - catch (IOException e) { - if (!isClosing.get()) { - logger.error("Error writing message", e); - exceptionHandler.accept(e); - sink.error(new RuntimeException(e)); - } - else { - logger.debug("Stream closed during shutdown", e); - } - } - } - else if (isClosing.get()) { - sink.complete(); - } - }) + .handle(this::write) .doOnComplete(() -> { isClosing.set(true); + terminate(); outboundScheduler.dispose(); }) .doOnError(e -> { if (!isClosing.get()) { logger.error("Error in outbound processing", e); isClosing.set(true); + terminate(); outboundScheduler.dispose(); } }) - .map(msg -> (JSONRPCMessage) msg); + // Logged above unless closing; not dropped to Reactor's ERROR hook. + .subscribe(ignored -> { + }, error -> logger.debug("Outbound processing ended: {}", error.toString())); + } - outboundConsumer.apply(outboundSink.asFlux()).subscribe(); + /** + * Writes one message to stdout as a line of JSON; once the transport is closing, ends + * the outbound stream instead. + */ + private void write(JSONRPCMessage message, SynchronousSink sink) { + if (isClosing.get()) { + sink.complete(); + return; + } + try { + writeLine(message); + if (message instanceof AcpSchema.JSONRPCResponse response && response.id() != null) { + this.awaitingAgent.remove(response.id()); + } + sink.next(message); + } + catch (IOException e) { + if (isClosing.get()) { + logger.debug("Stream closed during shutdown", e); + return; + } + logger.error("Error writing message", e); + exceptionHandler.accept(e); + sink.error(new RuntimeException(e)); + } } + private void writeLine(JSONRPCMessage message) throws IOException { + // Messages are delimited by newlines, and MUST NOT contain embedded newlines. + String jsonMessage = jsonMapper.writeValueAsString(message) + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n"); + synchronized (outputStream) { + outputStream.write(jsonMessage.getBytes(StandardCharsets.UTF_8)); + outputStream.write("\n".getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + } + logger.debug("Sent JSON message ({} characters)", jsonMessage.length()); + } + + /** + * {@inheritDoc} + *

Waits until the transport has started, then queues the message for the writer + * thread. A request sent once standard input has ended fails with an + * {@link AcpConnectionException}, since no answer can come; responses and notifications + * are still written until the transport terminates. Once the transport is closed, the + * Mono fails with an {@link AcpConnectionException}. + */ @Override public Mono sendMessage(JSONRPCMessage message) { return Mono.zip(inboundReady.asMono(), outboundReady.asMono()).then(Mono.defer(() -> { - outboundSink.emitNext(message, - Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null + && !this.awaitingClient.sent(request.id())) { + return Mono.error(new AcpConnectionException(CLIENT_INPUT_ENDED)); + } + try { + OutboundSinks.emit(outboundSink, message); + } + catch (Sinks.EmissionException e) { + return Mono.error(OutboundSinks.isClosed(e) ? new AcpConnectionException("The transport is closed", e) + : new AcpConnectionException("The message could not be queued: " + e.getReason(), e)); + } return Mono.empty(); })); } + /** + * {@inheritDoc} + *

Stops handing on the client's messages and writing the agent's (messages still queued + * are dropped), stops the transport's threads and completes {@link #awaitTermination()}. + * It closes the output stream only if standard input has already ended. It never closes + * the input stream, and does not end a read already in progress: on {@code System.in}, + * which cannot be interrupted, the reader thread stays blocked until the next line or the + * end of standard input, then discards what it read and ends (see the class + * documentation). {@link #close()} does the same. + */ @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { @@ -303,6 +588,7 @@ public Mono closeGracefully() { try { inboundScheduler.dispose(); outboundScheduler.dispose(); + terminate(); logger.debug("Agent transport closed"); } catch (Exception e) { @@ -311,11 +597,22 @@ public Mono closeGracefully() { })); } + /** + * {@inheritDoc} + *

The default logs each error at ERROR. Call it before {@link #start}. + */ @Override public void setExceptionHandler(Consumer handler) { this.exceptionHandler = handler; } + /** + * {@inheritDoc} + *

It completes after the transport is closed, or after standard input has ended and + * every request received before has been answered and written (or the drain timeout has + * passed) and standard output has been closed. + * @return a Mono that completes when the transport terminates + */ @Override public Mono awaitTermination() { return terminationSink.asMono(); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequests.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequests.java new file mode 100644 index 00000000..28be4dae --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The requests sent to a peer that has not answered them yet, on a connection whose inbound + * half can end while its outbound half stays open: once the peer's input to this side has + * ended, no answer can come, and each such request must be failed, exactly once, by + * whichever of the sender and the reader claims it. + * + *

+ * A sender records each request before sending it ({@link #sent}); the reader records each + * answer ({@link #answered}) and, when the input ends, claims every request still waiting + * ({@link #end}). A request is claimed by removing it: the sender re-checks the end after + * recording, so a request sent while the input ends is claimed by exactly one of the two. + *

+ * + * @author Mark Pollack + */ +final class UnansweredRequests { + + /** The requests waiting for an answer: the id as text, mapped to the id as sent. */ + private final ConcurrentHashMap waiting = new ConcurrentHashMap<>(); + + private volatile boolean ended; + + /** + * Records a request about to be sent. + * @param id the request's id + * @return {@code true} if the request may be sent; {@code false} if the input has ended, + * so no answer can come, and the caller must fail the request itself + */ + boolean sent(Object id) { + String key = key(id); + this.waiting.put(key, id); + // Recorded first, checked second: an end that ran in between sees the request. + return !this.ended || this.waiting.remove(key) == null; + } + + /** + * Records the answer to a request. + * @param id the id the answer carries + */ + void answered(Object id) { + this.waiting.remove(key(id)); + } + + /** + * Ends the input: no request is answered any more. + * @return the ids, as sent, of the requests still waiting, which the caller must fail; + * each is returned by at most one call, and not to a sender + */ + List end() { + this.ended = true; + List claimed = new ArrayList<>(); + for (String key : this.waiting.keySet()) { + Object id = this.waiting.remove(key); + if (id != null) { + claimed.add(id); + } + } + return claimed; + } + + /** + * Whether the input has ended. + * @return {@code true} once {@link #end} ran + */ + boolean ended() { + return this.ended; + } + + /** JSON-RPC ids are strings or numbers; a number read back may be of another width. */ + private static String key(Object id) { + return id instanceof Number number ? "n:" + number.longValue() : "s:" + id; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java new file mode 100644 index 00000000..1370a24e --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Agent-side transports: stdio, and the connection abstraction network transports serve sessions through. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.agent.transport; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilities.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilities.java index 84ed5c2f..bce2e8c5 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilities.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilities.java @@ -4,6 +4,7 @@ package com.agentclientprotocol.sdk.capabilities; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.error.AcpCapabilityException; import com.agentclientprotocol.sdk.spec.AcpSchema.AgentCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; @@ -12,39 +13,50 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.PromptCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema.ElicitationCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema.SessionCapabilities; +import org.jspecify.annotations.Nullable; /** - * Tracks the capabilities negotiated during the ACP initialization handshake. + * What the other side of a connection advertised in the {@code initialize} exchange, as yes or no + * answers: call a {@code supportsX()} method before offering or calling a feature, or a + * {@code requireX()} method to fail with {@link AcpCapabilityException} when it is missing. An + * agent reads the client's from + * {@link com.agentclientprotocol.sdk.agent.AcpAsyncAgent#getClientCapabilities()}, from its prompt + * context or from a {@code NegotiatedCapabilities} parameter of an annotated handler method; a + * client reads the agent's from + * {@link com.agentclientprotocol.sdk.client.AcpAsyncClient#getAgentCapabilities()}. Both are + * {@code null} until {@code initialize} has been exchanged. * - *

- * After the {@code initialize} exchange, both client and agent know what features - * the other side supports. This class provides convenient methods to check capabilities - * and validate that operations are supported before attempting them. - *

+ *

One instance describes one side. An instance from {@link #fromClient} answers the client + * checks ({@code supportsReadTextFile} to {@code supportsBooleanConfigOptions}, which say what an + * agent may ask of the client); one from {@link #fromAgent} answers the agent checks + * ({@code supportsLoadSession} to {@code supportsLogout}, which say what a client may call or + * send). Every check of the other side is false. A capability counts as advertised when its boolean + * is {@code true}, or, for one ACP expresses as an object (such as {@code sessionCapabilities.list} + * or {@code elicitation}), when the object is present. * - *

- * Example usage for an agent checking client capabilities: - *

{@code
- * NegotiatedCapabilities caps = NegotiatedCapabilities.fromClient(clientCapabilities);
+ * 

The SDK already checks what it can: a client call the agent did not advertise + * ({@code session/load}, {@code session/list}, {@code session/close}, {@code session/delete}, + * {@code session/resume}, {@code logout} and the unstable fork and provider methods) fails with + * {@link AcpCapabilityException} without being sent, and so does an agent's file, terminal or + * elicitation request the client did not advertise. It does not check prompt content, MCP server + * types, additional directories or config option kinds: check those here, for example by offering + * image prompts only when {@link #supportsImageContent()} is true. * - * // Check before calling fs/read_text_file - * if (caps.supportsReadTextFile()) { - * agent.readTextFile(request); - * } + *

Instances are immutable and safe to share between threads. * - * // Or require the capability (throws if not supported) - * caps.requireReadTextFile(); - * agent.readTextFile(request); + *

Example, an agent's prompt handler that reads a file only when the client allows it: + *

{@code
+ * NegotiatedCapabilities client = context.getClientCapabilities();
+ * String readme = (client != null && client.supportsReadTextFile())
+ *         ? context.readFile("/workspace/README.md")
+ *         : "";
  * }
* - *

- * Example usage for a client checking agent capabilities: + *

Example, a client that resumes an earlier session only when the agent can load it: *

{@code
- * NegotiatedCapabilities caps = NegotiatedCapabilities.fromAgent(agentCapabilities);
- *
- * // Check if agent supports image content
- * if (caps.supportsImageContent()) {
- *     prompt.add(imageContent);
+ * NegotiatedCapabilities agent = client.getAgentCapabilities();
+ * if (agent != null && agent.supportsLoadSession()) {
+ *     client.loadSession(new AcpSchema.LoadSessionRequest(sessionId, "/workspace", List.of()));
  * }
  * }
* @@ -95,6 +107,12 @@ public final class NegotiatedCapabilities { private final boolean mcpSse; + private final boolean logout; + + private final boolean booleanConfigOptions; + + private final boolean terminalAuth; + private NegotiatedCapabilities(Builder builder) { this.readTextFile = builder.readTextFile; this.writeTextFile = builder.writeTextFile; @@ -115,14 +133,19 @@ private NegotiatedCapabilities(Builder builder) { this.embeddedContext = builder.embeddedContext; this.mcpHttp = builder.mcpHttp; this.mcpSse = builder.mcpSse; + this.logout = builder.logout; + this.booleanConfigOptions = builder.booleanConfigOptions; + this.terminalAuth = builder.terminalAuth; } /** - * Creates negotiated capabilities from client capabilities (used by agents). - * @param caps the client's advertised capabilities, may be null - * @return negotiated capabilities based on client + * Reads what a client advertised in its {@code initialize} request. The SDK calls it when the + * request arrives; call it yourself only to check capabilities you hold, for example in a test. + * @param caps the client's capabilities, or {@code null}, which gives an instance where every + * check is false + * @return the client's capabilities as checks */ - public static NegotiatedCapabilities fromClient(ClientCapabilities caps) { + public static NegotiatedCapabilities fromClient(@Nullable ClientCapabilities caps) { if (caps == null) { return new Builder().build(); } @@ -144,15 +167,25 @@ public static NegotiatedCapabilities fromClient(ClientCapabilities caps) { builder.elicitationUrl(elicit.url() != null); } + var session = caps.session(); + var configOptions = session != null ? session.configOptions() : null; + builder.booleanConfigOptions(configOptions != null && configOptions.booleanOptions() != null); + + var auth = caps.auth(); + builder.terminalAuth(auth != null && Boolean.TRUE.equals(auth.terminal())); + return builder.build(); } /** - * Creates negotiated capabilities from agent capabilities (used by clients). - * @param caps the agent's advertised capabilities, may be null - * @return negotiated capabilities based on agent + * Reads what an agent advertised in its {@code initialize} response. The SDK calls it when the + * response arrives; call it yourself only to check capabilities you hold, for example in a + * test. + * @param caps the agent's capabilities, or {@code null}, which gives an instance where every + * check is false + * @return the agent's capabilities as checks */ - public static NegotiatedCapabilities fromAgent(AgentCapabilities caps) { + public static NegotiatedCapabilities fromAgent(@Nullable AgentCapabilities caps) { if (caps == null) { return new Builder().build(); } @@ -185,6 +218,9 @@ public static NegotiatedCapabilities fromAgent(AgentCapabilities caps) { builder.mcpSse(Boolean.TRUE.equals(mcp.sse())); } + var auth = caps.auth(); + builder.logout(auth != null && auth.logout() != null); + return builder.build(); } @@ -193,91 +229,132 @@ public static NegotiatedCapabilities fromAgent(AgentCapabilities caps) { // -------------------------- /** - * Returns true if the client supports reading text files. - * @return true if fs.readTextFile capability was advertised + * Returns whether the client advertised {@code fs.readTextFile}: an agent may read text files + * through the client ({@code fs/read_text_file}). + * @return true if {@code fs.readTextFile} was advertised */ public boolean supportsReadTextFile() { return readTextFile; } /** - * Returns true if the client supports writing text files. - * @return true if fs.writeTextFile capability was advertised + * Returns whether the client advertised {@code fs.writeTextFile}: an agent may write text files + * through the client ({@code fs/write_text_file}). + * @return true if {@code fs.writeTextFile} was advertised */ public boolean supportsWriteTextFile() { return writeTextFile; } /** - * Returns true if the client supports terminal operations. - * @return true if terminal capability was advertised + * Returns whether the client advertised {@code terminal}: an agent may run commands in + * terminals on the client ({@code terminal/*}). + * @return true if {@code terminal} was advertised */ public boolean supportsTerminal() { return terminal; } /** - * Requires read text file capability, throwing if not supported. - * @throws AcpCapabilityException if the client doesn't support this capability + * Checks that the client advertised {@code fs.readTextFile}, and throws if it did not (see + * {@link #supportsReadTextFile()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "fs.readTextFile"} */ public void requireReadTextFile() { - if (!readTextFile) { - throw new AcpCapabilityException("fs.readTextFile"); - } + require(readTextFile, "fs.readTextFile"); } /** - * Requires write text file capability, throwing if not supported. - * @throws AcpCapabilityException if the client doesn't support this capability + * Checks that the client advertised {@code fs.writeTextFile}, and throws if it did not (see + * {@link #supportsWriteTextFile()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "fs.writeTextFile"} */ public void requireWriteTextFile() { - if (!writeTextFile) { - throw new AcpCapabilityException("fs.writeTextFile"); - } + require(writeTextFile, "fs.writeTextFile"); } /** - * Requires terminal capability, throwing if not supported. - * @throws AcpCapabilityException if the client doesn't support this capability + * Checks that the client advertised {@code terminal}, and throws if it did not (see + * {@link #supportsTerminal()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "terminal"} */ public void requireTerminal() { - if (!terminal) { - throw new AcpCapabilityException("terminal"); - } + require(terminal, "terminal"); } /** - * Returns true if the client supports elicitation (any mode). - * @return true if elicitation capability was advertised + * Returns whether the client advertised {@code elicitation} at all, in any mode: an agent may + * ask the user for input through the client ({@code elicitation/create}). Check + * {@link #supportsElicitationForm()} or {@link #supportsElicitationUrl()} for the mode you + * need. + * @return true if {@code elicitation} was advertised */ public boolean supportsElicitation() { return elicitation; } /** - * Returns true if the client supports form-based elicitation. - * @return true if elicitation.form capability was advertised + * Returns whether the client advertised {@code elicitation.form}: an agent may ask the user to + * fill in a form. + * @return true if {@code elicitation.form} was advertised */ public boolean supportsElicitationForm() { return elicitationForm; } /** - * Returns true if the client supports URL-based elicitation. - * @return true if elicitation.url capability was advertised + * Returns whether the client advertised {@code elicitation.url}: an agent may ask the user to + * visit a URL. + * @return true if {@code elicitation.url} was advertised */ public boolean supportsElicitationUrl() { return elicitationUrl; } /** - * Requires elicitation capability, throwing if not supported. - * @throws AcpCapabilityException if the client doesn't support elicitation + * Checks that the client advertised {@code elicitation} in some mode, and throws if it did not. + * It does not check the mode; the SDK checks that when the agent sends the elicitation. + * @throws AcpCapabilityException if it did not; its capability is {@code "elicitation"} */ public void requireElicitation() { - if (!elicitation) { - throw new AcpCapabilityException("elicitation"); - } + require(elicitation, "elicitation"); + } + + /** + * Returns whether the client advertised {@code auth.terminal}: it can run the agent's program + * itself for an interactive login, so an agent may offer terminal authentication methods. + * @return true if {@code auth.terminal} was advertised + */ + public boolean supportsTerminalAuth() { + return terminalAuth; + } + + /** + * Checks that the client advertised {@code auth.terminal}, and throws if it did not (see + * {@link #supportsTerminalAuth()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "auth.terminal"} + */ + public void requireTerminalAuth() { + require(terminalAuth, "auth.terminal"); + } + + /** + * Returns whether the client advertised {@code session.configOptions.boolean}: an agent may + * offer boolean config options, not only selects. + * @return true if {@code session.configOptions.boolean} was advertised + */ + public boolean supportsBooleanConfigOptions() { + return booleanConfigOptions; + } + + /** + * Checks that the client advertised {@code session.configOptions.boolean}, and throws if it did + * not (see {@link #supportsBooleanConfigOptions()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "session.configOptions.boolean"} + */ + public void requireBooleanConfigOptions() { + require(booleanConfigOptions, "session.configOptions.boolean"); } // -------------------------- @@ -285,203 +362,245 @@ public void requireElicitation() { // -------------------------- /** - * Returns true if the agent supports loading sessions. - * @return true if loadSession capability was advertised + * Returns whether the agent advertised {@code loadSession}: a client may call + * {@code session/load}. + * @return true if {@code loadSession} was advertised */ public boolean supportsLoadSession() { return loadSession; } /** - * Returns true if the agent supports listing sessions. - * @return true if sessionCapabilities.list was advertised + * Returns whether the agent advertised {@code sessionCapabilities.list}: a client may call + * {@code session/list}. + * @return true if {@code sessionCapabilities.list} was advertised */ public boolean supportsListSessions() { return listSessions; } /** - * Returns true if the agent supports closing sessions. - * @return true if sessionCapabilities.close was advertised + * Returns whether the agent advertised {@code sessionCapabilities.close}: a client may call + * {@code session/close}. + * @return true if {@code sessionCapabilities.close} was advertised */ public boolean supportsCloseSession() { return closeSession; } /** - * Returns true if the agent supports resuming sessions. - * @return true if sessionCapabilities.resume was advertised + * Returns whether the agent advertised {@code sessionCapabilities.resume}: a client may call + * {@code session/resume}. + * @return true if {@code sessionCapabilities.resume} was advertised */ public boolean supportsResumeSession() { return resumeSession; } /** - * Returns true if the agent supports deleting sessions. - * @return true if sessionCapabilities.delete was advertised + * Returns whether the agent advertised {@code sessionCapabilities.delete}: a client may call + * {@code session/delete}. + * @return true if {@code sessionCapabilities.delete} was advertised */ public boolean supportsDeleteSession() { return deleteSession; } /** - * Returns true if the agent supports additional workspace directories on session - * lifecycle requests (session/new, session/load, session/resume). - * @return true if sessionCapabilities.additionalDirectories was advertised + * Returns whether the agent advertised {@code sessionCapabilities.additionalDirectories}: a + * client may send additional workspace directories with {@code session/new}, + * {@code session/load} and {@code session/resume}. + * @return true if {@code sessionCapabilities.additionalDirectories} was advertised */ public boolean supportsAdditionalDirectories() { return additionalDirectories; } /** - * Returns true if the agent supports forking sessions. - * @return true if sessionCapabilities.fork was advertised + * Returns whether the agent advertised {@code sessionCapabilities.fork}: a client may call + * {@code session/fork}. + * @return true if {@code sessionCapabilities.fork} was advertised */ + @UnstableAcpApi public boolean supportsForkSession() { return forkSession; } /** - * Returns true if the agent supports the {@code providers/*} configuration methods. - * @return true if {@code providers} capability was advertised + * Returns whether the agent advertised {@code providers}: a client may call the + * {@code providers/*} methods. + * @return true if {@code providers} was advertised */ + @UnstableAcpApi public boolean supportsProviders() { return providers; } /** - * Returns true if the agent supports image content in prompts. - * @return true if promptCapabilities.image was advertised + * Returns whether the agent advertised {@code promptCapabilities.image}: a client may send + * image content in a prompt. + * @return true if {@code promptCapabilities.image} was advertised */ public boolean supportsImageContent() { return imageContent; } /** - * Returns true if the agent supports audio content in prompts. - * @return true if promptCapabilities.audio was advertised + * Returns whether the agent advertised {@code promptCapabilities.audio}: a client may send + * audio content in a prompt. + * @return true if {@code promptCapabilities.audio} was advertised */ public boolean supportsAudioContent() { return audioContent; } /** - * Returns true if the agent supports embedded context (resources) in prompts. - * @return true if promptCapabilities.embeddedContext was advertised + * Returns whether the agent advertised {@code promptCapabilities.embeddedContext}: a client may + * embed resources in a prompt. + * @return true if {@code promptCapabilities.embeddedContext} was advertised */ public boolean supportsEmbeddedContext() { return embeddedContext; } /** - * Returns true if the agent supports MCP servers via HTTP. - * @return true if mcpCapabilities.http was advertised + * Returns whether the agent advertised {@code mcpCapabilities.http}: a client may pass it MCP + * servers that use HTTP. + * @return true if {@code mcpCapabilities.http} was advertised */ public boolean supportsMcpHttp() { return mcpHttp; } /** - * Returns true if the agent supports MCP servers via SSE. - * @return true if mcpCapabilities.sse was advertised + * Returns whether the agent advertised {@code mcpCapabilities.sse}: a client may pass it MCP + * servers that use SSE. + * @return true if {@code mcpCapabilities.sse} was advertised */ public boolean supportsMcpSse() { return mcpSse; } /** - * Requires load session capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code loadSession}, and throws if it did not (see + * {@link #supportsLoadSession()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "loadSession"} */ public void requireLoadSession() { - if (!loadSession) { - throw new AcpCapabilityException("loadSession"); - } + require(loadSession, "loadSession"); } /** - * Requires list sessions capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code sessionCapabilities.list}, and throws if it did not + * (see {@link #supportsListSessions()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.list"} */ public void requireListSessions() { - if (!listSessions) { - throw new AcpCapabilityException("sessionCapabilities.list"); - } + require(listSessions, "sessionCapabilities.list"); } /** - * Requires close session capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code sessionCapabilities.close}, and throws if it did not + * (see {@link #supportsCloseSession()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.close"} */ public void requireCloseSession() { - if (!closeSession) { - throw new AcpCapabilityException("sessionCapabilities.close"); - } + require(closeSession, "sessionCapabilities.close"); } /** - * Requires resume session capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code sessionCapabilities.resume}, and throws if it did not + * (see {@link #supportsResumeSession()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.resume"} */ public void requireResumeSession() { - if (!resumeSession) { - throw new AcpCapabilityException("sessionCapabilities.resume"); - } + require(resumeSession, "sessionCapabilities.resume"); } /** - * Requires delete session capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code sessionCapabilities.delete}, and throws if it did not + * (see {@link #supportsDeleteSession()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.delete"} */ public void requireDeleteSession() { - if (!deleteSession) { - throw new AcpCapabilityException("sessionCapabilities.delete"); - } + require(deleteSession, "sessionCapabilities.delete"); } /** - * Requires additional directories capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code sessionCapabilities.additionalDirectories}, and + * throws if it did not (see {@link #supportsAdditionalDirectories()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.additionalDirectories"} */ public void requireAdditionalDirectories() { - if (!additionalDirectories) { - throw new AcpCapabilityException("sessionCapabilities.additionalDirectories"); - } + require(additionalDirectories, "sessionCapabilities.additionalDirectories"); } + /** + * Checks that the agent advertised {@code sessionCapabilities.fork}, and throws if it did not + * (see {@link #supportsForkSession()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "sessionCapabilities.fork"} + */ + @UnstableAcpApi public void requireForkSession() { - if (!forkSession) { - throw new AcpCapabilityException("sessionCapabilities.fork"); - } + require(forkSession, "sessionCapabilities.fork"); } /** - * Requires provider configuration capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support the {@code providers/*} methods + * Checks that the agent advertised {@code providers}, and throws if it did not (see + * {@link #supportsProviders()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "providers"} */ + @UnstableAcpApi public void requireProviders() { - if (!providers) { - throw new AcpCapabilityException("providers"); - } + require(providers, "providers"); } /** - * Requires image content capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code promptCapabilities.image}, and throws if it did not + * (see {@link #supportsImageContent()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "promptCapabilities.image"} */ public void requireImageContent() { - if (!imageContent) { - throw new AcpCapabilityException("promptCapabilities.image"); - } + require(imageContent, "promptCapabilities.image"); } /** - * Requires audio content capability, throwing if not supported. - * @throws AcpCapabilityException if the agent doesn't support this capability + * Checks that the agent advertised {@code promptCapabilities.audio}, and throws if it did not + * (see {@link #supportsAudioContent()}). + * @throws AcpCapabilityException if it did not; its capability is + * {@code "promptCapabilities.audio"} */ public void requireAudioContent() { - if (!audioContent) { - throw new AcpCapabilityException("promptCapabilities.audio"); + require(audioContent, "promptCapabilities.audio"); + } + + /** + * Returns whether the agent advertised {@code auth.logout}: a client may call {@code logout}. + * @return true if {@code auth.logout} was advertised + */ + public boolean supportsLogout() { + return logout; + } + + /** + * Checks that the agent advertised {@code auth.logout}, and throws if it did not (see + * {@link #supportsLogout()}). + * @throws AcpCapabilityException if it did not; its capability is {@code "auth.logout"} + */ + public void requireLogout() { + require(logout, "auth.logout"); + } + + private static void require(boolean supported, String capability) { + if (!supported) { + throw new AcpCapabilityException(capability); } } @@ -495,11 +614,14 @@ public String toString() { + deleteSession + ", additionalDirectories=" + additionalDirectories + ", forkSession=" + forkSession + ", providers=" + providers + ", imageContent=" + imageContent + ", audioContent=" + audioContent + ", embeddedContext=" + embeddedContext - + ", mcpHttp=" + mcpHttp + ", mcpSse=" + mcpSse + '}'; + + ", mcpHttp=" + mcpHttp + ", mcpSse=" + mcpSse + ", terminalAuth=" + terminalAuth + ", booleanConfigOptions=" + booleanConfigOptions + ", logout=" + logout + '}'; } /** - * Builder for NegotiatedCapabilities. + * Builds an instance by hand, every capability false until set. The SDK reads capabilities with + * {@link #fromClient} and {@link #fromAgent}; the builder is for tests and for code that has + * capabilities in another form. Each setter names the capability it stands for. Not + * thread-safe. */ public static class Builder { @@ -541,101 +663,238 @@ public static class Builder { private boolean mcpSse = false; + private boolean logout = false; + + private boolean booleanConfigOptions = false; + + private boolean terminalAuth = false; + + /** + * Sets whether {@code fs.readTextFile} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder readTextFile(boolean value) { this.readTextFile = value; return this; } + /** + * Sets whether {@code fs.writeTextFile} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder writeTextFile(boolean value) { this.writeTextFile = value; return this; } + /** + * Sets whether {@code terminal} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder terminal(boolean value) { this.terminal = value; return this; } + /** + * Sets whether {@code elicitation} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder elicitation(boolean value) { this.elicitation = value; return this; } + /** + * Sets whether {@code elicitation.form} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder elicitationForm(boolean value) { this.elicitationForm = value; return this; } + /** + * Sets whether {@code elicitation.url} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder elicitationUrl(boolean value) { this.elicitationUrl = value; return this; } + /** + * Sets whether {@code loadSession} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder loadSession(boolean value) { this.loadSession = value; return this; } + /** + * Sets whether {@code sessionCapabilities.list} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder listSessions(boolean value) { this.listSessions = value; return this; } + /** + * Sets whether {@code sessionCapabilities.close} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder closeSession(boolean value) { this.closeSession = value; return this; } + /** + * Sets whether {@code sessionCapabilities.resume} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder resumeSession(boolean value) { this.resumeSession = value; return this; } + /** + * Sets whether {@code sessionCapabilities.delete} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder deleteSession(boolean value) { this.deleteSession = value; return this; } + /** + * Sets whether {@code sessionCapabilities.additionalDirectories} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder additionalDirectories(boolean value) { this.additionalDirectories = value; return this; } + /** + * Sets whether {@code sessionCapabilities.fork} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ + @UnstableAcpApi public Builder forkSession(boolean value) { this.forkSession = value; return this; } + /** + * Sets whether {@code providers} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ + @UnstableAcpApi public Builder providers(boolean value) { this.providers = value; return this; } + /** + * Sets whether {@code promptCapabilities.image} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder imageContent(boolean value) { this.imageContent = value; return this; } + /** + * Sets whether {@code promptCapabilities.audio} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder audioContent(boolean value) { this.audioContent = value; return this; } + /** + * Sets whether {@code promptCapabilities.embeddedContext} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder embeddedContext(boolean value) { this.embeddedContext = value; return this; } + /** + * Sets whether {@code mcpCapabilities.http} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder mcpHttp(boolean value) { this.mcpHttp = value; return this; } + /** + * Sets whether {@code mcpCapabilities.sse} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ public Builder mcpSse(boolean value) { this.mcpSse = value; return this; } + /** + * Sets whether {@code auth.terminal} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ + public Builder terminalAuth(boolean value) { + this.terminalAuth = value; + return this; + } + + /** + * Sets whether {@code session.configOptions.boolean} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ + public Builder booleanConfigOptions(boolean value) { + this.booleanConfigOptions = value; + return this; + } + + /** + * Sets whether {@code auth.logout} counts as advertised. + * @param value true if it is advertised + * @return this builder + */ + public Builder logout(boolean value) { + this.logout = value; + return this; + } + + /** + * Returns the capabilities set so far. + * @return a new instance + */ public NegotiatedCapabilities build() { return new NegotiatedCapabilities(this); } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/package-info.java index 96e2c34f..fad642e5 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/package-info.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/capabilities/package-info.java @@ -17,4 +17,7 @@ * * @see com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities */ +@NullMarked package com.agentclientprotocol.sdk.capabilities; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpAsyncClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpAsyncClient.java index c50b2aa2..d178c388 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpAsyncClient.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpAsyncClient.java @@ -4,84 +4,75 @@ package com.agentclientprotocol.sdk.client; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.util.concurrent.atomic.AtomicReference; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; import com.agentclientprotocol.sdk.spec.AcpClientTransport; import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientSession; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSession; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.Disposable; import reactor.core.publisher.Mono; /** - * The Agent Client Protocol (ACP) async client implementation that provides asynchronous - * communication with ACP-compliant agents using Project Reactor's Mono type. + * A connected ACP client with a Reactor API: it sends the agent requests (initialize, sessions, + * prompts, config options, extension methods) and returns each answer as a {@code Mono}, while the + * handlers registered on {@link AcpClient.AsyncSpec} answer the agent's requests. Get one from + * {@code AcpClient.async(transport)...build()}. Use {@link AcpSyncClient} for the same calls as + * blocking methods. * - *

- * This client implements the ACP specification, enabling applications to interact with - * autonomous coding agents through a standardized interface. Key features include: - *

    - *
  • Asynchronous communication using reactive programming patterns
  • - *
  • Initialize handshake and capability negotiation
  • - *
  • Session creation and management
  • - *
  • Prompt submission with streaming updates
  • - *
  • Authentication support for agents requiring it
  • - *
  • Cancel operations for long-running tasks
  • - *
+ *

A client follows ACP's order: {@link #initialize()} first, {@link #authenticate} if the agent + * requires it, then {@link #newSession}, {@link #loadSession} or {@link #resumeSession} for a + * session ID, then {@link #prompt} as often as needed, one turn at a time per session. Finish with + * {@link #closeGracefully()}. * - *

- * The client follows a lifecycle: - *

    - *
  1. Initialization - Establishes connection and negotiates protocol version
  2. - *
  3. Authentication - Optional authentication step
  4. - *
  5. Session Creation - Creates a new agent session with working directory
  6. - *
  7. Prompt Interaction - Sends prompts and receives responses
  8. - *
  9. Graceful Shutdown - Ensures clean connection termination
  10. - *
- * - *

- * This implementation uses Project Reactor for non-blocking operations, making it - * suitable for high-throughput scenarios and reactive applications. All operations return - * Mono types that can be composed into reactive pipelines. - * - *

- * Example usage:

{@code
- * // Create transport
- * AgentParameters params = AgentParameters.builder("gemini")
- *     .arg("--experimental-acp")
- *     .build();
- * StdioAcpClientTransport transport = new StdioAcpClientTransport(params, AcpJsonMapper.createDefault());
- *
- * // Create client
+ * 
{@code
  * AcpAsyncClient client = AcpClient.async(transport)
- *     .requestTimeout(Duration.ofSeconds(30))
+ *     .sessionUpdateHandler(notification -> Mono.fromRunnable(
+ *         () -> System.out.println(notification.update())))
  *     .build();
  *
- * // Initialize
- * AcpSchema.InitializeResponse initResponse = client
- *     .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities()))
- *     .block();
- *
- * // Create session and interact
- * String sessionId = client
- *     .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))
- *     .map(AcpSchema.NewSessionResponse::sessionId)
+ * client.initialize()
+ *     .then(client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())))
+ *     .flatMap(session -> client.prompt(new AcpSchema.PromptRequest(session.sessionId(),
+ *         List.of(new AcpSchema.TextContent("Fix the failing test")))))
+ *     .doOnNext(response -> System.out.println(response.stopReason()))
+ *     .then(client.closeGracefully())
  *     .block();
- *
- * AcpSchema.PromptResponse response = client
- *     .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("Fix the bug"))))
- *     .block();
- *
- * client.closeGracefully().block();
  * }
* + *

Calls send nothing until their {@code Mono} is subscribed. An error answer fails the + * {@code Mono} with {@link com.agentclientprotocol.sdk.spec.AcpError}, whose {@code getCode()} is + * the JSON-RPC error code. If the agent does not answer within the builder's request timeout (60 + * seconds by default), the {@code Mono} fails with a {@link java.util.concurrent.TimeoutException}; + * then, or when the caller disposes the {@code Mono} first, the client sends the agent a + * {@code $/cancel_request}. {@link #prompt} is the exception: a turn has no time limit unless the + * builder's {@code promptTimeout} sets one; to stop a turn and still get its answer, pass a + * {@link CancellationSignal} to {@link #prompt(AcpSchema.PromptRequest, CancellationSignal)}. To + * send a {@code $/cancel_request} and still wait for the answer, put + * {@link com.agentclientprotocol.sdk.spec.RequestCancellation#cancelWhen} in the request's context. + * Every call but {@link #initialize()} and the extension methods fails with + * {@link IllegalStateException} until the agent has answered {@code initialize}, and a call that + * needs a capability the agent did not advertise (see {@link #getAgentCapabilities()}) fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}; neither is sent. Methods may be called from several threads at once, and a null + * argument fails with {@link IllegalArgumentException}. + * * @author Mark Pollack * @author Christian Tzolov - * @see AcpSession - * @see AcpSchema + * @see AcpClient + * @see AcpSyncClient */ public class AcpAsyncClient { @@ -102,10 +93,6 @@ public class AcpAsyncClient { private static final TypeRef SET_SESSION_MODE_RESPONSE_TYPE_REF = new TypeRef<>() { }; - @SuppressWarnings("removal") - private static final TypeRef SET_SESSION_MODEL_RESPONSE_TYPE_REF = new TypeRef<>() { - }; - private static final TypeRef LIST_SESSIONS_RESPONSE_TYPE_REF = new TypeRef<>() { }; @@ -136,10 +123,10 @@ public class AcpAsyncClient { private static final TypeRef DISABLE_PROVIDER_RESPONSE_TYPE_REF = new TypeRef<>() { }; - private static final TypeRef PROMPT_RESPONSE_TYPE_REF = new TypeRef<>() { + private static final TypeRef RAW_RESULT_TYPE_REF = new TypeRef<>() { }; - private static final TypeRef VOID_TYPE_REF = new TypeRef<>() { + private static final TypeRef PROMPT_RESPONSE_TYPE_REF = new TypeRef<>() { }; /** @@ -153,14 +140,31 @@ public class AcpAsyncClient { private final AcpClientTransport transport; /** - * Client capabilities configured via the builder. Used by no-arg initialize(). + * Client capabilities configured via the builder: the ones every initialize request + * advertises. */ private final AcpSchema.ClientCapabilities clientCapabilities; + /** + * Client info configured via the builder, sent with every initialize request. + */ + private final AcpSchema.@Nullable Implementation clientInfo; + /** * Capabilities negotiated with the agent during initialization. */ - private final AtomicReference agentCapabilities = new AtomicReference<>(); + private final AtomicReference<@Nullable NegotiatedCapabilities> agentCapabilities = new AtomicReference<>(); + + /** + * The capabilities the client advertised in its last initialize request. + */ + private final AtomicReference advertisedCapabilities; + + /** + * How long a prompt turn may take, or null for no limit; prompts are not bound by the + * request timeout. + */ + private final @Nullable Duration promptTimeout; /** * Creates a new AcpAsyncClient with the given session and transport. Uses default @@ -180,12 +184,35 @@ public class AcpAsyncClient { * @param clientCapabilities the client capabilities to use during initialization (may * be null for defaults) */ - AcpAsyncClient(AcpSession session, AcpClientTransport transport, AcpSchema.ClientCapabilities clientCapabilities) { + AcpAsyncClient(AcpSession session, AcpClientTransport transport, + AcpSchema.@Nullable ClientCapabilities clientCapabilities) { + this(session, transport, clientCapabilities, null, new AtomicReference<>(), null); + } + + /** + * Creates a new AcpAsyncClient that records the capabilities it advertises when it + * initializes, so its handlers can honour them. + * @param session the ACP session for communication + * @param transport the transport layer for this client + * @param clientCapabilities the client capabilities to use during initialization (may + * be null for defaults) + * @param clientInfo the client info to send during initialization (may be null) + * @param advertisedCapabilities set to the capabilities of each initialize request + * the client sends + * @param promptTimeout how long a prompt turn may take, or null for no limit + */ + AcpAsyncClient(AcpSession session, AcpClientTransport transport, + AcpSchema.@Nullable ClientCapabilities clientCapabilities, AcpSchema.@Nullable Implementation clientInfo, + AtomicReference advertisedCapabilities, + @Nullable Duration promptTimeout) { Assert.notNull(session, "Session must not be null"); Assert.notNull(transport, "Transport must not be null"); this.session = session; this.transport = transport; this.clientCapabilities = clientCapabilities != null ? clientCapabilities : new AcpSchema.ClientCapabilities(); + this.clientInfo = clientInfo; + this.advertisedCapabilities = advertisedCapabilities; + this.promptTimeout = promptTimeout; } // -------------------------- @@ -193,28 +220,44 @@ public class AcpAsyncClient { // -------------------------- /** - * Initializes the connection with the agent. This is the first step in the ACP - * lifecycle and negotiates protocol version and capabilities. - * - *

- * The client sends its protocol version and capabilities, and the agent responds with - * its supported protocol version, authentication methods, and capabilities. - *

+ * Initializes the connection: the first request of the ACP lifecycle, sent once before any + * other. The client sends protocol version {@value AcpSchema#LATEST_PROTOCOL_VERSION} with the + * capabilities and client info set on the builder + * ({@link AcpClient.AsyncSpec#clientCapabilities}, {@link AcpClient.AsyncSpec#clientInfo}); the + * agent answers with its protocol version, capabilities and authentication methods, and + * {@link #getAgentCapabilities()} returns those capabilities from then on. The client does not + * check the protocol version the agent answers with. * - *

- * After initialization, the agent's capabilities can be accessed via - * {@link #getAgentCapabilities()}. - *

- * @param initializeRequest the initialization request containing protocol version and - * client capabilities - * @return a Mono emitting the initialization response with agent capabilities + *

The builder is the only place the client's capabilities are set, so what the client + * advertises is also what its handlers honour (an elicitation mode it did not advertise is + * refused). Without {@code clientCapabilities(...)} the client advertises what its handlers + * serve, and {@code new ClientCapabilities()} (no file system, no terminal) when it has none. + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_INITIALIZE - * @see #getAgentCapabilities() + * @see #initialize(int, Map) */ - public Mono initialize(AcpSchema.InitializeRequest initializeRequest) { - Assert.notNull(initializeRequest, "Initialize request must not be null"); - logger.debug("Initializing ACP client with protocol version: {}", initializeRequest.protocolVersion()); - return session.sendRequest(AcpSchema.METHOD_INITIALIZE, initializeRequest, INITIALIZE_RESPONSE_TYPE_REF) + public Mono initialize() { + return initialize(AcpSchema.LATEST_PROTOCOL_VERSION, null); + } + + /** + * Initializes the connection like {@link #initialize()}, with a chosen protocol version and + * {@code _meta}. The capabilities and client info still come from the builder; this overload + * exists for {@code _meta} and for testing version negotiation, not for advertising + * capabilities. + * @param protocolVersion the protocol version to announce; this SDK speaks + * {@value AcpSchema#LATEST_PROTOCOL_VERSION} + * @param meta the request's {@code _meta}, or {@code null} + * @return a {@code Mono} emitting the agent's answer + * @see #initialize() + */ + public Mono initialize(int protocolVersion, @Nullable Map meta) { + AcpSchema.InitializeRequest initializeRequest = new AcpSchema.InitializeRequest(protocolVersion, + this.clientCapabilities, this.clientInfo, meta); + logger.debug("Initializing ACP client with protocol version: {}", protocolVersion); + return Mono.fromRunnable(() -> advertisedCapabilities.set(this.clientCapabilities)) + .then(Mono.defer( + () -> session.sendRequest(AcpSchema.METHOD_INITIALIZE, initializeRequest, INITIALIZE_RESPONSE_TYPE_REF))) .doOnNext(response -> { // Store the negotiated agent capabilities NegotiatedCapabilities caps = NegotiatedCapabilities.fromAgent(response.agentCapabilities()); @@ -224,28 +267,14 @@ public Mono initialize(AcpSchema.InitializeRequest } /** - * Initializes the ACP client with default settings. - * - *

- * Uses protocol version 1 and default client capabilities. This is a convenience - * method for the common case where no special capabilities need to be advertised. - *

- * @return a Mono emitting the initialization response with agent capabilities - * @see #initialize(AcpSchema.InitializeRequest) + * Returns the agent's capabilities from its {@code initialize} answer. Check them before calls + * that need them, for example {@code supportsLoadSession()} before {@link #loadSession} or + * {@code supportsLogout()} before {@link #logout}: a call the agent did not advertise + * fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being + * sent. + * @return the agent's capabilities, or {@code null} before an {@code initialize} answer arrived */ - public Mono initialize() { - return initialize(new AcpSchema.InitializeRequest(1, this.clientCapabilities)); - } - - /** - * Returns the capabilities negotiated with the agent during initialization. - * - *

- * This method returns null if {@link #initialize} has not been called yet. - *

- * @return the negotiated agent capabilities, or null if not initialized - */ - public NegotiatedCapabilities getAgentCapabilities() { + public @Nullable NegotiatedCapabilities getAgentCapabilities() { return agentCapabilities.get(); } @@ -254,38 +283,40 @@ public NegotiatedCapabilities getAgentCapabilities() { // -------------------------- /** - * Authenticates with the agent using the specified authentication method. + * Logs in with one of the authentication methods the agent listed in its {@code initialize} + * answer ({@code authenticate}). Needed only for an agent that requires it; such an agent + * answers other requests with {@code -32000} (authentication required) until then. * - *

- * Authentication is optional and depends on the agent's configuration. The - * authentication methods available are returned in the initialize response. - *

- * @param authenticateRequest the authentication request specifying the auth method - * and credentials - * @return a Mono emitting the authentication response + *

Pass the ID of an {@link AcpSchema.AuthMethodAgent}. Do not pass an + * {@link AcpSchema.AuthMethodTerminal}: for that one the client runs the agent program itself, + * in a terminal, outside this connection. This method does not check the method's type. + * @param request the ID of the chosen authentication method + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_AUTHENTICATE */ - public Mono authenticate(AcpSchema.AuthenticateRequest authenticateRequest) { - Assert.notNull(authenticateRequest, "Authenticate request must not be null"); - logger.debug("Authenticating with method: {}", authenticateRequest.methodId()); - return session.sendRequest(AcpSchema.METHOD_AUTHENTICATE, authenticateRequest, AUTHENTICATE_RESPONSE_TYPE_REF); + public Mono authenticate(AcpSchema.AuthenticateRequest request) { + Assert.notNull(request, "Authenticate request must not be null"); + logger.debug("Authenticating with method: {}", request.methodId()); + return afterInitialize(AcpSchema.METHOD_AUTHENTICATE, AcpAsyncClient::initializedOnly, + () -> session.sendRequest(AcpSchema.METHOD_AUTHENTICATE, request, AUTHENTICATE_RESPONSE_TYPE_REF)); } /** - * Logs out of the agent, clearing any stored credentials. - * - *

- * Terminates the current authenticated session. After logout, the client must - * authenticate again before performing operations that require authentication. - *

- * @param logoutRequest the logout request - * @return a Mono emitting the logout response + * Logs out of the agent ({@code logout}), ending the authenticated state; afterwards the client + * must authenticate again where the agent requires it. Only an agent that advertises + * {@code auth.logout} supports it: check + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities#supportsLogout()} + * first: otherwise the call fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the logout request + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_LOGOUT */ - public Mono logout(AcpSchema.LogoutRequest logoutRequest) { - Assert.notNull(logoutRequest, "Logout request must not be null"); + public Mono logout(AcpSchema.LogoutRequest request) { + Assert.notNull(request, "Logout request must not be null"); logger.debug("Logging out"); - return session.sendRequest(AcpSchema.METHOD_LOGOUT, logoutRequest, LOGOUT_RESPONSE_TYPE_REF); + return afterInitialize(AcpSchema.METHOD_LOGOUT, NegotiatedCapabilities::requireLogout, + () -> session.sendRequest(AcpSchema.METHOD_LOGOUT, request, LOGOUT_RESPONSE_TYPE_REF)); } // -------------------------- @@ -293,181 +324,157 @@ public Mono logout(AcpSchema.LogoutRequest logoutReque // -------------------------- /** - * Creates a new agent session with the specified working directory. - * - *

- * A session represents a conversation context with the agent. All prompts within a - * session share the same working directory and conversation history. - *

- * @param newSessionRequest the session creation request with working directory and - * initial context - * @return a Mono emitting the session response containing the session ID + * Creates an ACP session ({@code session/new}) for a working directory, with the MCP servers + * the agent should connect to. The answer carries the session ID every later call for the + * session uses, and optionally the session's modes and config options. A request that names + * additional directories fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent unless + * the agent advertises {@code sessionCapabilities.additionalDirectories}; so do + * {@link #loadSession}, {@link #resumeSession} and {@link #forkSession}. + * @param request the working directory, an absolute path, and the MCP servers + * @return a {@code Mono} emitting the agent's answer, with the session ID * @see AcpSchema#METHOD_SESSION_NEW */ - public Mono newSession(AcpSchema.NewSessionRequest newSessionRequest) { - Assert.notNull(newSessionRequest, "New session request must not be null"); - logger.debug("Creating new session with cwd: {}", newSessionRequest.cwd()); - return session.sendRequest(AcpSchema.METHOD_SESSION_NEW, newSessionRequest, NEW_SESSION_RESPONSE_TYPE_REF); + public Mono newSession(AcpSchema.NewSessionRequest request) { + Assert.notNull(request, "New session request must not be null"); + logger.debug("Creating new session with cwd: {}", request.cwd()); + return afterInitialize(AcpSchema.METHOD_SESSION_NEW, + withDirectories(AcpAsyncClient::initializedOnly, request.additionalDirectories()), + () -> session.sendRequest(AcpSchema.METHOD_SESSION_NEW, request, NEW_SESSION_RESPONSE_TYPE_REF)); } /** - * Loads an existing agent session by ID. - * - *

- * This allows resuming a previous conversation with the agent, maintaining the full - * history and context. - *

- * @param loadSessionRequest the session load request with session ID - * @return a Mono emitting the load response confirming the session was loaded + * Reopens a session the agent kept ({@code session/load}). The agent replays the conversation + * as session updates, which reach the session update handlers before this call completes, then + * answers. Only an agent that advertises {@code loadSession} supports it; for any other the call + * fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the session ID, the working directory and the MCP servers + * @return a {@code Mono} emitting the agent's answer once the history has been replayed * @see AcpSchema#METHOD_SESSION_LOAD */ - public Mono loadSession(AcpSchema.LoadSessionRequest loadSessionRequest) { - Assert.notNull(loadSessionRequest, "Load session request must not be null"); - logger.debug("Loading session: {}", loadSessionRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_LOAD, loadSessionRequest, LOAD_SESSION_RESPONSE_TYPE_REF); + public Mono loadSession(AcpSchema.LoadSessionRequest request) { + Assert.notNull(request, "Load session request must not be null"); + logger.debug("Loading session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_LOAD, + withDirectories(NegotiatedCapabilities::requireLoadSession, request.additionalDirectories()), + () -> session.sendRequest(AcpSchema.METHOD_SESSION_LOAD, request, LOAD_SESSION_RESPONSE_TYPE_REF)); } /** - * Sets the operational mode for a session (e.g., "code", "plan", "review"). - * - *

- * Different modes may change how the agent processes prompts and what capabilities it - * exposes. - *

- * @param setModeRequest the set mode request with session ID and desired mode - * @return a Mono emitting the response confirming the mode change + * Switches a session to one of the modes the agent offered ({@code session/set_mode}). Modes + * may change how the agent works on prompts and what it asks permission for. + * @param request the session ID and the mode ID + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_SESSION_SET_MODE */ - public Mono setSessionMode(AcpSchema.SetSessionModeRequest setModeRequest) { - Assert.notNull(setModeRequest, "Set session mode request must not be null"); - logger.debug("Setting session mode: {} for session: {}", setModeRequest.modeId(), setModeRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_SET_MODE, setModeRequest, - SET_SESSION_MODE_RESPONSE_TYPE_REF); - } - - /** - * Sets the AI model for the specified session. - *

- * This allows changing which AI model is used for subsequent prompts in the session. - *

- * @param setModelRequest the set model request with session ID and desired model - * @return a Mono emitting the response confirming the model change - * @see AcpSchema#METHOD_SESSION_SET_MODEL - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@link #setSessionConfigOption} with a {@code "model"} category config - * option instead. Slated for removal. - */ - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - public Mono setSessionModel(AcpSchema.SetSessionModelRequest setModelRequest) { - Assert.notNull(setModelRequest, "Set session model request must not be null"); - logger.debug("Setting session model: {} for session: {}", setModelRequest.modelId(), - setModelRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_SET_MODEL, setModelRequest, - SET_SESSION_MODEL_RESPONSE_TYPE_REF); + public Mono setSessionMode(AcpSchema.SetSessionModeRequest request) { + Assert.notNull(request, "Set session mode request must not be null"); + logger.debug("Setting session mode: {} for session: {}", request.modeId(), request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_SET_MODE, AcpAsyncClient::initializedOnly, + () -> session.sendRequest(AcpSchema.METHOD_SESSION_SET_MODE, request, + SET_SESSION_MODE_RESPONSE_TYPE_REF)); } /** - * Lists sessions known to the agent, optionally filtered by working directory. - * - *

- * Results may be paginated. Use the {@code nextCursor} from the response to fetch - * subsequent pages. - *

- * @param listSessionsRequest the list sessions request with optional cwd filter and cursor - * @return a Mono emitting the list sessions response + * Lists the sessions the agent knows ({@code session/list}), optionally only those of one + * working directory. The answer may be one page: pass its {@code nextCursor} in the next + * request to get the next page. + * @param request an optional working directory and an optional cursor + * @return a {@code Mono} emitting a page of sessions * @see AcpSchema#METHOD_SESSION_LIST */ - public Mono listSessions(AcpSchema.ListSessionsRequest listSessionsRequest) { - Assert.notNull(listSessionsRequest, "List sessions request must not be null"); + public Mono listSessions(AcpSchema.ListSessionsRequest request) { + Assert.notNull(request, "List sessions request must not be null"); logger.debug("Listing sessions"); - return session.sendRequest(AcpSchema.METHOD_SESSION_LIST, listSessionsRequest, - LIST_SESSIONS_RESPONSE_TYPE_REF); + return afterInitialize(AcpSchema.METHOD_SESSION_LIST, NegotiatedCapabilities::requireListSessions, + () -> session.sendRequest(AcpSchema.METHOD_SESSION_LIST, request, + LIST_SESSIONS_RESPONSE_TYPE_REF)); } /** - * Closes an active session, cancelling any in-flight work. - * - *

- * The agent will cancel ongoing operations as if {@code session/cancel} had been - * called, then free resources associated with the session. - *

- * @param closeSessionRequest the close session request with session ID - * @return a Mono emitting the close session response + * Closes an active session ({@code session/close}): the agent stops its work as for + * {@code session/cancel}, then frees what the session holds. + * @param request the session ID + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_SESSION_CLOSE */ - public Mono closeSession(AcpSchema.CloseSessionRequest closeSessionRequest) { - Assert.notNull(closeSessionRequest, "Close session request must not be null"); - logger.debug("Closing session: {}", closeSessionRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_CLOSE, closeSessionRequest, - CLOSE_SESSION_RESPONSE_TYPE_REF); + public Mono closeSession(AcpSchema.CloseSessionRequest request) { + Assert.notNull(request, "Close session request must not be null"); + logger.debug("Closing session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_CLOSE, NegotiatedCapabilities::requireCloseSession, + () -> session.sendRequest(AcpSchema.METHOD_SESSION_CLOSE, request, + CLOSE_SESSION_RESPONSE_TYPE_REF)); } /** - * Permanently deletes a stored session. - * - *

- * Unlike {@link #closeSession}, which frees resources for an active session, this - * removes the session from the agent's storage entirely so it no longer appears in - * {@code session/list}. Only available if the agent advertises the - * {@code sessionCapabilities.delete} capability. - *

- * @param deleteSessionRequest the delete session request with session ID - * @return a Mono emitting the delete session response + * Deletes a stored session ({@code session/delete}). Unlike {@link #closeSession}, which frees + * an active session, it removes the session from the agent's storage, so that it no longer + * appears in {@code session/list}. Only an agent that advertises + * {@code sessionCapabilities.delete} supports it; for any other the call fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the session ID + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_SESSION_DELETE */ - public Mono deleteSession(AcpSchema.DeleteSessionRequest deleteSessionRequest) { - Assert.notNull(deleteSessionRequest, "Delete session request must not be null"); - logger.debug("Deleting session: {}", deleteSessionRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_DELETE, deleteSessionRequest, - DELETE_SESSION_RESPONSE_TYPE_REF); + public Mono deleteSession(AcpSchema.DeleteSessionRequest request) { + Assert.notNull(request, "Delete session request must not be null"); + logger.debug("Deleting session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_DELETE, NegotiatedCapabilities::requireDeleteSession, + () -> session.sendRequest(AcpSchema.METHOD_SESSION_DELETE, request, + DELETE_SESSION_RESPONSE_TYPE_REF)); } /** - * Resumes an existing session without replaying conversation history. - * - *

- * Unlike {@link #loadSession}, this method does not replay previous messages. - * It is useful for reconnecting to a still-running agent or when the client - * manages history replay independently. - *

- * @param resumeSessionRequest the resume session request with session ID and cwd - * @return a Mono emitting the resume session response + * Reopens a session without replaying its history ({@code session/resume}), unlike + * {@link #loadSession}. Use it to reconnect to a session the agent still runs, or when the + * client keeps the history itself. + * @param request the session ID, the working directory and the MCP servers + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_SESSION_RESUME */ public Mono resumeSession( - AcpSchema.ResumeSessionRequest resumeSessionRequest) { - Assert.notNull(resumeSessionRequest, "Resume session request must not be null"); - logger.debug("Resuming session: {}", resumeSessionRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_RESUME, resumeSessionRequest, - RESUME_SESSION_RESPONSE_TYPE_REF); + AcpSchema.ResumeSessionRequest request) { + Assert.notNull(request, "Resume session request must not be null"); + logger.debug("Resuming session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_RESUME, + withDirectories(NegotiatedCapabilities::requireResumeSession, request.additionalDirectories()), + () -> session.sendRequest(AcpSchema.METHOD_SESSION_RESUME, request, + RESUME_SESSION_RESPONSE_TYPE_REF)); } /** - * Forks an existing session, creating a new session branched from it. - * @param forkSessionRequest the fork request with source session ID and cwd - * @return a Mono emitting the fork response with the new session ID + * Creates a new session branched from an existing one ({@code session/fork}). + * @param request the ID of the session to branch from and the working directory + * @return a {@code Mono} emitting the agent's answer, with the new session's ID * @see AcpSchema#METHOD_SESSION_FORK */ - public Mono forkSession(AcpSchema.ForkSessionRequest forkSessionRequest) { - Assert.notNull(forkSessionRequest, "Fork session request must not be null"); - logger.debug("Forking session: {}", forkSessionRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_FORK, forkSessionRequest, - FORK_SESSION_RESPONSE_TYPE_REF); + @UnstableAcpApi + public Mono forkSession(AcpSchema.ForkSessionRequest request) { + Assert.notNull(request, "Fork session request must not be null"); + logger.debug("Forking session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_FORK, + withDirectories(NegotiatedCapabilities::requireForkSession, request.additionalDirectories()), + () -> session.sendRequest(AcpSchema.METHOD_SESSION_FORK, request, + FORK_SESSION_RESPONSE_TYPE_REF)); } /** - * Sets a configuration option for a session. - * @param request the config option request with session ID, config ID, and value - * @return a Mono emitting the response with the full config state + * Changes one of a session's config options ({@code session/set_config_option}); make the + * request with {@link AcpSchema.SetSessionConfigOptionRequest#select} or + * {@link AcpSchema.SetSessionConfigOptionRequest#bool}. The answer carries the full list of the + * session's options, which replaces the client's copy. Send a boolean option only to an agent + * that offered it. + * @param request the session ID, the option ID and the new value + * @return a {@code Mono} emitting the agent's answer, with all of the session's options * @see AcpSchema#METHOD_SESSION_SET_CONFIG_OPTION */ public Mono setSessionConfigOption( AcpSchema.SetSessionConfigOptionRequest request) { Assert.notNull(request, "Set config option request must not be null"); logger.debug("Setting config option {} for session: {}", request.configId(), request.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, request, - SET_SESSION_CONFIG_OPTION_RESPONSE_TYPE_REF); + return afterInitialize(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, AcpAsyncClient::initializedOnly, + () -> session.sendRequest(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, request, + SET_SESSION_CONFIG_OPTION_RESPONSE_TYPE_REF)); } // -------------------------- @@ -475,41 +482,47 @@ public Mono setSessionConfigOption( // -------------------------- /** - * Lists the providers the agent can route to (UNSTABLE). - * - *

Only available if the agent advertises the {@code providers} capability. - * @param request the list providers request - * @return a Mono emitting the list of configurable providers + * Lists the providers the agent can route to ({@code providers/list}). Only an agent that + * advertises {@code providers} supports it. + * @param request the list request + * @return a {@code Mono} emitting the providers * @see AcpSchema#METHOD_PROVIDERS_LIST */ + @UnstableAcpApi public Mono listProviders(AcpSchema.ListProvidersRequest request) { Assert.notNull(request, "List providers request must not be null"); logger.debug("Listing providers"); - return session.sendRequest(AcpSchema.METHOD_PROVIDERS_LIST, request, LIST_PROVIDERS_RESPONSE_TYPE_REF); + return afterInitialize(AcpSchema.METHOD_PROVIDERS_LIST, NegotiatedCapabilities::requireProviders, + () -> session.sendRequest(AcpSchema.METHOD_PROVIDERS_LIST, request, LIST_PROVIDERS_RESPONSE_TYPE_REF)); } /** - * Configures a provider's routing (protocol, base URL, headers) (UNSTABLE). - * @param request the set provider request - * @return a Mono emitting the response + * Configures how the agent reaches a provider: protocol, base URL and headers + * ({@code providers/set}). + * @param request the provider ID and its routing + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_PROVIDERS_SET */ + @UnstableAcpApi public Mono setProvider(AcpSchema.SetProviderRequest request) { Assert.notNull(request, "Set provider request must not be null"); - logger.debug("Setting provider: {}", request.id()); - return session.sendRequest(AcpSchema.METHOD_PROVIDERS_SET, request, SET_PROVIDER_RESPONSE_TYPE_REF); + logger.debug("Setting provider: {}", request.providerId()); + return afterInitialize(AcpSchema.METHOD_PROVIDERS_SET, NegotiatedCapabilities::requireProviders, + () -> session.sendRequest(AcpSchema.METHOD_PROVIDERS_SET, request, SET_PROVIDER_RESPONSE_TYPE_REF)); } /** - * Disables a provider by id (UNSTABLE). - * @param request the disable provider request - * @return a Mono emitting the response + * Disables a provider by ID ({@code providers/disable}). + * @param request the provider ID + * @return a {@code Mono} emitting the agent's answer * @see AcpSchema#METHOD_PROVIDERS_DISABLE */ + @UnstableAcpApi public Mono disableProvider(AcpSchema.DisableProviderRequest request) { Assert.notNull(request, "Disable provider request must not be null"); - logger.debug("Disabling provider: {}", request.id()); - return session.sendRequest(AcpSchema.METHOD_PROVIDERS_DISABLE, request, DISABLE_PROVIDER_RESPONSE_TYPE_REF); + logger.debug("Disabling provider: {}", request.providerId()); + return afterInitialize(AcpSchema.METHOD_PROVIDERS_DISABLE, NegotiatedCapabilities::requireProviders, + () -> session.sendRequest(AcpSchema.METHOD_PROVIDERS_DISABLE, request, DISABLE_PROVIDER_RESPONSE_TYPE_REF)); } // -------------------------- @@ -517,40 +530,199 @@ public Mono disableProvider(AcpSchema.Disable // -------------------------- /** - * Sends a prompt to the agent within a session. + * Sends a prompt to a session ({@code session/prompt}) and returns the agent's answer at the + * end of the turn, with the stop reason. Meanwhile the agent streams the turn as + * {@code session/update} notifications to the session update handlers. A session takes one + * prompt at a time: a Java agent answers a second prompt sent before the first is answered with + * {@code -32600}. + * + *

The answer is delivered only once the session update handlers have finished with every + * notification the agent sent before it, so what they collected for the turn is complete when + * the stop reason arrives. A slow update handler delays the answer, and the wait counts against the + * prompt timeout, if one is set. The one exception: an update handler that was already running when + * the prompt was sent, and is still running when its answer arrives, is not waited for, since + * it may be the one waiting for the prompt. An update handler must therefore not wait for this prompt + * to complete. * - *

- * The prompt can contain text, images, or other content types. The agent processes - * the prompt and may send streaming updates via session/update notifications before - * returning the final response. - *

- * @param promptRequest the prompt request with session ID and content - * @return a Mono emitting the prompt response with stop reason + *

A prompt is not bound by the builder's {@code requestTimeout}: by default it waits for the + * end of the turn however long that takes. Set {@link AcpClient.AsyncSpec#promptTimeout} to + * bound it; when that passes, the call fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends {@code $/cancel_request}, + * which makes a Java agent cancel the turn. Disposing the returned {@code Mono} does the same + * at any time. + * @param request the session ID and the prompt's content blocks + * @return a {@code Mono} emitting the agent's answer, with the stop reason * @see AcpSchema#METHOD_SESSION_PROMPT */ - public Mono prompt(AcpSchema.PromptRequest promptRequest) { - Assert.notNull(promptRequest, "Prompt request must not be null"); - logger.debug("Sending prompt to session: {}", promptRequest.sessionId()); - return session.sendRequest(AcpSchema.METHOD_SESSION_PROMPT, promptRequest, PROMPT_RESPONSE_TYPE_REF); + public Mono prompt(AcpSchema.PromptRequest request) { + Assert.notNull(request, "Prompt request must not be null"); + logger.debug("Sending prompt to session: {}", request.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_PROMPT, AcpAsyncClient::initializedOnly, () -> { + if (session instanceof AcpClientSession clientSession) { + return clientSession.sendRequest(AcpSchema.METHOD_SESSION_PROMPT, request, + PROMPT_RESPONSE_TYPE_REF, this.promptTimeout); + } + return session.sendRequest(AcpSchema.METHOD_SESSION_PROMPT, request, PROMPT_RESPONSE_TYPE_REF); + }); + } + + /** + * Sends a prompt as {@link #prompt(AcpSchema.PromptRequest)} does, and stops its turn when + * {@code stop} is cancelled: the client then sends {@code session/cancel} for the prompt's + * session ({@link #cancel}), once, and keeps waiting for the agent's answer, which ACP requires + * to be stop reason {@code cancelled}. A Java agent answers it itself once its cancel grace + * period has passed. Cancelling {@code stop} after the answer has arrived sends nothing. + * Disposing the returned {@code Mono} still gives up on the prompt with + * {@code $/cancel_request}, as for the one-argument method. + * + *

{@code
+	 * CancellationSignal stop = new CancellationSignal();
+	 * client.prompt(request, stop).subscribe(response -> show(response.stopReason()));
+	 * // when the user presses Stop:
+	 * stop.cancel();
+	 * }
+ * @param request the session ID and the prompt's content blocks + * @param stop the signal that stops the turn + * @return a {@code Mono} emitting the agent's answer, with the stop reason + * @see CancellationSignal + */ + public Mono prompt(AcpSchema.PromptRequest request, CancellationSignal stop) { + Assert.notNull(request, "Prompt request must not be null"); + Assert.notNull(stop, "Cancellation signal must not be null"); + return Mono.create(sink -> { + // The prompt is subscribed first, so its request is handed to the transport before + // a session/cancel the signal triggers. + Disposable prompt = prompt(request).contextWrite(sink.contextView()) + .subscribe(sink::success, sink::error, () -> sink.success()); + Disposable cancel = stop.whenCancelled() + .then(Mono.defer(() -> cancel(new AcpSchema.CancelNotification(request.sessionId())))) + .subscribe(v -> { + }, error -> logger.debug("No session/cancel sent for session {}: {}", request.sessionId(), + error.getMessage())); + sink.onDispose(() -> { + cancel.dispose(); + prompt.dispose(); + }); + }); } /** - * Cancels ongoing operations for a session. + * Asks the agent to stop a session's prompt turn ({@code session/cancel}). It is a + * notification: the agent does not answer it. * - *

- * This sends a notification to the agent to stop any in-progress work for the - * specified session. Note that this is a notification (fire-and-forget), not a - * request. - *

- * @param cancelNotification the cancel notification with session ID and optional - * reason - * @return a Mono that completes when the notification is sent + *

The cancel does not end the turn. The agent may still send {@code session/update}s, which + * reach the session update handlers as usual, and then answers the pending prompt with stop + * reason {@code cancelled}. Send the next prompt on the session once that answer has arrived: + * until then the agent refuses it (ACP v1, prompt turn, Cancellation). + * @param notification the session ID + * @return a {@code Mono} that completes when the notification has been handed to the transport * @see AcpSchema#METHOD_SESSION_CANCEL */ - public Mono cancel(AcpSchema.CancelNotification cancelNotification) { - Assert.notNull(cancelNotification, "Cancel notification must not be null"); - logger.debug("Canceling operations for session: {}", cancelNotification.sessionId()); - return session.sendNotification(AcpSchema.METHOD_SESSION_CANCEL, cancelNotification); + public Mono cancel(AcpSchema.CancelNotification notification) { + Assert.notNull(notification, "Cancel notification must not be null"); + logger.debug("Canceling operations for session: {}", notification.sessionId()); + return afterInitialize(AcpSchema.METHOD_SESSION_CANCEL, AcpAsyncClient::initializedOnly, + () -> session.sendNotification(AcpSchema.METHOD_SESSION_CANCEL, notification)); + } + + // -------------------------- + // Extension Methods + // -------------------------- + + /** + * Sends a custom extension request ({@code _}-prefixed method name, ACP v1 Extensibility) to + * the agent and reads its result as the given type. An agent that does not handle the method + * answers "Method not found" ({@code -32601}), which fails the {@code Mono} with + * {@link com.agentclientprotocol.sdk.spec.AcpError}. The SDK checks no capability for extension + * methods: agents advertise them in the {@code _meta} of their capabilities + * ({@link #getAgentCapabilities()}). + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return a {@code Mono} emitting the result, or completing empty when the agent answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public Mono sendExtRequest(String method, Object params, TypeRef resultType) { + ExtensionMethods.requireExtension(method); + Assert.notNull(params, "Params must not be null"); + Assert.notNull(resultType, "Result type must not be null"); + // Extension methods are outside ACP's lifecycle: the peer decides whether it needs + // initialize first. + return session.sendRequest(method, params, resultType); + } + + /** + * Sends a custom extension request to the agent and returns its result as the raw JSON value: a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} emitting the result, or completing empty when the agent answers + * {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #sendExtRequest(String, Object, TypeRef) + */ + public Mono sendExtRequest(String method, Object params) { + return sendExtRequest(method, params, RAW_RESULT_TYPE_REF); + } + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the agent. An agent + * without a handler for it ignores it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return a {@code Mono} that completes when the notification has been handed to the transport + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public Mono sendExtNotification(String method, Object params) { + ExtensionMethods.requireExtension(method); + Assert.notNull(params, "Params must not be null"); + // Extension methods are outside ACP's lifecycle: the peer decides whether it needs + // initialize first. + return session.sendNotification(method, params); + } + + // -------------------------- + // Order and capabilities + // -------------------------- + + /** + * A session call's requirement, plus {@code sessionCapabilities.additionalDirectories} when + * the request names additional directories: ACP lets a client send them only to an agent that + * advertises it. No list or an empty one needs nothing more. + */ + private static Consumer withDirectories(Consumer requirement, + @Nullable List additionalDirectories) { + if (additionalDirectories == null || additionalDirectories.isEmpty()) { + return requirement; + } + return requirement.andThen(NegotiatedCapabilities::requireAdditionalDirectories); + } + + /** No capability needed beyond an initialized connection. */ + private static void initializedOnly(NegotiatedCapabilities capabilities) { + // any agent serves the call + } + + /** + * Sends a call once the connection is initialized, checked when the call is subscribed: + * until the agent has answered {@code initialize} every ACP call but initialize fails with + * {@link IllegalStateException} (extension methods are not checked), and a call that needs a capability the agent did not + * advertise fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}. + * Neither sends anything. + */ + private Mono afterInitialize(String method, Consumer requirement, + Supplier> send) { + return Mono.defer(() -> { + NegotiatedCapabilities capabilities = this.agentCapabilities.get(); + if (capabilities == null) { + return Mono.error(new IllegalStateException("Call initialize() first: the agent has not answered " + + "initialize, so the client does not send " + method)); + } + requirement.accept(capabilities); + return send.get(); + }); } // -------------------------- @@ -558,11 +730,11 @@ public Mono cancel(AcpSchema.CancelNotification cancelNotification) { // -------------------------- /** - * Closes the client connection immediately. - * - *

- * This closes both the session and the underlying transport. - *

+ * Closes the client without waiting for the agent: requests waiting for an answer fail, the + * agent's requests being handled are cancelled, session updates not yet handled are dropped, + * and the transport is closed. Closing the transport can still block the calling thread: a + * stdio transport stops the agent process and can take about seven seconds (see + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport}). */ public void close() { logger.debug("Closing ACP client"); @@ -571,12 +743,13 @@ public void close() { } /** - * Gracefully closes the client connection, allowing pending operations to complete. - * - *

- * This closes both the session and the underlying transport. - *

- * @return a Mono that completes when the connection is closed + * Closes the client gracefully. Requests still waiting for an answer, a prompt included, fail + * at once, and the agent's requests being handled are cancelled and answered {@code -32800}. + * Session updates already received are still handed to the update handlers, waiting at most the + * request timeout, and then the transport closes gracefully. For a + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport} that closes the + * agent's input first and gives the agent time to exit. + * @return a {@code Mono} that completes when the transport has closed */ public Mono closeGracefully() { logger.debug("Gracefully closing ACP client"); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpClient.java index d21edc89..ebaeb67e 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpClient.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpClient.java @@ -12,14 +12,22 @@ import java.util.function.Consumer; import java.util.function.Function; -import java.util.concurrent.Executors; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicReference; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; import com.agentclientprotocol.sdk.spec.AcpClientSession; import com.agentclientprotocol.sdk.spec.AcpClientTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSession; +import com.agentclientprotocol.sdk.spec.ExtensionMethods; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.HandlerFailures; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -27,175 +35,201 @@ import reactor.core.scheduler.Schedulers; /** - * Factory class for creating Agent Client Protocol (ACP) clients. ACP is a protocol that - * enables applications to interact with autonomous coding agents through a standardized - * interface. + * The entry point for an ACP client, the side that starts or connects to an agent and sends it + * prompts: {@link #sync(AcpClientTransport)} and {@link #async(AcpClientTransport)} start a builder + * on a client transport, you set the client's capabilities and the handlers for the agent's + * requests, and {@code build()} returns the client. Use {@code sync} for blocking code and + * {@link AcpSyncClient}; use {@code async} for Reactor code and {@link AcpAsyncClient}. * - *

- * This class serves as the main entry point for establishing connections with ACP agents, - * implementing the client-side of the ACP specification. The protocol follows a - * client-agent architecture where: - *

    - *
  • The client (this implementation) initiates connections and sends prompts
  • - *
  • The agent responds to prompts and can request client capabilities (file access, - * etc.)
  • - *
  • Communication occurs through a transport layer (e.g., stdio) using JSON-RPC - * 2.0
  • - *
+ *

The interface is not implemented. It holds the two builders, {@link SyncRequestHandler}, and + * the scheduler synchronous handlers run on. Building a client connects its transport, which for + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport} starts the agent + * process; then call {@code initialize()}, create a session and send prompts. A transport carries + * one client. * - *

- * The class provides factory methods to create either: - *

    - *
  • {@link AcpAsyncClient} for non-blocking operations with Mono responses
  • - *
  • {@link AcpSyncClient} for blocking operations with direct responses (future)
  • - *
+ *
{@code
+ * AgentParameters params = AgentParameters.builder("my-agent").arg("--acp").build();
+ * var transport = new StdioAcpClientTransport(params, AcpJsonMapper.createDefault());
+ * try (AcpSyncClient client = AcpClient.sync(transport)
+ *         .requestTimeout(Duration.ofMinutes(5))
+ *         .sessionUpdateHandler(notification -> System.out.println(notification.update()))
+ *         .build()) {
+ *     client.initialize();
+ *     String sessionId = client
+ *         .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))
+ *         .sessionId();
+ *     AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(sessionId,
+ *         List.of(new AcpSchema.TextContent("Fix the failing test"))));
+ *     System.out.println(response.stopReason());
+ * }
+ * }
* - *

- * Example of creating a basic asynchronous client: + *

The agent's requests

* - *
{@code
- * // Create transport
- * AgentParameters params = AgentParameters.builder("gemini")
- *     .arg("--experimental-acp")
- *     .build();
- * StdioAcpClientTransport transport = new StdioAcpClientTransport(params, AcpJsonMapper.createDefault());
+ * 

The agent calls back into the client for files ({@code fs/*}), terminals ({@code terminal/*}), + * permission and elicitation. Register a handler for each one you support. Without + * {@code clientCapabilities(...)}, the client advertises what its handlers serve: + * {@code fs.readTextFile} and {@code fs.writeTextFile} for their handlers, {@code terminal} once all + * five terminal handlers are registered, and form-mode elicitation for an elicitation handler. + * Capabilities set with {@code clientCapabilities(...)} replace that derivation entirely; then + * {@code build()} fails when a capability is advertised without its handlers and warns about a + * handler whose capability is not advertised. A request without a handler is + * answered {@code -32601} (method not found). A request reaches its handler once the session + * updates the agent sent before it have been handled (see below); handlers do not wait for each + * other. Asynchronous handlers are called on the thread that delivered the request or finished the + * last of those updates, and must not block; synchronous handlers run on the executor given to + * {@link SyncSpec#handlerExecutor}, by default a virtual thread per call on JDK 21 and later, and + * before that a pool of daemon threads the SDK shares between all synchronous clients in the JVM. A handler that fails is answered with an error: an + * {@link AcpProtocolException} with its own code, an {@code AcpError} from a call to the agent + * with the error it carries, anything else with {@code -32603} (internal error, message + * "Internal error"). * - * // Build client - * AcpAsyncClient client = AcpClient.async(transport) - * .requestTimeout(Duration.ofSeconds(30)) - * .sessionUpdateConsumer(notification -> { - * System.out.println("Session update: " + notification); - * return Mono.empty(); - * }) - * .build(); + *

Session updates and ordering

* - * // Initialize and use - * client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - * .flatMap(initResponse -> client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))) - * .flatMap(sessionResponse -> client.prompt(new AcpSchema.PromptRequest( - * sessionResponse.sessionId(), - * List.of(new AcpSchema.TextContent("Fix the failing test"))))) - * .doOnNext(response -> System.out.println("Response: " + response)) - * .block(); + *

{@code session/update} notifications go to the session update handlers one at a time, in the + * order the agent sent them. A response completes its caller only after every notification received + * before it has been handled, so when {@code prompt} returns, the turn's updates have all been + * handled. In the same way, a request from the agent reaches its handler only after the updates the + * agent sent before it, so a permission request comes after the {@code tool_call} update that + * announced the tool call. A handler must therefore not wait for a prompt in flight to complete. + * The one exception: a handler that is itself waiting for an answer to a request it sent does not + * hold back the agent's requests or that answer. * - * client.closeGracefully().block(); - * }

+ *

Timeouts and cancellation

* - *

- * The client supports: - *

    - *
  • Protocol version negotiation and capability exchange
  • - *
  • Optional authentication with various methods
  • - *
  • Session creation and management
  • - *
  • Prompt submission with streaming updates
  • - *
  • File system operations (read/write) through client handlers
  • - *
  • Permission requests for sensitive operations
  • - *
  • Terminal operations for command execution
  • - *
+ *

Requests wait at most 60 seconds unless the builder's {@code requestTimeout} says otherwise. + * A prompt is the exception: its answer comes only at the end of its turn, so it waits as long as + * the turn takes unless the builder's {@code promptTimeout} sets a limit. When a timeout passes, or + * the caller disposes a request's {@code Mono}, the client sends the agent a + * {@code $/cancel_request} and the call fails; a Java agent then cancels the handler, which for a + * prompt ends the turn. To stop a turn and still receive its answer, pass a + * {@link CancellationSignal} to {@code prompt(request, stop)} and cancel it, which sends + * {@code session/cancel}; {@code cancel(...)} sends one directly. For any other request, + * {@link com.agentclientprotocol.sdk.spec.RequestCancellation#cancelWhen} in the request's context + * sends {@code $/cancel_request} and still waits for the answer. * * @author Mark Pollack * @author Christian Tzolov * @see AcpAsyncClient + * @see AcpSyncClient * @see AcpClientTransport */ public interface AcpClient { - Logger logger = LoggerFactory.getLogger(AcpClient.class); - - // ==================================================================== - // Sync Handler Scheduler (library-owned, daemon threads) - // ==================================================================== - - /** - * Library-owned scheduler for executing synchronous handlers. - * Uses daemon threads with descriptive names to prevent JVM hang on exit. - * This follows the best practice of never using global Schedulers.boundedElastic(). - */ - Scheduler SYNC_HANDLER_SCHEDULER = Schedulers.fromExecutorService( - Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r, "acp-sync-handler"); - t.setDaemon(true); - return t; - }), "acp-sync-handler"); - // ==================================================================== // Sync Handler Interfaces (for use with AcpClient.sync()) // ==================================================================== /** - * Functional interface for synchronous request handlers. Unlike - * {@link AcpClientSession.RequestHandler}, this interface returns the response - * directly without wrapping in Mono, making it natural for blocking I/O operations. - * - *

Use with {@link SyncSpec} builder methods to register handlers that don't - * require reactive programming patterns. - * - * @param The response type + * A blocking handler for one agent-to-client request method, registered with + * {@link SyncSpec#requestHandler(String, SyncRequestHandler)} for a method the typed setters do + * not cover. It receives the params as the transport read them and returns the result. Prefer + * the typed setters for ACP methods and {@code extRequestHandler} for extension methods, which + * read the params into a type for you. + * @param the result type */ @FunctionalInterface interface SyncRequestHandler { /** - * Handles an incoming request with the given parameters. - * @param params The raw request parameters (requires unmarshalling) - * @return The response object + * Answers one request from the agent. It runs on the sync builder's handler executor + * and may block. + * @param params the request's params as the transport read them, usually a {@code Map}; an + * omitted params arrives as an empty object + * @return the result, any value the JSON mapper can write; {@code null} answers the request + * with an internal error ({@code -32603}) */ T handle(Object params); } /** - * Start building a synchronous ACP client with the specified transport layer. The - * synchronous ACP client provides blocking operations. Synchronous clients wait for - * each operation to complete before returning, making them simpler to use but - * potentially less performant for concurrent operations. - * @param transport The transport layer implementation for ACP communication - * @return A new builder instance for configuring the client - * @throws IllegalArgumentException if transport is null + * Starts a builder for a client with blocking calls ({@link AcpSyncClient}) and blocking + * handlers, which run on the builder's handler executor. + * @param transport the client transport, for example a + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport}, which starts + * the agent process + * @return a new builder + * @throws IllegalArgumentException if {@code transport} is null */ static SyncSpec sync(AcpClientTransport transport) { return new SyncSpec(transport); } /** - * Start building an asynchronous ACP client with the specified transport layer. The - * asynchronous ACP client provides non-blocking operations using Project Reactor's - * Mono type. The transport layer handles the low-level communication between client - * and agent using protocols like stdio. - * @param transport The transport layer implementation for ACP communication. Common - * implementation is {@code StdioAcpClientTransport} for stdio-based communication. - * @return A new builder instance for configuring the client - * @throws IllegalArgumentException if transport is null + * Starts a builder for a client whose calls return Reactor {@code Mono}s + * ({@link AcpAsyncClient}) and whose handlers return {@code Mono}s and must not block. + * @param transport the client transport, for example a + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport}, which starts + * the agent process + * @return a new builder + * @throws IllegalArgumentException if {@code transport} is null */ static AsyncSpec async(AcpClientTransport transport) { return new AsyncSpec(transport); } /** - * Asynchronous client specification. This class follows the builder pattern to - * provide a fluent API for setting up clients with custom configurations. + * Configures and builds an {@link AcpAsyncClient}: the client's capabilities and info, the + * request timeout, the session update handlers, and the handlers for the agent's requests and + * notifications, each returning a Reactor {@code Mono}. Get one from + * {@link AcpClient#async(AcpClientTransport)}. For blocking handlers, use {@link SyncSpec}. * - *

- * The builder supports configuration of: - *

    - *
  • Transport layer for client-agent communication
  • - *
  • Request timeouts for operation boundaries
  • - *
  • Client capabilities for feature negotiation
  • - *
  • Request handlers for incoming agent requests (file operations, etc.)
  • - *
  • Notification handlers for streaming updates
  • - *
+ *

Handlers are called on the transport's thread and must not block: return a {@code Mono} + * that completes later instead. A method takes one handler: registering a second one, with the + * same setter or another, throws {@link IllegalStateException}, and a null handler throws + * {@link IllegalArgumentException}. A request + * handler's {@code Mono} must emit the answer: an empty one is answered {@code -32603}. + * {@link #build()} connects the transport. A builder is not thread-safe; configure it on one + * thread. */ class AsyncSpec { + private static final Logger logger = LoggerFactory.getLogger(AcpClient.class); + + /** Reads params as the raw JSON value, for the untyped extension handlers. */ + private static final TypeRef RAW_PARAMS = new TypeRef<>() { + }; + + /** The typed setter of each agent-to-client request the SDK models. */ + private static final Map TYPED_REQUEST_SETTERS = Map.of( + AcpSchema.METHOD_FS_READ_TEXT_FILE, "readTextFileHandler", + AcpSchema.METHOD_FS_WRITE_TEXT_FILE, "writeTextFileHandler", + AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, "requestPermissionHandler", + AcpSchema.METHOD_TERMINAL_CREATE, "createTerminalHandler", + AcpSchema.METHOD_TERMINAL_OUTPUT, "terminalOutputHandler", + AcpSchema.METHOD_TERMINAL_RELEASE, "releaseTerminalHandler", + AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, "waitForTerminalExitHandler", + AcpSchema.METHOD_TERMINAL_KILL, "killTerminalHandler", + AcpSchema.METHOD_ELICITATION_CREATE, "createElicitationHandler"); + + /** The typed setter of each agent-to-client notification the SDK models. */ + private static final Map TYPED_NOTIFICATION_SETTERS = Map.of( + AcpSchema.METHOD_SESSION_UPDATE, "sessionUpdateHandler", + AcpSchema.METHOD_ELICITATION_COMPLETE, "completeElicitationHandler"); + private final AcpClientTransport transport; - private Duration requestTimeout = Duration.ofSeconds(30); // Default timeout + /** The SDK's one default request timeout, the same as the agent builders'. */ + private static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(60); + + private Duration requestTimeout = DEFAULT_REQUEST_TIMEOUT; + + /** How long a prompt turn may take; null for no limit (the default). */ + private @Nullable Duration promptTimeout; - private AcpSchema.ClientCapabilities clientCapabilities; + private AcpSchema.@Nullable ClientCapabilities clientCapabilities; + + private AcpSchema.@Nullable Implementation clientInfo; private final Map> requestHandlers = new HashMap<>(); private final Map notificationHandlers = new HashMap<>(); - private final List>> sessionUpdateConsumers = new ArrayList<>(); + private final List>> sessionUpdateHandlers = new ArrayList<>(); + + /** Receives the session updates only while no handler is added. */ + private @Nullable Function> defaultSessionUpdateHandler; + + private @Nullable Function> createElicitationHandler; private AsyncSpec(AcpClientTransport transport) { Assert.notNull(transport, "Transport must not be null"); @@ -203,13 +237,15 @@ private AsyncSpec(AcpClientTransport transport) { } /** - * Sets the duration to wait for agent responses before timing out requests. This - * timeout applies to all requests made through the client, including initialize, - * prompt, and session operations. - * @param requestTimeout The duration to wait before timing out requests. Must not - * be null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if requestTimeout is null + * Sets how long the client waits for the agent to answer a request: {@code initialize}, + * session calls and extension requests. When it passes, the call fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}; a Java agent then cancels the handler. Default: 60 seconds, as for the agent builders. + * {@code session/prompt} is not bound by it, since its answer comes only at the end of the + * turn; see {@code promptTimeout}. + * @param requestTimeout the timeout + * @return this builder + * @throws IllegalArgumentException if {@code requestTimeout} is null */ public AsyncSpec requestTimeout(Duration requestTimeout) { Assert.notNull(requestTimeout, "Request timeout must not be null"); @@ -218,13 +254,40 @@ public AsyncSpec requestTimeout(Duration requestTimeout) { } /** - * Sets the client capabilities that will be advertised to the agent during - * initialization. Capabilities define what features the client supports, such as - * file system operations, terminal access, and authentication methods. - * @param clientCapabilities The client capabilities configuration. Must not be - * null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if clientCapabilities is null + * Sets how long a prompt turn ({@code session/prompt}) may take before the client gives up + * on it. When it passes, {@code prompt} fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}, which makes a Java agent cancel the turn. Default: none, a + * prompt waits for the end of its turn however long it takes; the request timeout does not + * apply to it. {@link Duration#ZERO} also means none. To stop a turn early and still + * receive its answer, send {@code session/cancel} instead. + * @param promptTimeout the longest a turn may take, or {@link Duration#ZERO} for no limit + * @return this builder + * @throws IllegalArgumentException if {@code promptTimeout} is null or negative + */ + public AsyncSpec promptTimeout(Duration promptTimeout) { + Assert.notNull(promptTimeout, "Prompt timeout must not be null"); + Assert.isTrue(!promptTimeout.isNegative(), "Prompt timeout must not be negative"); + this.promptTimeout = promptTimeout.isZero() ? null : promptTimeout; + return this; + } + + /** + * Sets the capabilities the client advertises to the agent in {@code initialize}: file + * reads and writes, terminals, boolean config options, authentication and elicitation. + * Every initialize request carries them. Set this way they are the whole truth: nothing is + * derived from the handlers, {@code build()} throws when {@code fs.readTextFile}, + * {@code fs.writeTextFile}, {@code terminal} or {@code elicitation} is advertised without + * its handlers, and logs a warning for a handler whose capability is not. Default: derived + * from the handlers ({@code fs.readTextFile} and {@code fs.writeTextFile} for their + * handlers, {@code terminal} once all five terminal handlers are registered, form-mode + * elicitation for an elicitation handler), which is {@code new ClientCapabilities()} when + * none is registered. Set them for anything the handlers cannot say: URL-mode elicitation, + * boolean config options, terminal authentication or {@code _meta}. Build them with + * {@link AcpSchema.ClientCapabilities#builder()}. + * @param clientCapabilities the capabilities + * @return this builder + * @throws IllegalArgumentException if {@code clientCapabilities} is null */ public AsyncSpec clientCapabilities(AcpSchema.ClientCapabilities clientCapabilities) { Assert.notNull(clientCapabilities, "Client capabilities must not be null"); @@ -233,733 +296,1081 @@ public AsyncSpec clientCapabilities(AcpSchema.ClientCapabilities clientCapabilit } /** - * Adds a typed handler for file system read requests from the agent. - * This is the preferred method as it provides type-safe request handling - * without manual unmarshalling. + * Sets the client's name and version, sent to the agent in {@code initialize}. Optional. + * @param clientInfo the client's name and version + * @return this builder + * @throws IllegalArgumentException if {@code clientInfo} is null + */ + public AsyncSpec clientInfo(AcpSchema.Implementation clientInfo) { + Assert.notNull(clientInfo, "Client info must not be null"); + this.clientInfo = clientInfo; + return this; + } + + /** + * Sets the handler for {@code fs/read_text_file}: the agent asks for the content of a text + * file, which should include unsaved changes in the user's editor. Without + * {@link #clientCapabilities} the client advertises {@code fs.readTextFile} for it; with + * them, advertise it there as well, or {@code build()} warns. * - *

Example usage: *

{@code
-		 * .readTextFileHandler(req ->
-		 *     Mono.fromCallable(() -> Files.readString(Path.of(req.path())))
-		 *         .map(ReadTextFileResponse::new))
+		 * .readTextFileHandler(request -> Mono
+		 *     .fromCallable(() -> Files.readString(Path.of(request.path())))
+		 *     .subscribeOn(Schedulers.boundedElastic())
+		 *     .map(AcpSchema.ReadTextFileResponse::new))
 		 * }
* - * @param handler The typed handler function that processes read requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec readTextFileHandler( Function> handler) { Assert.notNull(handler, "Read text file handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.ReadTextFileRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_FS_READ_TEXT_FILE, rawHandler); - return this; + return request("readTextFileHandler", AcpSchema.METHOD_FS_READ_TEXT_FILE, new TypeRef() { + }, handler); } /** - * Adds a typed handler for file system write requests from the agent. - * This is the preferred method as it provides type-safe request handling - * without manual unmarshalling. + * Sets the handler for {@code fs/write_text_file}: the agent asks to write a text file, + * which ACP requires the client to create if it does not exist. Without + * {@link #clientCapabilities} the client advertises {@code fs.writeTextFile} for it; with + * them, advertise it there as well. * - *

Example usage: *

{@code
-		 * .writeTextFileHandler(req ->
-		 *     Mono.fromRunnable(() -> Files.writeString(Path.of(req.path()), req.content()))
-		 *         .then(Mono.just(new WriteTextFileResponse())))
+		 * .writeTextFileHandler(request -> Mono
+		 *     .fromCallable(() -> Files.writeString(Path.of(request.path()), request.content()))
+		 *     .subscribeOn(Schedulers.boundedElastic())
+		 *     .thenReturn(new AcpSchema.WriteTextFileResponse()))
 		 * }
* - * @param handler The typed handler function that processes write requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec writeTextFileHandler( Function> handler) { Assert.notNull(handler, "Write text file handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.WriteTextFileRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, rawHandler); - return this; + return request("writeTextFileHandler", AcpSchema.METHOD_FS_WRITE_TEXT_FILE, new TypeRef() { + }, handler); } /** - * Adds a typed handler for permission requests from the agent. - * This is the preferred method as it provides type-safe request handling - * without manual unmarshalling. + * Sets the handler for {@code session/request_permission}: the agent asks the user to + * approve a tool call and offers the options to choose from. Answer with the selected + * option ({@link AcpSchema.PermissionSelected}), or with + * {@link AcpSchema.PermissionCancelled} once the prompt turn was cancelled, as ACP + * requires. Every client should register one: without it the request is answered + * {@code -32601} (method not found). * - *

Example usage: *

{@code
-		 * .requestPermissionHandler(req ->
-		 *     Mono.just(new RequestPermissionResponse(
-		 *         new RequestPermissionOutcome("approve", null))))
+		 * .requestPermissionHandler(request -> Mono.just(new AcpSchema.RequestPermissionResponse(
+		 *     new AcpSchema.PermissionSelected(request.options().get(0).optionId()))))
 		 * }
* - * @param handler The typed handler function that processes permission requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec requestPermissionHandler( Function> handler) { Assert.notNull(handler, "Request permission handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.RequestPermissionRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, rawHandler); - return this; + return request("requestPermissionHandler", AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, new TypeRef() { + }, handler); } /** - * Adds a typed handler for terminal creation requests from the agent. - * - *

Example usage: - *

{@code
-		 * .createTerminalHandler(req -> {
-		 *     String terminalId = UUID.randomUUID().toString();
-		 *     // Start process with req.command(), req.args(), req.cwd()
-		 *     return Mono.just(new CreateTerminalResponse(terminalId));
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/create}: the agent asks to start a command in a new + * terminal and gets back its ID. Register the other four terminal handlers as well: with all + * five, the client advertises {@code terminal} unless {@link #clientCapabilities} are set, + * in which case advertise it there. * - * @param handler The typed handler function that processes terminal creation requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec createTerminalHandler( Function> handler) { Assert.notNull(handler, "Create terminal handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.CreateTerminalRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_CREATE, rawHandler); - return this; + return request("createTerminalHandler", AcpSchema.METHOD_TERMINAL_CREATE, new TypeRef() { + }, handler); } /** - * Adds a typed handler for terminal output requests from the agent. - * - *

Example usage: - *

{@code
-		 * .terminalOutputHandler(req -> {
-		 *     String output = getTerminalOutput(req.terminalId());
-		 *     return Mono.just(new TerminalOutputResponse(output, false, null));
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/output}: the agent asks for a terminal's output so + * far, whether it was truncated, and the exit status if the command has ended. * - * @param handler The typed handler function that processes terminal output requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec terminalOutputHandler( Function> handler) { Assert.notNull(handler, "Terminal output handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.TerminalOutputRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_OUTPUT, rawHandler); - return this; + return request("terminalOutputHandler", AcpSchema.METHOD_TERMINAL_OUTPUT, new TypeRef() { + }, handler); } /** - * Adds a typed handler for terminal release requests from the agent. - * - *

Example usage: - *

{@code
-		 * .releaseTerminalHandler(req -> {
-		 *     releaseTerminal(req.terminalId());
-		 *     return Mono.just(new ReleaseTerminalResponse());
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/release}: the agent is done with a terminal. Kill + * its command if it is still running and free the terminal; its ID is invalid afterwards. * - * @param handler The typed handler function that processes terminal release requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec releaseTerminalHandler( Function> handler) { Assert.notNull(handler, "Release terminal handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.ReleaseTerminalRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_RELEASE, rawHandler); - return this; + return request("releaseTerminalHandler", AcpSchema.METHOD_TERMINAL_RELEASE, new TypeRef() { + }, handler); } /** - * Adds a typed handler for wait-for-terminal-exit requests from the agent. - * - *

Example usage: - *

{@code
-		 * .waitForTerminalExitHandler(req -> {
-		 *     int exitCode = waitForExit(req.terminalId());
-		 *     return Mono.just(new WaitForTerminalExitResponse(exitCode, null));
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/wait_for_exit}: answer once the terminal's command + * has ended, with its exit code or the signal that ended it. The agent's request timeout + * bounds how long the agent waits for this answer. * - * @param handler The typed handler function that processes wait-for-exit requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec waitForTerminalExitHandler( Function> handler) { Assert.notNull(handler, "Wait for terminal exit handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.WaitForTerminalExitRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, rawHandler); - return this; + return request("waitForTerminalExitHandler", AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, new TypeRef() { + }, handler); } /** - * Adds a typed handler for terminal kill requests from the agent. - * - *

Example usage: - *

{@code
-		 * .killTerminalHandler(req -> {
-		 *     killProcess(req.terminalId());
-		 *     return Mono.just(new KillTerminalCommandResponse());
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/kill}: kill the terminal's command but keep the + * terminal, so its output and exit status can still be read until it is released. * - * @param handler The typed handler function that processes terminal kill requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it emits the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec killTerminalHandler( Function> handler) { Assert.notNull(handler, "Kill terminal handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.KillTerminalCommandRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_KILL, rawHandler); - return this; + return request("killTerminalHandler", AcpSchema.METHOD_TERMINAL_KILL, new TypeRef() { + }, handler); } /** - * Adds a typed handler for elicitation requests from the agent. - * The agent uses elicitation to request structured user input via forms. + * Sets the handler for {@code elicitation/create}: the agent asks the user for structured + * input, with a form or by sending the user to a URL. The handler answers accept (with the + * form content), decline or cancel. * - * @param handler The typed handler function that processes elicitation requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + *

A request whose mode ({@code form} or {@code url}) the client did not advertise in its + * elicitation capabilities is answered with {@code -32602} (invalid params) without calling + * the handler. Without {@link #clientCapabilities} the client advertises form mode for this + * handler; to offer URL mode, advertise the modes the handler supports there, for example + * with {@link AcpSchema.ElicitationCapabilities#formAndUrl()}. + * @param handler the handler; it emits the user's answer + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public AsyncSpec createElicitationHandler( Function> handler) { Assert.notNull(handler, "Create elicitation handler must not be null"); - AcpClientSession.RequestHandler rawHandler = params -> { - AcpSchema.CreateElicitationRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_ELICITATION_CREATE, rawHandler); + if (this.createElicitationHandler != null) { + throw alreadyRegistered(AcpSchema.METHOD_ELICITATION_CREATE, "createElicitationHandler"); + } + this.createElicitationHandler = handler; + return this; + } + + /** + * Sets the handler for {@code elicitation/complete} notifications: the agent reports that + * the outside interaction of a URL-mode elicitation has finished. ACP requires clients to + * ignore unknown or already completed elicitation IDs, so check the ID against the URL + * elicitations the user accepted. + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null + */ + public AsyncSpec completeElicitationHandler( + Function> handler) { + Assert.notNull(handler, "Complete elicitation handler must not be null"); + return notification("completeElicitationHandler", AcpSchema.METHOD_ELICITATION_COMPLETE, params -> handler + .apply(transport.unmarshalFrom(params, new TypeRef() { + }))); + } + + /** + * Adds a handler for {@code session/update} notifications: the message and thought chunks, + * tool calls, plans and other updates the agent streams during a prompt turn, and the + * updates it sends between turns. The handler's {@code Mono} completes when it has + * finished with a notification. + * + *

Notifications are delivered one at a time, in the order the agent sent them, and each + * goes to every handler added: the next one waits until all handlers have finished with + * this one. A response from the agent, such as a prompt's, completes its caller only after + * the handlers have finished with every notification sent before it, so the updates of a + * turn are all handled when {@code prompt} returns. A slow handler therefore delays + * responses, and the wait counts against the request timeout. A handler must not wait for + * a prompt in flight to complete: that prompt waits for the handler. A handler that fails + * is logged, and the next notification follows. Handlers add up; the first one added + * replaces the {@link #defaultSessionUpdateHandler default handler}, if one is set. + * @param sessionUpdateHandler the handler + * @return this builder + * @throws IllegalArgumentException if {@code sessionUpdateHandler} is null + */ + public AsyncSpec sessionUpdateHandler( + Function> sessionUpdateHandler) { + Assert.notNull(sessionUpdateHandler, "Session update handler must not be null"); + this.sessionUpdateHandlers.add(sessionUpdateHandler); return this; } /** - * Adds a consumer to be notified when session update notifications are received - * from the agent. Session updates include agent thoughts, message chunks, and - * other streaming content during prompt processing. - * @param sessionUpdateConsumer A consumer that receives session update - * notifications. Must not be null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if sessionUpdateConsumer is null + * Sets the handler that receives {@code session/update} notifications when no + * {@link #sessionUpdateHandler} is added: a default that the application's own handlers + * replace rather than run beside, whichever is registered first. It is meant for + * frameworks, which set a default (such as logging each update at DEBUG) before handing + * the builder to the application's customizers. It is delivered like a handler added + * with {@code sessionUpdateHandler}. + * @param handler the default handler + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null + * @throws IllegalStateException if a default handler is already set */ - public AsyncSpec sessionUpdateConsumer( - Function> sessionUpdateConsumer) { - Assert.notNull(sessionUpdateConsumer, "Session update consumer must not be null"); - this.sessionUpdateConsumers.add(sessionUpdateConsumer); + public AsyncSpec defaultSessionUpdateHandler(Function> handler) { + Assert.notNull(handler, "Default session update handler must not be null"); + if (this.defaultSessionUpdateHandler != null) { + throw new IllegalStateException( + "A default session update handler is already set; defaultSessionUpdateHandler was called twice"); + } + this.defaultSessionUpdateHandler = handler; return this; } /** - * Adds a custom request handler for a specific method. This allows handling - * additional agent requests beyond the standard file system and permission - * operations. - * @param method The method name (e.g., "custom/operation") - * @param handler The handler function for this method - * @return This builder instance for method chaining - * @throws IllegalArgumentException if method or handler is null + * Registers a handler for an agent-to-client request method the SDK does not model, with + * the params as the transport read them: the escape hatch for a method newer than this + * SDK. A method with a typed setter ({@code fs/*}, {@code terminal/*}, + * {@code session/request_permission}, {@code elicitation/create}) is refused, since the + * raw handler would bypass its typed params and checks; use the setter the exception + * names. For extension methods prefer + * {@link #extRequestHandler(String, TypeRef, Function)}. + * @param method the method name + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if {@code method} or {@code handler} is null, or the + * method has a typed setter + * @throws IllegalStateException if the method already has a handler */ public AsyncSpec requestHandler(String method, AcpClientSession.RequestHandler handler) { Assert.notNull(method, "Method must not be null"); Assert.notNull(handler, "Handler must not be null"); - this.requestHandlers.put(method, handler); + refuseTyped(method, TYPED_REQUEST_SETTERS, "requestHandler"); + if (this.requestHandlers.putIfAbsent(method, handler) != null) { + throw alreadyRegistered(method, "requestHandler"); + } return this; } /** - * Adds a custom notification handler for a specific method. This allows handling - * additional agent notifications beyond session updates. - * @param method The method name (e.g., "custom/notification") - * @param handler The handler function for this method - * @return This builder instance for method chaining - * @throws IllegalArgumentException if method or handler is null + * Registers a handler for an agent-to-client notification method the SDK does not model, + * with the params as the transport read them. It is delivered in order with the session + * updates, and it is called on the delivering thread, so it must not block. A method with + * a typed setter ({@code session/update}, {@code elicitation/complete}) is refused; use + * the setter the exception names. For extension methods prefer + * {@code extNotificationHandler}. + * @param method the method name + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if {@code method} or {@code handler} is null, or the + * method has a typed setter + * @throws IllegalStateException if the method already has a handler */ public AsyncSpec notificationHandler(String method, AcpClientSession.NotificationHandler handler) { Assert.notNull(method, "Method must not be null"); Assert.notNull(handler, "Handler must not be null"); - this.notificationHandlers.put(method, handler); + refuseTyped(method, TYPED_NOTIFICATION_SETTERS, "notificationHandler"); + return notification("notificationHandler", method, handler); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the agent, its params read as the given type. A request for an + * extension method without a handler is answered with "Method not found" ({@code -32601}). + * + *

{@code
+		 * .extRequestHandler("_example.com/workspace/buffers",
+		 *     params -> Mono.just(Map.of("buffers", List.of())))
+		 * }
+ * + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as + * @param handler the handler; its result is any value the JSON mapper can write, and it + * must not complete empty (the request is then answered with an internal error, + * {@code -32603}): answer with an empty map when there is nothing to return + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _}, or the + * type or handler is null + */ + public AsyncSpec extRequestHandler(String method, TypeRef paramsType, + Function> handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return request("extRequestHandler", method, paramsType, params -> handler.apply(params).cast(Object.class)); + } + + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the agent, its params delivered as the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}). + * @param method the method name, which must start with {@code _} + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, Function) + */ + public AsyncSpec extRequestHandler(String method, Function> handler) { + return extRequestHandler(method, RAW_PARAMS, handler); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the agent, its params read as the given type. An extension notification + * without a handler is ignored, as the protocol asks. Extension notifications are delivered + * in order with the session updates. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _}, or the + * type or handler is null + */ + public AsyncSpec extNotificationHandler(String method, TypeRef paramsType, + Function> handler) { + ExtensionMethods.requireExtension(method); + Assert.notNull(paramsType, "Params type must not be null"); + Assert.notNull(handler, "Handler must not be null"); + return notification("extNotificationHandler", method, + params -> handler.apply(transport.unmarshalParams(params, paramsType))); + } + + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the agent, its params delivered as the raw JSON value. + * @param method the method name, which must start with {@code _} + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, Function) + */ + public AsyncSpec extNotificationHandler(String method, Function> handler) { + return extNotificationHandler(method, RAW_PARAMS, handler); + } + + /** Reads the params as the request type, then calls the handler. */ + private AsyncSpec request(String setter, String method, TypeRef requestType, + Function> handler) { + AcpClientSession.RequestHandler rawHandler = params -> handler + .apply(transport.unmarshalParams(params, requestType)); + if (this.requestHandlers.putIfAbsent(method, rawHandler) != null) { + throw alreadyRegistered(method, setter); + } return this; } + /** Registers a notification handler, unless the method has one. */ + private AsyncSpec notification(String setter, String method, AcpClientSession.NotificationHandler handler) { + if (this.notificationHandlers.putIfAbsent(method, handler) != null) { + throw alreadyRegistered(method, setter); + } + return this; + } + + /** The five {@code terminal/*} methods, all served for {@code terminal} to be advertised. */ + private static final List TERMINAL_METHODS = List.of(AcpSchema.METHOD_TERMINAL_CREATE, + AcpSchema.METHOD_TERMINAL_OUTPUT, AcpSchema.METHOD_TERMINAL_RELEASE, + AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, AcpSchema.METHOD_TERMINAL_KILL); + + /** + * The capabilities a client without {@code clientCapabilities(..)} advertises: what its + * handlers serve. Terminals need all five handlers; an elicitation handler advertises form + * mode, the mode every elicitation client renders (URL mode is advertised explicitly). With + * no such handler it is {@code new ClientCapabilities()}. Warns about a partial set of + * terminal handlers, which advertises no terminal. + */ + private AcpSchema.ClientCapabilities deriveCapabilities() { + AcpSchema.ClientCapabilities.Builder derived = AcpSchema.ClientCapabilities.builder(); + if (this.requestHandlers.containsKey(AcpSchema.METHOD_FS_READ_TEXT_FILE)) { + derived.readTextFile(); + } + if (this.requestHandlers.containsKey(AcpSchema.METHOD_FS_WRITE_TEXT_FILE)) { + derived.writeTextFile(); + } + List absent = TERMINAL_METHODS.stream() + .filter(method -> !this.requestHandlers.containsKey(method)) + .map(TYPED_REQUEST_SETTERS::get) + .toList(); + if (absent.isEmpty()) { + derived.terminal(); + } + else if (absent.size() < TERMINAL_METHODS.size()) { + logger.warn("The client has some terminal handlers but not {}, so it does not advertise terminal and " + + "an agent will not call them. Register all five terminal handlers", String.join(", ", absent)); + } + if (this.createElicitationHandler != null) { + derived.elicitationForm(); + } + return derived.build(); + } + + /** + * Fails when the advertised capabilities claim a capability without the handlers that + * serve it, and warns once about handlers for capabilities not advertised. + */ + private void checkCapabilitiesHaveHandlers(AcpSchema.ClientCapabilities capabilities) { + NegotiatedCapabilities advertised = NegotiatedCapabilities.fromClient(capabilities); + List missing = new ArrayList<>(); + List unadvertised = new ArrayList<>(); + checkCapability("fs.readTextFile", advertised.supportsReadTextFile(), + List.of(AcpSchema.METHOD_FS_READ_TEXT_FILE), missing, unadvertised); + checkCapability("fs.writeTextFile", advertised.supportsWriteTextFile(), + List.of(AcpSchema.METHOD_FS_WRITE_TEXT_FILE), missing, unadvertised); + checkCapability("terminal", advertised.supportsTerminal(), TERMINAL_METHODS, missing, unadvertised); + // An elicitation capability without a mode advertises nothing the agent may ask for. + checkCapability("elicitation", advertised.supportsElicitationForm() || advertised.supportsElicitationUrl(), + List.of(AcpSchema.METHOD_ELICITATION_CREATE), missing, unadvertised); + if (!missing.isEmpty()) { + throw new IllegalStateException("The client advertises capabilities it has no handler for: " + + String.join("; ", missing) + + ". Register the handlers, or do not advertise the capabilities in clientCapabilities(..)"); + } + if (!unadvertised.isEmpty()) { + logger.warn("The client has handlers for capabilities it does not advertise, so an agent will not " + + "call them: {}. Advertise the capabilities in clientCapabilities(..)", + String.join("; ", unadvertised)); + } + } + + private void checkCapability(String capability, boolean advertised, List methods, + List missing, List unadvertised) { + List absent = new ArrayList<>(); + List present = new ArrayList<>(); + for (String method : methods) { + boolean handled = AcpSchema.METHOD_ELICITATION_CREATE.equals(method) + ? this.createElicitationHandler != null : this.requestHandlers.containsKey(method); + (handled ? present : absent).add(TYPED_REQUEST_SETTERS.get(method)); + } + if (advertised && !absent.isEmpty()) { + missing.add(capability + " needs " + String.join(", ", absent)); + } + else if (!advertised && !present.isEmpty()) { + unadvertised.add(String.join(", ", present) + " (" + capability + ")"); + } + } + + /** The raw setters are for methods the SDK does not model; a modelled one has its setter. */ + private static void refuseTyped(String method, Map typedSetters, String rawSetter) { + String setter = typedSetters.get(method); + if (setter != null) { + throw new IllegalArgumentException(method + " has a typed setter: use " + setter + " instead of " + + rawSetter + ", which is for methods the SDK does not model"); + } + } + + /** A second handler for a method: a mistake, which would silently replace the first. */ + private static IllegalStateException alreadyRegistered(String method, String setter) { + return new IllegalStateException("A handler for " + method + " is already registered on this builder; " + + setter + " cannot register a second one"); + } + /** - * Creates an instance of {@link AcpAsyncClient} with the provided configurations - * or sensible defaults. - * @return a new instance of {@link AcpAsyncClient} + * Builds the client and connects the transport: for a + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport} that starts + * the agent process. Call {@link AcpAsyncClient#initialize()} next. A transport carries one + * client, so build once per transport. + * @return the client + * @throws IllegalStateException if the capabilities advertise {@code fs.readTextFile}, + * {@code fs.writeTextFile}, {@code terminal} or {@code elicitation} without the handlers + * that serve them (the message names the missing setters); or if the transport refuses to + * connect at once, for example because it is already connected; a connection that fails + * later fails the client's requests instead */ public AcpAsyncClient build() { - // Set up session update notification handler - if (!sessionUpdateConsumers.isEmpty()) { - notificationHandlers.put(AcpSchema.METHOD_SESSION_UPDATE, params -> { - AcpSchema.SessionNotification notification = transport.unmarshalFrom(params, + // Explicit capabilities win; without them the handlers decide what is advertised + AcpSchema.@Nullable ClientCapabilities explicit = this.clientCapabilities; + AcpSchema.ClientCapabilities capabilities; + if (explicit != null) { + checkCapabilitiesHaveHandlers(explicit); + capabilities = explicit; + } + else { + capabilities = deriveCapabilities(); + } + + // Set up session update notification handler: the application's handlers, or else the + // default one + List>> updateHandlers = new ArrayList<>( + sessionUpdateHandlers); + Function> defaultHandler = this.defaultSessionUpdateHandler; + if (updateHandlers.isEmpty() && defaultHandler != null) { + updateHandlers.add(defaultHandler); + } + Map notifications = new HashMap<>(notificationHandlers); + if (!updateHandlers.isEmpty()) { + notifications.put(AcpSchema.METHOD_SESSION_UPDATE, params -> { + AcpSchema.SessionNotification notification = transport.unmarshalParams(params, new TypeRef() { }); logger.debug("Received session update for session: {}", notification.sessionId()); - // Call all registered consumers - return Mono - .when(sessionUpdateConsumers.stream().map(consumer -> consumer.apply(notification)).toList()); + // Call all registered handlers + return Mono.when(updateHandlers.stream().map(handler -> handler.apply(notification)).toList()); }); } + // The capabilities the client advertises when it initializes + AtomicReference advertised = new AtomicReference<>(); + Map> handlers = new HashMap<>(requestHandlers); + Function> elicitation = this.createElicitationHandler; + if (elicitation != null) { + AcpClientSession.RequestHandler rawHandler = params -> { + AcpSchema.CreateElicitationRequest request = transport.unmarshalFrom(params, + new TypeRef() { + }); + if (!advertisesMode(advertised.get(), request.mode())) { + return Mono.error(new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, + "Elicitation mode '" + request.mode() + "' was not advertised by the client")); + } + return elicitation.apply(request); + }; + handlers.put(AcpSchema.METHOD_ELICITATION_CREATE, rawHandler); + } + // Create session with request and notification handlers - AcpSession session = new AcpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers, + AcpSession session = new AcpClientSession(requestTimeout, transport, handlers, notifications, Function.identity()); - return new AcpAsyncClient(session, transport, clientCapabilities); + return new AcpAsyncClient(session, transport, capabilities, clientInfo, advertised, promptTimeout); + } + + /** + * Whether the advertised capabilities cover an elicitation mode. Before the + * client initializes nothing is known and every mode passes; a mode this SDK does + * not know passes too, since only the handler can judge it. + */ + private static boolean advertisesMode(AcpSchema.@Nullable ClientCapabilities advertised, String mode) { + if (advertised == null) { + return true; + } + AcpSchema.ElicitationCapabilities elicitation = advertised.elicitation(); + return switch (mode) { + case AcpSchema.CreateElicitationRequest.MODE_FORM -> elicitation != null && elicitation.form() != null; + case AcpSchema.CreateElicitationRequest.MODE_URL -> elicitation != null && elicitation.url() != null; + default -> true; + }; } } /** - * Synchronous client specification. This class follows the builder pattern to - * provide a fluent API for setting up synchronous clients with custom configurations. + * Configures and builds an {@link AcpSyncClient}: the client's capabilities and info, the + * request timeout, the session update handlers, and the handlers for the agent's requests and + * notifications, each returning a plain value. Get one from + * {@link AcpClient#sync(AcpClientTransport)}. * - *

- * The builder supports configuration of: - *

    - *
  • Transport layer for client-agent communication
  • - *
  • Request timeouts for operation boundaries
  • - *
  • Client capabilities for feature negotiation
  • - *
  • Request handlers for incoming agent requests (file operations, etc.)
  • - *
  • Notification handlers for streaming updates
  • - *
+ *

Handlers and session update handlers run on the sync builder's handler executor, not + * on the transport's thread, so they may block. A request is handed to its handler once the + * session updates before it have been handled, without waiting for other handlers, so several + * handlers can run at the same time and state they share must be thread-safe. A request handler + * that returns {@code null} is answered {@code -32603}. The builder turns each handler into its + * asynchronous counterpart on an {@link AsyncSpec}, so the rules described there apply. + * {@link #build()} connects the transport. A builder is not thread-safe; configure it on one + * thread. */ class SyncSpec { - private final AcpClientTransport transport; - - private Duration requestTimeout = Duration.ofSeconds(30); // Default timeout - - private AcpSchema.ClientCapabilities clientCapabilities; - - private final Map> requestHandlers = new HashMap<>(); - - private final Map notificationHandlers = new HashMap<>(); + private final AsyncSpec asyncSpec; - private final List>> sessionUpdateConsumers = new ArrayList<>(); + /** Where the handlers run; read when one is called. */ + private Scheduler handlerScheduler = SyncHandlerScheduler.DEFAULT; private SyncSpec(AcpClientTransport transport) { - Assert.notNull(transport, "Transport must not be null"); - this.transport = transport; + this.asyncSpec = new AsyncSpec(transport); + } + + /** + * Sets the executor the handlers run on, for example + * {@code Executors.newVirtualThreadPerTaskExecutor()} or a framework's worker pool. Without + * it each call runs on a virtual thread of its own on JDK 21 and later, and before that on + * a pool of daemon threads shared by every synchronous client in the JVM, which has no size + * limit: each handler running at the same time takes a thread of its own. Either way the + * threads are named {@code acp-sync-handler}. On JDK 21 to 23 a virtual thread that blocks + * inside a {@code synchronized} block pins its carrier thread (JDK 24 removed that, JEP + * 491). The executor must allow blocking. A handler the agent + * cancels is interrupted (its task is cancelled), and the SDK never shuts the executor + * down; that is the application's job. + * @param executor the executor the handlers run on + * @return this builder + * @throws IllegalArgumentException if {@code executor} is null + */ + public SyncSpec handlerExecutor(ExecutorService executor) { + Assert.notNull(executor, "Executor must not be null"); + this.handlerScheduler = Schedulers.fromExecutorService(executor, "acp-client-handlers"); + return this; } /** * Converts a sync request handler to an async request handler. * Follows the MCP SDK pattern of wrapping sync handlers with Mono.fromCallable() - * and scheduling on a library-owned daemon scheduler to prevent blocking the event loop. + * and scheduling on the handler executor to prevent blocking the event loop. * * @param The response type * @param syncHandler The synchronous handler to convert * @return An async handler that wraps the sync handler */ - private static AcpClientSession.RequestHandler fromSync(SyncRequestHandler syncHandler) { - return params -> Mono.fromCallable(() -> syncHandler.handle(params)) - .subscribeOn(SYNC_HANDLER_SCHEDULER); + private AcpClientSession.RequestHandler fromSync(SyncRequestHandler syncHandler) { + return params -> onSyncHandlerThread(() -> syncHandler.handle(params)); + } + + /** Runs a sync handler on the handler executor, so it may block. */ + private Mono onSyncHandlerThread(Callable handler) { + return Mono.fromCallable(HandlerFailures.guard(handler)).subscribeOn(this.handlerScheduler); } /** - * Sets the duration to wait for agent responses before timing out requests. This - * timeout applies to all requests made through the client, including initialize, - * prompt, and session operations. - * @param requestTimeout The duration to wait before timing out requests. Must not - * be null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if requestTimeout is null + * Sets how long the client waits for the agent to answer a request: {@code initialize}, + * session calls and extension requests. When it passes, the call fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}; a Java agent then cancels the handler. Default: 60 seconds, as for the agent builders. + * {@code session/prompt} is not bound by it, since its answer comes only at the end of the + * turn; see {@code promptTimeout}. + * @param requestTimeout the timeout + * @return this builder + * @throws IllegalArgumentException if {@code requestTimeout} is null */ public SyncSpec requestTimeout(Duration requestTimeout) { - Assert.notNull(requestTimeout, "Request timeout must not be null"); - this.requestTimeout = requestTimeout; + asyncSpec.requestTimeout(requestTimeout); + return this; + } + + /** + * Sets how long a prompt turn ({@code session/prompt}) may take before the client gives up + * on it. When it passes, {@code prompt} fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}, which makes a Java agent cancel the turn. Default: none, a + * prompt waits for the end of its turn however long it takes; the request timeout does not + * apply to it. {@link Duration#ZERO} also means none. To stop a turn early and still + * receive its answer, send {@code session/cancel} instead. + * @param promptTimeout the longest a turn may take, or {@link Duration#ZERO} for no limit + * @return this builder + * @throws IllegalArgumentException if {@code promptTimeout} is null or negative + */ + public SyncSpec promptTimeout(Duration promptTimeout) { + asyncSpec.promptTimeout(promptTimeout); return this; } /** - * Sets the client capabilities that will be advertised to the agent during - * initialization. Capabilities define what features the client supports, such as - * file system operations, terminal access, and authentication methods. - * @param clientCapabilities The client capabilities configuration. Must not be - * null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if clientCapabilities is null + * Sets the capabilities the client advertises to the agent in {@code initialize}: file + * reads and writes, terminals, boolean config options, authentication and elicitation. + * Every initialize request carries them. Set this way they are the whole truth: nothing is + * derived from the handlers, {@code build()} throws when {@code fs.readTextFile}, + * {@code fs.writeTextFile}, {@code terminal} or {@code elicitation} is advertised without + * its handlers, and logs a warning for a handler whose capability is not. Default: derived + * from the handlers ({@code fs.readTextFile} and {@code fs.writeTextFile} for their + * handlers, {@code terminal} once all five terminal handlers are registered, form-mode + * elicitation for an elicitation handler), which is {@code new ClientCapabilities()} when + * none is registered. Set them for anything the handlers cannot say: URL-mode elicitation, + * boolean config options, terminal authentication or {@code _meta}. Build them with + * {@link AcpSchema.ClientCapabilities#builder()}. + * @param clientCapabilities the capabilities + * @return this builder + * @throws IllegalArgumentException if {@code clientCapabilities} is null */ public SyncSpec clientCapabilities(AcpSchema.ClientCapabilities clientCapabilities) { - Assert.notNull(clientCapabilities, "Client capabilities must not be null"); - this.clientCapabilities = clientCapabilities; + asyncSpec.clientCapabilities(clientCapabilities); + return this; + } + + /** + * Sets the client's name and version, sent to the agent in {@code initialize}. Optional. + * @param clientInfo the client's name and version + * @return this builder + * @throws IllegalArgumentException if {@code clientInfo} is null + */ + public SyncSpec clientInfo(AcpSchema.Implementation clientInfo) { + asyncSpec.clientInfo(clientInfo); return this; } /** - * Adds a typed handler for file system read requests from the agent. - * Provides type-safe request handling with automatic unmarshalling. + * Sets the handler for {@code fs/read_text_file}: the agent asks for the content of a text + * file, which should include unsaved changes in the user's editor. Without + * {@link #clientCapabilities} the client advertises {@code fs.readTextFile} for it; with + * them, advertise it there as well, or {@code build()} warns. * - *

Example usage: *

{@code
-		 * .readTextFileHandler(req ->
-		 *     new ReadTextFileResponse(Files.readString(Path.of(req.path()))))
+		 * .readTextFileHandler(request -> {
+		 *     try {
+		 *         String content = Files.readString(Path.of(request.path()));
+		 *         return new AcpSchema.ReadTextFileResponse(content);
+		 *     } catch (IOException e) {
+		 *         throw new UncheckedIOException(e);
+		 *     }
+		 * })
 		 * }
* - * @param handler The typed handler function that processes read requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec readTextFileHandler( Function handler) { Assert.notNull(handler, "Read text file handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("readTextFile request params: {}", params); - AcpSchema.ReadTextFileRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_FS_READ_TEXT_FILE, fromSync(rawHandler)); + asyncSpec.readTextFileHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for file system write requests from the agent. - * Provides type-safe request handling with automatic unmarshalling. - * - *

Example usage: - *

{@code
-		 * .writeTextFileHandler(req -> {
-		 *     Files.writeString(Path.of(req.path()), req.content());
-		 *     return new WriteTextFileResponse();
-		 * })
-		 * }
+ * Sets the handler for {@code fs/write_text_file}: the agent asks to write a text file, + * which ACP requires the client to create if it does not exist. Without + * {@link #clientCapabilities} the client advertises {@code fs.writeTextFile} for it; with + * them, advertise it there as well. * - * @param handler The typed handler function that processes write requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec writeTextFileHandler( Function handler) { Assert.notNull(handler, "Write text file handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("writeTextFile request params: {}", params); - AcpSchema.WriteTextFileRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, fromSync(rawHandler)); + asyncSpec.writeTextFileHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for permission requests from the agent. - * Provides type-safe request handling with automatic unmarshalling. + * Sets the handler for {@code session/request_permission}: the agent asks the user to + * approve a tool call and offers the options to choose from. Answer with the selected + * option ({@link AcpSchema.PermissionSelected}), or with + * {@link AcpSchema.PermissionCancelled} once the prompt turn was cancelled, as ACP + * requires. Every client should register one: without it the request is answered + * {@code -32601} (method not found). * - *

Example usage: *

{@code
-		 * .requestPermissionHandler(req -> {
-		 *     System.out.println("Permission requested: " + req.toolCall().title());
-		 *     // Show UI or auto-approve
-		 *     return new RequestPermissionResponse(
-		 *         new RequestPermissionOutcome("approve", null));
+		 * .requestPermissionHandler(request -> {
+		 *     System.out.println("Permission requested: " + request.toolCall().title());
+		 *     return new AcpSchema.RequestPermissionResponse(
+		 *         new AcpSchema.PermissionSelected(request.options().get(0).optionId()));
 		 * })
 		 * }
* - * @param handler The typed handler function that processes permission requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec requestPermissionHandler( Function handler) { Assert.notNull(handler, "Request permission handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("requestPermission request params: {}", params); - AcpSchema.RequestPermissionRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, fromSync(rawHandler)); + asyncSpec.requestPermissionHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for terminal creation requests from the agent. - * - *

Example usage: - *

{@code
-		 * .createTerminalHandler(req -> {
-		 *     String terminalId = UUID.randomUUID().toString();
-		 *     // Start process with req.command(), req.args(), req.cwd()
-		 *     return new CreateTerminalResponse(terminalId);
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/create}: the agent asks to start a command in a new + * terminal and gets back its ID. Register the other four terminal handlers as well: with all + * five, the client advertises {@code terminal} unless {@link #clientCapabilities} are set, + * in which case advertise it there. * - * @param handler The typed handler function that processes terminal creation requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec createTerminalHandler( Function handler) { Assert.notNull(handler, "Create terminal handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("createTerminal request params: {}", params); - AcpSchema.CreateTerminalRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_CREATE, fromSync(rawHandler)); + asyncSpec.createTerminalHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for terminal output requests from the agent. + * Sets the handler for {@code terminal/output}: the agent asks for a terminal's output so + * far, whether it was truncated, and the exit status if the command has ended. * - *

Example usage: - *

{@code
-		 * .terminalOutputHandler(req -> {
-		 *     String output = getTerminalOutput(req.terminalId());
-		 *     return new TerminalOutputResponse(output, false, null);
-		 * })
-		 * }
- * - * @param handler The typed handler function that processes terminal output requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec terminalOutputHandler( Function handler) { Assert.notNull(handler, "Terminal output handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("terminalOutput request params: {}", params); - AcpSchema.TerminalOutputRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_OUTPUT, fromSync(rawHandler)); + asyncSpec.terminalOutputHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for terminal release requests from the agent. - * - *

Example usage: - *

{@code
-		 * .releaseTerminalHandler(req -> {
-		 *     releaseTerminal(req.terminalId());
-		 *     return new ReleaseTerminalResponse();
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/release}: the agent is done with a terminal. Kill + * its command if it is still running and free the terminal; its ID is invalid afterwards. * - * @param handler The typed handler function that processes terminal release requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec releaseTerminalHandler( Function handler) { Assert.notNull(handler, "Release terminal handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("releaseTerminal request params: {}", params); - AcpSchema.ReleaseTerminalRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_RELEASE, fromSync(rawHandler)); + asyncSpec.releaseTerminalHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for wait-for-terminal-exit requests from the agent. - * - *

Example usage: - *

{@code
-		 * .waitForTerminalExitHandler(req -> {
-		 *     int exitCode = waitForExit(req.terminalId());
-		 *     return new WaitForTerminalExitResponse(exitCode, null);
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/wait_for_exit}: answer once the terminal's command + * has ended, with its exit code or the signal that ended it. The agent's request timeout + * bounds how long the agent waits for this answer. * - * @param handler The typed handler function that processes wait-for-exit requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec waitForTerminalExitHandler( Function handler) { Assert.notNull(handler, "Wait for terminal exit handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("waitForTerminalExit request params: {}", params); - AcpSchema.WaitForTerminalExitRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, fromSync(rawHandler)); + asyncSpec.waitForTerminalExitHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for terminal kill requests from the agent. - * - *

Example usage: - *

{@code
-		 * .killTerminalHandler(req -> {
-		 *     killProcess(req.terminalId());
-		 *     return new KillTerminalCommandResponse();
-		 * })
-		 * }
+ * Sets the handler for {@code terminal/kill}: kill the terminal's command but keep the + * terminal, so its output and exit status can still be read until it is released. * - * @param handler The typed handler function that processes terminal kill requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * @param handler the handler; it returns the answer to the agent + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec killTerminalHandler( Function handler) { Assert.notNull(handler, "Kill terminal handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("killTerminal request params: {}", params); - AcpSchema.KillTerminalCommandRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_TERMINAL_KILL, fromSync(rawHandler)); + asyncSpec.killTerminalHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); return this; } /** - * Adds a typed handler for elicitation requests from the agent. - * The agent uses elicitation to request structured user input via forms. - * - * @param handler The typed handler function that processes elicitation requests - * @return This builder instance for method chaining - * @throws IllegalArgumentException if handler is null + * Sets the handler for {@code elicitation/create}: the agent asks the user for structured + * input, with a form or by sending the user to a URL, and the handler returns accept, + * decline or cancel. As with {@link AsyncSpec#createElicitationHandler(Function)}, a + * request for a mode the client did not advertise is answered with {@code -32602} without + * calling the handler. + * @param handler the handler; it returns the user's answer + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null */ public SyncSpec createElicitationHandler( Function handler) { Assert.notNull(handler, "Create elicitation handler must not be null"); - SyncRequestHandler rawHandler = params -> { - logger.debug("createElicitation request params: {}", params); - AcpSchema.CreateElicitationRequest request = transport.unmarshalFrom(params, - new TypeRef() {}); - return handler.apply(request); - }; - this.requestHandlers.put(AcpSchema.METHOD_ELICITATION_CREATE, fromSync(rawHandler)); + asyncSpec.createElicitationHandler(request -> onSyncHandlerThread(() -> handler.apply(request))); + return this; + } + + /** + * Sets the handler for {@code elicitation/complete} notifications: the agent reports that + * the outside interaction of a URL-mode elicitation has finished. ACP requires clients to + * ignore unknown or already completed elicitation IDs, so check the ID against the URL + * elicitations the user accepted. + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null + */ + public SyncSpec completeElicitationHandler(Consumer handler) { + Assert.notNull(handler, "Complete elicitation handler must not be null"); + asyncSpec.completeElicitationHandler(notification -> Mono + .fromRunnable(HandlerFailures.guard(() -> handler.accept(notification))) + .subscribeOn(this.handlerScheduler) + .then()); return this; } /** - * Adds a synchronous consumer to be notified when session update notifications - * are received from the agent. This is the preferred method for sync clients. + * Adds a handler for {@code session/update} notifications: the message and thought chunks, + * tool calls, plans and other updates the agent streams during a prompt turn, and the + * updates it sends between turns. The handler runs on + * the sync builder's handler executor and has finished with a notification when it + * returns. + * + *

Notifications are delivered one at a time, in the order the agent sent them, and each + * goes to every handler added: the next one waits until all handlers have finished with + * this one. A response from the agent, such as a prompt's, completes its caller only after + * the handlers have finished with every notification sent before it, so the updates of a + * turn are all handled when {@code prompt} returns. A slow handler therefore delays + * responses, and the wait counts against the request timeout. A handler must not wait for + * a prompt in flight to complete: that prompt waits for the handler. A handler that fails + * is logged, and the next notification follows. * - *

Example usage: *

{@code
-		 * .sessionUpdateConsumer(notification -> {
-		 *     if (notification.update() instanceof AgentMessageChunk msg) {
-		 *         System.out.println(msg.content());
+		 * .sessionUpdateHandler(notification -> {
+		 *     if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk
+		 *             && chunk.content() instanceof AcpSchema.TextContent text) {
+		 *         System.out.print(text.text());
 		 *     }
 		 * })
 		 * }
* - * @param sessionUpdateConsumer A consumer that receives session update - * notifications. Must not be null. - * @return This builder instance for method chaining - * @throws IllegalArgumentException if sessionUpdateConsumer is null - */ - public SyncSpec sessionUpdateConsumer(Consumer sessionUpdateConsumer) { - Assert.notNull(sessionUpdateConsumer, "Session update consumer must not be null"); - // Convert sync consumer to async Function - this.sessionUpdateConsumers.add(notification -> { - return Mono.fromRunnable(() -> sessionUpdateConsumer.accept(notification)) - .subscribeOn(SYNC_HANDLER_SCHEDULER) - .then(); - }); + * @param sessionUpdateHandler the handler + * @return this builder + * @throws IllegalArgumentException if {@code sessionUpdateHandler} is null + */ + public SyncSpec sessionUpdateHandler(Consumer sessionUpdateHandler) { + Assert.notNull(sessionUpdateHandler, "Session update handler must not be null"); + // Convert sync handler to async Function + asyncSpec.sessionUpdateHandler(notification -> Mono + .fromRunnable(HandlerFailures.guard(() -> sessionUpdateHandler.accept(notification))) + .subscribeOn(this.handlerScheduler) + .then()); return this; } /** - * Adds a synchronous custom request handler for a specific method. - * This is the preferred method for sync clients. - * - * @param The response type - * @param method The method name (e.g., "custom/operation") - * @param handler The synchronous handler function for this method - * @return This builder instance for method chaining - * @throws IllegalArgumentException if method or handler is null + * Sets the handler that receives {@code session/update} notifications when no + * {@link #sessionUpdateHandler} is added; the application's own handlers replace it, + * whichever is registered first. Meant for frameworks; see + * {@link AsyncSpec#defaultSessionUpdateHandler(Function)}. It runs on the handler + * executor. + * @param handler the default handler + * @return this builder + * @throws IllegalArgumentException if {@code handler} is null + * @throws IllegalStateException if a default handler is already set + */ + public SyncSpec defaultSessionUpdateHandler(Consumer handler) { + Assert.notNull(handler, "Default session update handler must not be null"); + asyncSpec.defaultSessionUpdateHandler(notification -> Mono + .fromRunnable(HandlerFailures.guard(() -> handler.accept(notification))) + .subscribeOn(this.handlerScheduler) + .then()); + return this; + } + + /** + * Registers a blocking handler for any agent-to-client request method, with the params as + * the transport read them. The method name is not checked: prefer the typed setters for ACP + * methods and {@link #extRequestHandler(String, TypeRef, Function)} for extension methods. + * A method already registered throws {@link IllegalStateException}. + * @param the result type + * @param method the method name + * @param handler the handler, run on the sync builder's handler executor + * @return this builder + * @throws IllegalArgumentException if {@code method} or {@code handler} is null */ public SyncSpec requestHandler(String method, SyncRequestHandler handler) { Assert.notNull(method, "Method must not be null"); Assert.notNull(handler, "Handler must not be null"); - this.requestHandlers.put(method, fromSync(handler)); + asyncSpec.requestHandler(method, fromSync(handler)); return this; } /** - * Adds a custom notification handler for a specific method. This allows handling - * additional agent notifications beyond session updates. - * @param method The method name (e.g., "custom/notification") - * @param handler The handler function for this method - * @return This builder instance for method chaining - * @throws IllegalArgumentException if method or handler is null + * Registers a blocking handler for an agent-to-client notification method the SDK does + * not model, with the params as the transport read them. It runs on the handler executor, + * like the other sync handlers, and is delivered + * in order with the session updates. A method with a typed setter ({@code session/update}, + * {@code elicitation/complete}) is refused; use the setter the exception names. For + * extension methods prefer {@code extNotificationHandler}. + * @param method the method name + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if {@code method} or {@code handler} is null, or the + * method has a typed setter + * @throws IllegalStateException if the method already has a handler */ - public SyncSpec notificationHandler(String method, AcpClientSession.NotificationHandler handler) { - Assert.notNull(method, "Method must not be null"); + public SyncSpec notificationHandler(String method, Consumer handler) { Assert.notNull(handler, "Handler must not be null"); - this.notificationHandlers.put(method, handler); + asyncSpec.notificationHandler(method, params -> Mono + .fromRunnable(HandlerFailures.guard(() -> handler.accept(params))) + .subscribeOn(this.handlerScheduler)); return this; } /** - * Creates an instance of {@link AcpSyncClient} with the provided configurations - * or sensible defaults. - * @return a new instance of {@link AcpSyncClient} + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the agent, its params read as the given type. A request for an + * extension method without a handler is answered with "Method not found" ({@code -32601}). + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as + * @param handler the handler; it returns the result, any value the JSON mapper can write, + * and returning {@code null} answers the request with an internal error ({@code -32603}) + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _}, or the + * type or handler is null + * @see AsyncSpec#extRequestHandler(String, TypeRef, Function) */ - public AcpSyncClient build() { - // Set up session update notification handler - if (!sessionUpdateConsumers.isEmpty()) { - notificationHandlers.put(AcpSchema.METHOD_SESSION_UPDATE, params -> { - AcpSchema.SessionNotification notification = transport.unmarshalFrom(params, - new TypeRef() { - }); - logger.debug("Received session update for session: {}", notification.sessionId()); + public SyncSpec extRequestHandler(String method, TypeRef paramsType, Function handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncSpec.extRequestHandler(method, paramsType, + params -> onSyncHandlerThread(() -> handler.apply(params))); + return this; + } - // Call all registered consumers - return Mono - .when(sessionUpdateConsumers.stream().map(consumer -> consumer.apply(notification)).toList()); - }); - } + /** + * Registers the handler for a custom extension request ({@code _}-prefixed method name, ACP + * v1 Extensibility) from the agent, its params delivered as the raw JSON value (a + * {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}). + * @param method the method name, which must start with {@code _} + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extRequestHandler(String, TypeRef, Function) + */ + public SyncSpec extRequestHandler(String method, Function handler) { + return extRequestHandler(method, AsyncSpec.RAW_PARAMS, handler); + } - // Create session with request and notification handlers - AcpSession session = new AcpClientSession(requestTimeout, transport, requestHandlers, notificationHandlers, - Function.identity()); + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the agent, its params read as the given type. An extension notification + * without a handler is ignored, as the protocol asks. Extension notifications are delivered + * in order with the session updates. + * @param the params type + * @param method the method name, which must start with {@code _} + * @param paramsType the type the params are read as + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _}, or the + * type or handler is null + * @see AsyncSpec#extNotificationHandler(String, TypeRef, Function) + */ + public SyncSpec extNotificationHandler(String method, TypeRef paramsType, Consumer handler) { + Assert.notNull(handler, "Handler must not be null"); + asyncSpec.extNotificationHandler(method, paramsType, params -> Mono + .fromRunnable(HandlerFailures.guard(() -> handler.accept(params))) + .subscribeOn(this.handlerScheduler)); + return this; + } - return new AcpSyncClient(new AcpAsyncClient(session, transport, clientCapabilities)); + /** + * Registers the handler for a custom extension notification ({@code _}-prefixed method + * name) from the agent, its params delivered as the raw JSON value. + * @param method the method name, which must start with {@code _} + * @param handler the handler + * @return this builder + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see #extNotificationHandler(String, TypeRef, Consumer) + */ + public SyncSpec extNotificationHandler(String method, Consumer handler) { + return extNotificationHandler(method, AsyncSpec.RAW_PARAMS, handler); + } + + /** + * Builds the client and connects the transport: for a + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport} that starts + * the agent process. Call {@link AcpSyncClient#initialize()} next. A transport carries one + * client, so build once per transport. + * @return the client + * @throws IllegalStateException if the transport refuses to connect at once, for example + * because it is already connected; a connection that fails later fails the client's + * requests instead + */ + public AcpSyncClient build() { + return new AcpSyncClient(asyncSpec.build()); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpSyncClient.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpSyncClient.java index e533414a..2aee438f 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpSyncClient.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/AcpSyncClient.java @@ -5,67 +5,61 @@ package com.agentclientprotocol.sdk.client; import java.time.Duration; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.AcpSchedulers; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.spec.SyncCalls; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; /** - * A synchronous client implementation for the Agent Client Protocol (ACP) that wraps an - * {@link AcpAsyncClient} to provide blocking operations. + * A connected ACP client with blocking calls: the calls of {@link AcpAsyncClient}, each waiting for + * the agent's answer, while the handlers registered on {@link AcpClient.SyncSpec} answer the + * agent's requests. Get one from {@code AcpClient.sync(transport)...build()}. Use it in plain Java + * code; it is {@link AutoCloseable}, so try-with-resources closes the connection. * - *

- * This client implements the ACP specification by delegating to an asynchronous client - * and blocking on the results. Key features include: - *

    - *
  • Synchronous, blocking API for simpler integration in non-reactive applications
  • - *
  • Initialize handshake and capability negotiation
  • - *
  • Session creation and management
  • - *
  • Prompt submission with streaming updates
  • - *
  • Authentication support for agents requiring it
  • - *
+ *

A client follows ACP's order: {@link #initialize()} first, {@link #authenticate} if the agent + * requires it, then {@link #newSession}, {@link #loadSession} or {@link #resumeSession} for a + * session ID, then {@link #prompt} as often as needed, one turn at a time per session. * - *

- * The client follows the same lifecycle as its async counterpart: - *

    - *
  1. Initialization - Establishes connection and negotiates protocol version
  2. - *
  3. Authentication - Optional authentication step
  4. - *
  5. Session Creation - Creates a new agent session with working directory
  6. - *
  7. Prompt Interaction - Sends prompts and receives responses
  8. - *
  9. Graceful Shutdown - Ensures clean connection termination
  10. - *
- * - *

- * This implementation implements {@link AutoCloseable} for resource cleanup and provides - * both immediate and graceful shutdown options. All operations block until completion or - * timeout, making it suitable for traditional synchronous programming models. - * - *

- * Example usage:

{@code
+ * 
{@code
  * try (AcpSyncClient client = AcpClient.sync(transport).build()) {
- *     // Initialize
- *     AcpSchema.InitializeResponse initResponse = client.initialize(
- *         new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities()));
- *
- *     // Create session
- *     AcpSchema.NewSessionResponse sessionResponse = client.newSession(
+ *     client.initialize();
+ *     AcpSchema.NewSessionResponse session = client.newSession(
  *         new AcpSchema.NewSessionRequest("/workspace", List.of()));
- *
- *     // Send prompt
- *     AcpSchema.PromptResponse response = client.prompt(
- *         new AcpSchema.PromptRequest(sessionResponse.sessionId(),
- *             List.of(new AcpSchema.TextContent("Fix the bug"))));
- *
+ *     AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(
+ *         session.sessionId(), List.of(new AcpSchema.TextContent("Fix the bug"))));
  *     System.out.println("Stop reason: " + response.stopReason());
  * }
  * }
* + *

A call blocks until the answer arrives; it has no time limit of its own beyond the builder's + * request timeout (60 seconds by default), or, for {@link #prompt}, the builder's prompt timeout + * (none by default). Failures are thrown: {@link com.agentclientprotocol.sdk.spec.AcpError} for an + * error answer, whose {@code getCode()} is the JSON-RPC error code; + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}, whose cause is the + * {@link java.util.concurrent.TimeoutException}, when no answer came in time, after the client has + * sent the agent a {@code $/cancel_request}; {@link java.util.concurrent.CancellationException} + * when the waiting thread is interrupted (the request is cancelled and the interrupt flag stays + * set); {@link IllegalStateException} for a call before {@link #initialize()} (extension methods + * excepted); {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} for a call the agent + * did not advertise (see {@link #getAgentCapabilities()}), neither of which is sent; and + * {@link IllegalArgumentException} for a null argument. Methods may be called + * from several threads at once, but not from a thread that must not block, and not from a session + * update handler waiting for a prompt in flight (see {@link #prompt}). + * * @author Mark Pollack * @author Christian Tzolov * @see AcpClient * @see AcpAsyncClient - * @see AcpSchema */ public class AcpSyncClient implements AutoCloseable { @@ -73,19 +67,19 @@ public class AcpSyncClient implements AutoCloseable { private static final long DEFAULT_CLOSE_TIMEOUT_MS = 10_000L; + private static final Duration CLOSE_TIMEOUT = Duration.ofMillis(DEFAULT_CLOSE_TIMEOUT_MS); + private final AcpAsyncClient delegate; /** - * Creates a synchronous facade over an existing asynchronous client. + * Creates a blocking view of an existing asynchronous client. * - *

- * Both clients share the one session and the one transport connection behind - * {@code delegate}: use this when an application needs both APIs, because a transport - * instance carries exactly one session, and building a second client on an - * already-connected transport fails. Closing either client closes the shared session. - *

- * @param delegate the asynchronous client on top of which this synchronous client - * provides a blocking API + *

Both clients share the one session and the one transport connection behind + * {@code delegate}. Use this when an application needs both APIs: a transport carries exactly + * one client, and building a second client on a connected transport fails. Closing either + * client closes the shared session. + * @param delegate the asynchronous client to block on + * @throws IllegalArgumentException if {@code delegate} is null */ public AcpSyncClient(AcpAsyncClient delegate) { Assert.notNull(delegate, "Delegate must not be null"); @@ -97,13 +91,8 @@ public AcpSyncClient(AcpAsyncClient delegate) { // -------------------------- /** - * Closes the client connection and waits for shutdown to complete. - * - *

- * This method blocks until the connection is closed or the timeout is reached. - * For synchronous clients, this ensures resources are fully released when - * try-with-resources completes. - *

+ * Closes the client gracefully, as {@link #closeGracefully()} does, and waits at most 10 + * seconds. If that fails or takes longer, the failure is logged and nothing more is done. */ @Override public void close() { @@ -112,65 +101,92 @@ public void close() { } /** - * Gracefully closes the client connection with a default timeout. - * @return true if the client closed gracefully, false if it timed out + * Closes the client as {@link AcpAsyncClient#closeGracefully()} does, and waits at most 10 + * seconds: requests still waiting for an answer fail at once, session updates already received + * are still handed to the update handlers, and then the transport closes gracefully. + * @return {@code true} if the client closed; {@code false} if closing failed or took longer + * than 10 seconds, which is logged */ public boolean closeGracefully() { try { logger.debug("Gracefully closing ACP sync client"); - this.delegate.closeGracefully().block(Duration.ofMillis(DEFAULT_CLOSE_TIMEOUT_MS)); + this.delegate.closeGracefully() + .timeout(CLOSE_TIMEOUT, AcpSchedulers.timeouts()) + .block(); } catch (RuntimeException e) { - logger.warn("Client didn't close within timeout of {} ms", DEFAULT_CLOSE_TIMEOUT_MS, e); + Throwable cause = Exceptions.unwrap(e); + if (cause instanceof TimeoutException) { + logger.warn("Client didn't close within timeout of {} ms", DEFAULT_CLOSE_TIMEOUT_MS); + } + else { + logger.warn("Client close failed: {}", cause.toString(), cause); + } return false; } return true; } + /** + * Returns the asynchronous client this client blocks on: the same session and transport + * connection, with calls that return {@code Mono}s, for code that needs both APIs, such as + * cancelling one request with {@link com.agentclientprotocol.sdk.spec.RequestCancellation}, or + * closing at once with {@link AcpAsyncClient#close()}. + * @return the asynchronous client + */ + public AcpAsyncClient async() { + return this.delegate; + } + // -------------------------- // Initialization // -------------------------- /** - * Initializes the connection with the agent. This is the first step in the ACP - * lifecycle and negotiates protocol version and capabilities. + * Initializes the connection: the first request of the ACP lifecycle, sent once before any + * other. The client sends protocol version {@value AcpSchema#LATEST_PROTOCOL_VERSION} with the + * capabilities and client info set on the builder + * ({@link AcpClient.SyncSpec#clientCapabilities}, {@link AcpClient.SyncSpec#clientInfo}); the + * agent answers with its protocol version, capabilities and authentication methods, and + * {@link #getAgentCapabilities()} returns those capabilities from then on. The client does not + * check the protocol version the agent answers with. * - *

- * The client sends its protocol version and capabilities, and the agent responds with - * its supported protocol version, authentication methods, and capabilities. - *

- * @param initializeRequest the initialization request containing protocol version and - * client capabilities - * @return the initialization response with agent capabilities + *

The builder is the only place the client's capabilities are set, so what the client + * advertises is also what its handlers honour (an elicitation mode it did not advertise is + * refused). Without {@code clientCapabilities(...)} the client advertises what its handlers + * serve, and {@code new ClientCapabilities()} (no file system, no terminal) when it has none. + * @return the agent's answer * @see AcpSchema#METHOD_INITIALIZE + * @see #initialize(int, Map) */ - public AcpSchema.InitializeResponse initialize(AcpSchema.InitializeRequest initializeRequest) { - return this.delegate.initialize(initializeRequest).block(); + public AcpSchema.InitializeResponse initialize() { + return awaitResponse(this.delegate.initialize()); } /** - * Initializes the ACP client with default settings. - * - *

- * Uses protocol version 1 and default client capabilities. This is a convenience - * method for the common case where no special capabilities need to be advertised. - *

- * @return the initialization response with agent capabilities - * @see #initialize(AcpSchema.InitializeRequest) + * Initializes the connection like {@link #initialize()}, with a chosen protocol version and + * {@code _meta}. The capabilities and client info still come from the builder; this overload + * exists for {@code _meta} and for testing version negotiation, not for advertising + * capabilities. + * @param protocolVersion the protocol version to announce; this SDK speaks + * {@value AcpSchema#LATEST_PROTOCOL_VERSION} + * @param meta the request's {@code _meta}, or {@code null} + * @return the agent's answer + * @see #initialize() */ - public AcpSchema.InitializeResponse initialize() { - return this.delegate.initialize().block(); + public AcpSchema.InitializeResponse initialize(int protocolVersion, @Nullable Map meta) { + return awaitResponse(this.delegate.initialize(protocolVersion, meta)); } /** - * Returns the capabilities negotiated with the agent during initialization. - * - *

- * This method returns null if {@link #initialize} has not been called yet. - *

- * @return the negotiated agent capabilities, or null if not initialized + * Returns the agent's capabilities from its {@code initialize} answer. Check them before calls + * that need them, for example {@code supportsLoadSession()} before {@link #loadSession} or + * {@code supportsLogout()} before {@link #logout}: a call the agent did not advertise + * fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being + * sent. + * @return the agent's capabilities, or {@code null} before an {@code initialize} answer arrived */ - public com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities getAgentCapabilities() { + public com.agentclientprotocol.sdk.capabilities.@Nullable NegotiatedCapabilities getAgentCapabilities() { return this.delegate.getAgentCapabilities(); } @@ -179,29 +195,34 @@ public com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities getAgentC // -------------------------- /** - * Authenticates with the agent using the specified authentication method. + * Logs in with one of the authentication methods the agent listed in its {@code initialize} + * answer ({@code authenticate}). Needed only for an agent that requires it; such an agent + * answers other requests with {@code -32000} (authentication required) until then. * - *

- * Authentication is optional and depends on the agent's configuration. The - * authentication methods available are returned in the initialize response. - *

- * @param authenticateRequest the authentication request specifying the auth method - * and credentials - * @return the authentication response + *

Pass the ID of an {@link AcpSchema.AuthMethodAgent}. Do not pass an + * {@link AcpSchema.AuthMethodTerminal}: for that one the client runs the agent program itself, + * in a terminal, outside this connection. This method does not check the method's type. + * @param request the ID of the chosen authentication method + * @return the agent's answer * @see AcpSchema#METHOD_AUTHENTICATE */ - public AcpSchema.AuthenticateResponse authenticate(AcpSchema.AuthenticateRequest authenticateRequest) { - return this.delegate.authenticate(authenticateRequest).block(); + public AcpSchema.AuthenticateResponse authenticate(AcpSchema.AuthenticateRequest request) { + return awaitResponse(this.delegate.authenticate(request)); } /** - * Logs out of the agent, clearing any stored credentials. - * @param logoutRequest the logout request - * @return the logout response + * Logs out of the agent ({@code logout}), ending the authenticated state; afterwards the client + * must authenticate again where the agent requires it. Only an agent that advertises + * {@code auth.logout} supports it: check + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities#supportsLogout()} + * first: otherwise the call fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the logout request + * @return the agent's answer * @see AcpSchema#METHOD_LOGOUT */ - public AcpSchema.LogoutResponse logout(AcpSchema.LogoutRequest logoutRequest) { - return this.delegate.logout(logoutRequest).block(); + public AcpSchema.LogoutResponse logout(AcpSchema.LogoutRequest request) { + return awaitResponse(this.delegate.logout(request)); } // -------------------------- @@ -209,128 +230,114 @@ public AcpSchema.LogoutResponse logout(AcpSchema.LogoutRequest logoutRequest) { // -------------------------- /** - * Creates a new agent session with the specified working directory. - * - *

- * A session represents a conversation context with the agent. All prompts within a - * session share the same working directory and conversation history. - *

- * @param newSessionRequest the session creation request with working directory and - * initial context - * @return the session response containing the session ID + * Creates an ACP session ({@code session/new}) for a working directory, with the MCP servers + * the agent should connect to. The answer carries the session ID every later call for the + * session uses, and optionally the session's modes and config options. + * @param request the working directory, an absolute path, and the MCP servers + * @return the agent's answer, with the session ID * @see AcpSchema#METHOD_SESSION_NEW */ - public AcpSchema.NewSessionResponse newSession(AcpSchema.NewSessionRequest newSessionRequest) { - return this.delegate.newSession(newSessionRequest).block(); + public AcpSchema.NewSessionResponse newSession(AcpSchema.NewSessionRequest request) { + return awaitResponse(this.delegate.newSession(request)); } /** - * Loads an existing agent session by ID. - * - *

- * This allows resuming a previous conversation with the agent, maintaining the full - * history and context. - *

- * @param loadSessionRequest the session load request with session ID - * @return the load response confirming the session was loaded + * Reopens a session the agent kept ({@code session/load}). The agent replays the conversation + * as session updates, which reach the session update handlers before this call completes, then + * answers. Only an agent that advertises {@code loadSession} supports it; for any other the call + * fails with {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the session ID, the working directory and the MCP servers + * @return the agent's answer, once the history has been replayed * @see AcpSchema#METHOD_SESSION_LOAD */ - public AcpSchema.LoadSessionResponse loadSession(AcpSchema.LoadSessionRequest loadSessionRequest) { - return this.delegate.loadSession(loadSessionRequest).block(); + public AcpSchema.LoadSessionResponse loadSession(AcpSchema.LoadSessionRequest request) { + return awaitResponse(this.delegate.loadSession(request)); } /** - * Sets the operational mode for a session (e.g., "code", "plan", "review"). - * - *

- * Different modes may change how the agent processes prompts and what capabilities it - * exposes. - *

- * @param setModeRequest the set mode request with session ID and desired mode - * @return the response confirming the mode change + * Switches a session to one of the modes the agent offered ({@code session/set_mode}). Modes + * may change how the agent works on prompts and what it asks permission for. + * @param request the session ID and the mode ID + * @return the agent's answer * @see AcpSchema#METHOD_SESSION_SET_MODE */ - public AcpSchema.SetSessionModeResponse setSessionMode(AcpSchema.SetSessionModeRequest setModeRequest) { - return this.delegate.setSessionMode(setModeRequest).block(); - } - - /** - * Sets the AI model for the specified session. - *

- * This allows changing which AI model is used for subsequent prompts in the session. - *

- * @param setModelRequest the set model request with session ID and desired model - * @return the response confirming the model change - * @see AcpSchema#METHOD_SESSION_SET_MODEL - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026). Use {@link #setSessionConfigOption} with a {@code "model"} category config - * option instead. Slated for removal. - */ - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - public AcpSchema.SetSessionModelResponse setSessionModel(AcpSchema.SetSessionModelRequest setModelRequest) { - return this.delegate.setSessionModel(setModelRequest).block(); + public AcpSchema.SetSessionModeResponse setSessionMode(AcpSchema.SetSessionModeRequest request) { + return awaitResponse(this.delegate.setSessionMode(request)); } /** - * Lists sessions known to the agent, optionally filtered by working directory. - * @param listSessionsRequest the list sessions request with optional cwd filter and cursor - * @return the list sessions response + * Lists the sessions the agent knows ({@code session/list}), optionally only those of one + * working directory. The answer may be one page: pass its {@code nextCursor} in the next + * request to get the next page. + * @param request an optional working directory and an optional cursor + * @return a page of sessions * @see AcpSchema#METHOD_SESSION_LIST */ - public AcpSchema.ListSessionsResponse listSessions(AcpSchema.ListSessionsRequest listSessionsRequest) { - return this.delegate.listSessions(listSessionsRequest).block(); + public AcpSchema.ListSessionsResponse listSessions(AcpSchema.ListSessionsRequest request) { + return awaitResponse(this.delegate.listSessions(request)); } /** - * Closes an active session, cancelling any in-flight work. - * @param closeSessionRequest the close session request with session ID - * @return the close session response + * Closes an active session ({@code session/close}): the agent stops its work as for + * {@code session/cancel}, then frees what the session holds. + * @param request the session ID + * @return the agent's answer * @see AcpSchema#METHOD_SESSION_CLOSE */ - public AcpSchema.CloseSessionResponse closeSession(AcpSchema.CloseSessionRequest closeSessionRequest) { - return this.delegate.closeSession(closeSessionRequest).block(); + public AcpSchema.CloseSessionResponse closeSession(AcpSchema.CloseSessionRequest request) { + return awaitResponse(this.delegate.closeSession(request)); } /** - * Permanently deletes a stored session. - * @param deleteSessionRequest the delete session request with session ID - * @return the delete session response + * Deletes a stored session ({@code session/delete}). Unlike {@link #closeSession}, which frees + * an active session, it removes the session from the agent's storage, so that it no longer + * appears in {@code session/list}. Only an agent that advertises + * {@code sessionCapabilities.delete} supports it; for any other the call fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent. + * @param request the session ID + * @return the agent's answer * @see AcpSchema#METHOD_SESSION_DELETE */ - public AcpSchema.DeleteSessionResponse deleteSession(AcpSchema.DeleteSessionRequest deleteSessionRequest) { - return this.delegate.deleteSession(deleteSessionRequest).block(); + public AcpSchema.DeleteSessionResponse deleteSession(AcpSchema.DeleteSessionRequest request) { + return awaitResponse(this.delegate.deleteSession(request)); } /** - * Resumes an existing session without replaying conversation history. - * @param resumeSessionRequest the resume session request with session ID and cwd - * @return the resume session response + * Reopens a session without replaying its history ({@code session/resume}), unlike + * {@link #loadSession}. Use it to reconnect to a session the agent still runs, or when the + * client keeps the history itself. + * @param request the session ID, the working directory and the MCP servers + * @return the agent's answer * @see AcpSchema#METHOD_SESSION_RESUME */ - public AcpSchema.ResumeSessionResponse resumeSession(AcpSchema.ResumeSessionRequest resumeSessionRequest) { - return this.delegate.resumeSession(resumeSessionRequest).block(); + public AcpSchema.ResumeSessionResponse resumeSession(AcpSchema.ResumeSessionRequest request) { + return awaitResponse(this.delegate.resumeSession(request)); } /** - * Forks an existing session, creating a new session branched from it. - * @param forkSessionRequest the fork request with source session ID and cwd - * @return the fork response with the new session ID + * Creates a new session branched from an existing one ({@code session/fork}). + * @param request the ID of the session to branch from and the working directory + * @return the agent's answer, with the new session's ID * @see AcpSchema#METHOD_SESSION_FORK */ - public AcpSchema.ForkSessionResponse forkSession(AcpSchema.ForkSessionRequest forkSessionRequest) { - return this.delegate.forkSession(forkSessionRequest).block(); + @UnstableAcpApi + public AcpSchema.ForkSessionResponse forkSession(AcpSchema.ForkSessionRequest request) { + return awaitResponse(this.delegate.forkSession(request)); } /** - * Sets a configuration option for a session. - * @param request the config option request with session ID, config ID, and value - * @return the response with the full config state + * Changes one of a session's config options ({@code session/set_config_option}); make the + * request with {@link AcpSchema.SetSessionConfigOptionRequest#select} or + * {@link AcpSchema.SetSessionConfigOptionRequest#bool}. The answer carries the full list of the + * session's options, which replaces the client's copy. Send a boolean option only to an agent + * that offered it. + * @param request the session ID, the option ID and the new value + * @return the agent's answer, with all of the session's options * @see AcpSchema#METHOD_SESSION_SET_CONFIG_OPTION */ public AcpSchema.SetSessionConfigOptionResponse setSessionConfigOption( AcpSchema.SetSessionConfigOptionRequest request) { - return this.delegate.setSessionConfigOption(request).block(); + return awaitResponse(this.delegate.setSessionConfigOption(request)); } // -------------------------- @@ -338,33 +345,38 @@ public AcpSchema.SetSessionConfigOptionResponse setSessionConfigOption( // -------------------------- /** - * Lists the providers the agent can route to (UNSTABLE). - * @param request the list providers request - * @return the list of configurable providers + * Lists the providers the agent can route to ({@code providers/list}). Only an agent that + * advertises {@code providers} supports it. + * @param request the list request + * @return the providers * @see AcpSchema#METHOD_PROVIDERS_LIST */ + @UnstableAcpApi public AcpSchema.ListProvidersResponse listProviders(AcpSchema.ListProvidersRequest request) { - return this.delegate.listProviders(request).block(); + return awaitResponse(this.delegate.listProviders(request)); } /** - * Configures a provider's routing (UNSTABLE). - * @param request the set provider request - * @return the response + * Configures how the agent reaches a provider: protocol, base URL and headers + * ({@code providers/set}). + * @param request the provider ID and its routing + * @return the agent's answer * @see AcpSchema#METHOD_PROVIDERS_SET */ + @UnstableAcpApi public AcpSchema.SetProviderResponse setProvider(AcpSchema.SetProviderRequest request) { - return this.delegate.setProvider(request).block(); + return awaitResponse(this.delegate.setProvider(request)); } /** - * Disables a provider by id (UNSTABLE). - * @param request the disable provider request - * @return the response + * Disables a provider by ID ({@code providers/disable}). + * @param request the provider ID + * @return the agent's answer * @see AcpSchema#METHOD_PROVIDERS_DISABLE */ + @UnstableAcpApi public AcpSchema.DisableProviderResponse disableProvider(AcpSchema.DisableProviderRequest request) { - return this.delegate.disableProvider(request).block(); + return awaitResponse(this.delegate.disableProvider(request)); } // -------------------------- @@ -372,35 +384,124 @@ public AcpSchema.DisableProviderResponse disableProvider(AcpSchema.DisableProvid // -------------------------- /** - * Sends a prompt to the agent within a session. + * Sends a prompt to a session ({@code session/prompt}) and returns the agent's answer at the + * end of the turn, with the stop reason. Meanwhile the agent streams the turn as + * {@code session/update} notifications to the session update handlers. A session takes one + * prompt at a time: a Java agent answers a second prompt sent before the first is answered with + * {@code -32600}. + * + *

The answer is delivered only once the session update handlers have finished with every + * notification the agent sent before it, so what they collected for the turn is complete when + * the stop reason arrives. A slow update handler delays the answer, and the wait counts against the + * prompt timeout, if one is set. The one exception: an update handler that was already running when the prompt was + * sent, and is still running when its answer arrives, is not waited for, since it may be the + * one waiting for the prompt. An update handler must therefore not wait for this prompt to complete. * - *

- * The prompt can contain text, images, or other content types. The agent processes - * the prompt and may send streaming updates via session/update notifications before - * returning the final response. - *

- * @param promptRequest the prompt request with session ID and content - * @return the prompt response with stop reason + *

A prompt is not bound by the builder's {@code requestTimeout}: by default it blocks until + * the end of the turn however long that takes. Set {@link AcpClient.SyncSpec#promptTimeout} to + * bound it; when that passes, the call fails as described above for a timeout and the client + * sends {@code $/cancel_request}, which makes a Java agent cancel the turn. To stop a turn from + * another thread and still receive its answer, call {@link #cancel}. + * @param request the session ID and the prompt's content blocks + * @return the agent's answer, with the stop reason * @see AcpSchema#METHOD_SESSION_PROMPT */ - public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest promptRequest) { - return this.delegate.prompt(promptRequest).block(); + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request) { + return awaitResponse(this.delegate.prompt(request)); } /** - * Cancels ongoing operations for a session. + * Sends a prompt as {@link #prompt(AcpSchema.PromptRequest)} does, and stops its turn when + * another thread cancels {@code stop}: the client then sends {@code session/cancel} for the + * prompt's session, once, and this call keeps blocking until the agent's answer arrives, + * which ACP requires to be stop reason {@code cancelled}. Cancelling {@code stop} after the + * answer has arrived sends nothing. * - *

- * This sends a notification to the agent to stop any in-progress work for the - * specified session. Note that this is a notification (fire-and-forget), not a - * request. - *

- * @param cancelNotification the cancel notification with session ID and optional - * reason + *
{@code
+	 * CancellationSignal stop = new CancellationSignal();
+	 * stopButton.onClick(stop::cancel);
+	 * AcpSchema.PromptResponse response = client.prompt(request, stop);
+	 * }
+ * @param request the session ID and the prompt's content blocks + * @param stop the signal that stops the turn + * @return the agent's answer, with the stop reason + * @see CancellationSignal + */ + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, CancellationSignal stop) { + return awaitResponse(this.delegate.prompt(request, stop)); + } + + /** + * Asks the agent to stop a session's prompt turn ({@code session/cancel}). It is a + * notification: the agent does not answer it. + * + *

The cancel does not end the turn. The agent may still send {@code session/update}s, which + * reach the session update handlers as usual, and then answers the pending prompt with stop + * reason {@code cancelled}. Send the next prompt on the session once that answer has arrived: + * until then the agent refuses it (ACP v1, prompt turn, Cancellation). + * + *

Returns once the notification has been handed to the transport, so it can be called from + * another thread while {@link #prompt} blocks. + * @param notification the session ID * @see AcpSchema#METHOD_SESSION_CANCEL */ - public void cancel(AcpSchema.CancelNotification cancelNotification) { - this.delegate.cancel(cancelNotification).block(); + public void cancel(AcpSchema.CancelNotification notification) { + SyncCalls.block(this.delegate.cancel(notification)); + } + + /** + * Sends a custom extension request ({@code _}-prefixed method name) to the agent and blocks for + * its result, read as the given type. An agent that does not handle the method answers "Method + * not found" ({@code -32601}), thrown as {@link com.agentclientprotocol.sdk.spec.AcpError}. + * @param the result type + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @param resultType the type the result is read as + * @return the result, or {@code null} when the agent answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AcpAsyncClient#sendExtRequest(String, Object, TypeRef) + */ + public @Nullable T sendExtRequest(String method, Object params, TypeRef resultType) { + return SyncCalls.block(this.delegate.sendExtRequest(method, params, resultType)); + } + + /** + * Sends a custom extension request to the agent and blocks for its result, as the raw JSON + * value: a {@code Map}, {@code List}, {@code String}, {@code Number} or {@code Boolean}. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @return the result, or {@code null} when the agent answers {@code "result": null} + * @throws IllegalArgumentException if the method name does not start with {@code _} + * @see AcpAsyncClient#sendExtRequest(String, Object) + */ + public @Nullable Object sendExtRequest(String method, Object params) { + return SyncCalls.block(this.delegate.sendExtRequest(method, params)); + } + + /** + * Sends a custom extension notification ({@code _}-prefixed method name) to the agent and + * returns once it has been handed to the transport. An agent without a handler for it ignores + * it. + * @param method the method name, which must start with {@code _} + * @param params the params, any value the JSON mapper can write + * @throws IllegalArgumentException if the method name does not start with {@code _} + */ + public void sendExtNotification(String method, Object params) { + SyncCalls.block(this.delegate.sendExtNotification(method, params)); + } + + /** + * Blocks for the response to a request. A request's Mono emits the response or fails: + * the session delivers every result through a Reactor sink, which cannot carry null, so + * it never completes empty. An empty completion would be a broken invariant, reported + * as such rather than returned as a null response. + */ + private static T awaitResponse(Mono response) { + T value = SyncCalls.block(response); + if (value == null) { + throw new IllegalStateException("ACP request completed without a response"); + } + return value; } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/CancellationSignal.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/CancellationSignal.java new file mode 100644 index 00000000..6ca83d02 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/CancellationSignal.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * A stop button for one prompt turn: pass it to {@link AcpAsyncClient#prompt( + * com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest, CancellationSignal)} or + * {@link AcpSyncClient#prompt(com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest, + * CancellationSignal)}, and call {@link #cancel()} from any thread to stop the turn. The client + * then sends {@code session/cancel} for the prompt's session, as ACP intends, and the prompt still + * returns the agent's answer, normally stop reason {@code cancelled}, together with the session + * updates the agent sent before it. + * + *

{@code
+ * CancellationSignal stop = new CancellationSignal();
+ * stopButton.onClick(stop::cancel);                      // from the UI thread
+ * AcpSchema.PromptResponse response = client.prompt(request, stop);   // blocks until the answer
+ * }
+ * + *

A signal is cancelled once and stays cancelled; use a new one for each prompt. Cancelling it + * before the prompt is sent sends the prompt and then {@code session/cancel} at once; cancelling + * it after the answer has arrived sends nothing. It is safe to use from several threads. + * + * @author Mark Pollack + * @see AcpAsyncClient#cancel(com.agentclientprotocol.sdk.spec.AcpSchema.CancelNotification) + */ +public final class CancellationSignal { + + private final Sinks.Empty cancelled = Sinks.empty(); + + private volatile boolean isCancelled; + + /** Creates a signal that is not cancelled. */ + public CancellationSignal() { + } + + /** + * Cancels the prompt this signal was passed to: the client sends {@code session/cancel} for + * its session, unless the prompt has already been answered. Calling it again does nothing. + */ + public void cancel() { + this.isCancelled = true; + this.cancelled.tryEmitEmpty(); + } + + /** + * Whether {@link #cancel()} has been called. + * @return whether the signal is cancelled + */ + public boolean isCancelled() { + return this.isCancelled; + } + + /** Completes when the signal is cancelled, at once if it already is. */ + Mono whenCancelled() { + return this.cancelled.asMono(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/SyncHandlerScheduler.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/SyncHandlerScheduler.java new file mode 100644 index 00000000..a67688d5 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/SyncHandlerScheduler.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.jspecify.annotations.Nullable; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +/** + * The default executor of synchronous clients' handlers and session update handlers, shared by + * every synchronous client in the JVM that was not given a {@code handlerExecutor}, and created on + * first use: on JDK 21 and later a virtual thread per handler call, named + * {@code acp-sync-handler}; before, a cached pool of daemon threads of that name. Its threads may + * block, and neither kind keeps the JVM alive. On JDK 21 to 23 a handler that blocks inside a + * {@code synchronized} block pins its carrier thread (JEP 491 removed that in JDK 24). + */ +final class SyncHandlerScheduler { + + static final Scheduler DEFAULT = Schedulers.fromExecutorService(executor(), "acp-sync-handler"); + + private static ExecutorService executor() { + return executor(VirtualThreads.newPerTaskExecutor("acp-sync-handler")); + } + + /** + * The virtual-thread executor when there is one, else the cached pool; tests give either on + * any JDK. + */ + static ExecutorService executor(@Nullable ExecutorService virtual) { + if (virtual != null) { + return virtual; + } + return Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-sync-handler"); + t.setDaemon(true); + return t; + }); + } + + private SyncHandlerScheduler() { + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/package-info.java new file mode 100644 index 00000000..adbaeda4 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The ACP client: the async and sync clients and the builder that creates them. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.client; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/AgentParameters.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/AgentParameters.java index b5ebd859..8ffa977d 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/AgentParameters.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/AgentParameters.java @@ -8,6 +8,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -16,7 +17,29 @@ import com.agentclientprotocol.sdk.util.Assert; /** - * Agent parameters for stdio client - configuration for launching an ACP agent process. + * The command, arguments and environment variables that {@link StdioAcpClientTransport} starts + * an agent process with. Build one with {@link #builder(String)} and pass it to the + * transport's constructor: + * + *
{@code
+ * AgentParameters params = AgentParameters.builder("gemini")
+ *     .arg("--experimental-acp")
+ *     .addEnvVar("GEMINI_API_KEY", System.getenv("GEMINI_API_KEY"))
+ *     .build();
+ * }
+ * + *

By default the agent process inherits the client's whole environment, with {@link #getEnv()} + * added, so the agent can read every secret in it. {@link #getEnv()} starts with a few variables + * copied from the client's environment when the parameters are built (HOME, LOGNAME, PATH, SHELL, + * TERM and USER; on Windows PATH, SYSTEMROOT, TEMP, USERPROFILE and a few more), and the + * variables added on the builder replace them. To give the agent only those, call + * {@link Builder#inheritEnvironment(boolean) inheritEnvironment(false)}: the process then starts + * from an empty environment, and an agent that needs a key, for example an API key, must be given + * it with {@link Builder#addEnvVar(String, String)}. + * + *

The parameters are not a snapshot: {@link #getArgs()} and {@link #getEnv()} return the + * collections themselves, and an argument added with {@link Builder#arg(String)} after + * {@link Builder#build()} also appears in parameters already built from that builder. * * @author Mark Pollack * @author Christian Tzolov (MCP Java SDK) @@ -26,80 +49,150 @@ public class AgentParameters { // Environment variables to inherit by default private static final List DEFAULT_INHERITED_ENV_VARS = System.getProperty("os.name") - .toLowerCase() + .toLowerCase(Locale.ROOT) .contains("win") ? Arrays.asList("APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE") : Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"); @JsonProperty("command") - private String command; + private final String command; @JsonProperty("args") - private List args = new ArrayList<>(); + private final List args; @JsonProperty("env") - private Map env; + private final Map env; - private AgentParameters(String command, List args, Map env) { + @JsonProperty("inheritEnvironment") + private final boolean inheritEnvironment; + + private AgentParameters(String command, List args, Map env, boolean inheritEnvironment) { Assert.notNull(command, "The command can not be null"); Assert.notNull(args, "The args can not be null"); this.command = command; this.args = args; this.env = new HashMap<>(getDefaultEnvironment()); - if (env != null && !env.isEmpty()) { - this.env.putAll(env); - } + this.env.putAll(env); + this.inheritEnvironment = inheritEnvironment; } + /** + * Returns the program to start: a path, or a name the operating system looks up on the + * PATH. + * @return the command + */ public String getCommand() { return this.command; } + /** + * Returns the arguments passed to the command, in order. + * @return the arguments; the list itself, not a copy + */ public List getArgs() { return this.args; } + /** + * Returns the environment variables set for the agent process: the defaults copied from + * the client's environment, replaced where the builder added a variable of the same name. + * The transport adds them to the environment the process inherits, or, without inheriting + * ({@link #isInheritEnvironment()}), gives the process only these. + * @return the variables, by name; the map itself, not a copy + */ public Map getEnv() { return this.env; } + /** + * Returns whether the agent process inherits the client's whole environment, with + * {@link #getEnv()} added (the default), or gets only {@link #getEnv()}. + * @return {@code true} if the process inherits the client's environment + */ + public boolean isInheritEnvironment() { + return this.inheritEnvironment; + } + + /** + * Returns a builder for parameters that start {@code command}. + * @param command the program to start: a path, or a name looked up on the PATH + * @return a new builder + * @throws IllegalArgumentException if {@code command} is null + */ public static Builder builder(String command) { return new Builder(command); } + /** + * Builds {@link AgentParameters}. Get one from {@link AgentParameters#builder(String)}. + * Arguments keep the order they are added in; an environment variable added again + * replaces the earlier value. + */ public static class Builder { - private String command; + private final String command; private List args = new ArrayList<>(); - private Map env = new HashMap<>(); + private final Map env = new HashMap<>(); + + private boolean inheritEnvironment = true; + /** + * Creates a builder for parameters that start {@code command}; the same as + * {@link AgentParameters#builder(String)}. + * @param command the program to start + * @throws IllegalArgumentException if {@code command} is null + */ public Builder(String command) { Assert.notNull(command, "The command can not be null"); this.command = command; } + /** + * Replaces the arguments with these. + * @param args the arguments, in order + * @return this builder + * @throws IllegalArgumentException if {@code args} is null + */ public Builder args(String... args) { Assert.notNull(args, "The args can not be null"); - this.args = Arrays.asList(args); + this.args = new ArrayList<>(Arrays.asList(args)); return this; } + /** + * Replaces the arguments with a copy of these. + * @param args the arguments, in order + * @return this builder + * @throws IllegalArgumentException if {@code args} is null + */ public Builder args(List args) { Assert.notNull(args, "The args can not be null"); this.args = new ArrayList<>(args); return this; } + /** + * Adds one argument after those already set. + * @param arg the argument + * @return this builder + * @throws IllegalArgumentException if {@code arg} is null + */ public Builder arg(String arg) { Assert.notNull(arg, "The arg can not be null"); this.args.add(arg); return this; } + /** + * Adds these environment variables, replacing any of the same name added before. An + * empty map adds nothing. + * @param env the variables, by name + * @return this builder + */ public Builder env(Map env) { if (env != null && !env.isEmpty()) { this.env.putAll(env); @@ -107,6 +200,13 @@ public Builder env(Map env) { return this; } + /** + * Adds one environment variable, replacing one of the same name added before. + * @param key the variable's name + * @param value the variable's value + * @return this builder + * @throws IllegalArgumentException if {@code key} or {@code value} is null + */ public Builder addEnvVar(String key, String value) { Assert.notNull(key, "The key can not be null"); Assert.notNull(value, "The value can not be null"); @@ -114,8 +214,27 @@ public Builder addEnvVar(String key, String value) { return this; } + /** + * Sets whether the agent process inherits the client's whole environment. Default: + * {@code true}, the process gets the client's environment with the variables of + * {@link AgentParameters#getEnv()} added, every secret in it included. With + * {@code false} the process gets only {@link AgentParameters#getEnv()}: the safe defaults + * (HOME, PATH, USER and the like) and the variables added on this builder. + * @param inheritEnvironment {@code false} to start the agent from the safe defaults only + * @return this builder + */ + public Builder inheritEnvironment(boolean inheritEnvironment) { + this.inheritEnvironment = inheritEnvironment; + return this; + } + + /** + * Returns the parameters, with the default environment variables read from the + * client's environment now. + * @return the parameters + */ public AgentParameters build() { - return new AgentParameters(command, args, env); + return new AgentParameters(command, args, env, inheritEnvironment); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/RouteScope.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/RouteScope.java new file mode 100644 index 00000000..557099de --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/RouteScope.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import org.jspecify.annotations.Nullable; + +/** + * The HTTP scope a Streamable HTTP message travels in: before a connection exists + * (bootstrap), on the connection, or on one of its sessions. The scope decides the + * {@code Acp-Connection-Id} and {@code Acp-Session-Id} headers of a POST and which SSE + * stream carries a reply. + */ +record RouteScope(Kind kind, @Nullable String sessionId) { + + enum Kind { + + BOOTSTRAP, + + CONNECTION, + + SESSION + + } + + static RouteScope bootstrap() { + return new RouteScope(Kind.BOOTSTRAP, null); + } + + static RouteScope connection() { + return new RouteScope(Kind.CONNECTION, null); + } + + static RouteScope session(String sessionId) { + return new RouteScope(Kind.SESSION, sessionId); + } + + boolean isBootstrap() { + return kind == Kind.BOOTSTRAP; + } + + boolean isSession() { + return kind == Kind.SESSION; + } + + /** + * The session id this scope routes to. Only session scopes carry one, and every caller + * has already established that the scope is one. + */ + String boundSessionId() { + if (sessionId == null) { + throw new IllegalStateException("A " + kind + " route scope has no session id"); + } + return sessionId; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/SseStream.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/SseStream.java new file mode 100644 index 00000000..f7cf264f --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/SseStream.java @@ -0,0 +1,215 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * One open Server-Sent Events stream of a Streamable HTTP client: reads its events on a + * thread of the transport's executor, held while the stream is open, turns each into a JSON-RPC message, and hands it to the + * {@link Listener}. Reconnecting is the listener's business; the stream only reports that + * it ended without being closed. + */ +final class SseStream { + + private static final Logger logger = LoggerFactory.getLogger(SseStream.class); + + /** Receives what a stream reads, and its unexpected end. */ + interface Listener { + + /** Delivers a message read from the stream of {@code scope}. */ + Mono onMessage(RouteScope scope, JSONRPCMessage message); + + /** + * An event that is not a JSON-RPC message was skipped. + * @param scope the stream it came on + * @param error why it could not be read + * @param data the event's data + */ + void onUnreadable(RouteScope scope, Throwable error, String data); + + /** The stream ended, or failed, without having been closed. */ + void onUnexpectedClose(SseStream stream, Throwable error); + + /** Whether the transport is closing, which makes the end of a stream expected. */ + boolean transportClosing(); + + } + + private final RouteScope scope; + + private final InputStream body; + + private final AcpJsonMapper jsonMapper; + + private final Listener listener; + + private final AtomicBoolean closed = new AtomicBoolean(false); + + private volatile @Nullable Future readerTask; + + /** Whether this stream carried at least one event; resets the reconnect budget. */ + private volatile boolean delivered; + + /** Reconnects in a row, ending with this stream, that delivered nothing. */ + private volatile int barrenReconnects; + + SseStream(RouteScope scope, InputStream body, AcpJsonMapper jsonMapper, Listener listener) { + this.scope = scope; + this.body = body; + this.jsonMapper = jsonMapper; + this.listener = listener; + } + + RouteScope scope() { + return scope; + } + + boolean isClosed() { + return closed.get(); + } + + boolean delivered() { + return delivered; + } + + int barrenReconnects() { + return barrenReconnects; + } + + void barrenReconnects(int barrenReconnects) { + this.barrenReconnects = barrenReconnects; + } + + /** + * Starts reading on a thread of {@code executor}, holding one of {@code permits} until the + * read ends: the permits bound the number of open streams. + * @throws AcpConnectionException when no permit is left, or the executor refuses the + * reader; the stream is closed + */ + void start(Executor executor, Semaphore permits, int maxStreams) { + if (!permits.tryAcquire()) { + close(); + throw new AcpConnectionException("Maximum active SSE streams exceeded: " + maxStreams); + } + FutureTask task = new FutureTask<>(this::readLoop, null); + this.readerTask = task; + try { + // The permit is released outside the FutureTask: a reader cancelled before it + // runs never enters readLoop, but still returns its permit. + executor.execute(() -> { + try { + task.run(); + } + finally { + permits.release(); + } + }); + } + catch (RejectedExecutionException e) { + permits.release(); + close(); + throw new AcpConnectionException("Maximum active SSE streams exceeded: " + maxStreams, e); + } + } + + void close() { + if (closed.compareAndSet(false, true)) { + try { + body.close(); + } + catch (IOException ignored) { + // Best effort: the stream is abandoned either way, and cancelling the + // reader below stops the read loop. + } + Future task = this.readerTask; + if (task != null) { + task.cancel(true); + } + } + } + + private void readLoop() { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + readEvents(reader); + if (!closed.get() && !listener.transportClosing()) { + throw new AcpConnectionException("SSE stream closed unexpectedly: " + scope); + } + } + catch (Exception e) { + if (!closed.get()) { + listener.onUnexpectedClose(this, e); + } + } + } + + /** + * Reads events until the stream ends or is closed. An event is the {@code data:} lines + * up to a blank line; comment lines ({@code :}) and other fields are ignored. + */ + private void readEvents(BufferedReader reader) throws IOException { + StringBuilder dataBuffer = new StringBuilder(); + String line; + while (!closed.get() && (line = reader.readLine()) != null) { + if (line.isEmpty()) { + dispatchEvent(dataBuffer); + dataBuffer.setLength(0); + } + else if (line.startsWith("data:")) { + if (!dataBuffer.isEmpty()) { + dataBuffer.append('\n'); + } + dataBuffer.append(line.substring(5).stripLeading()); + } + } + dispatchEvent(dataBuffer); + } + + private void dispatchEvent(StringBuilder dataBuffer) { + if (dataBuffer.isEmpty()) { + return; + } + try { + JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, dataBuffer.toString()); + delivered = true; + listener.onMessage(scope, message).subscribe(v -> { + }, error -> { + if (isLive()) { + logger.warn("Failed to process SSE event from {}", scope, error); + } + }); + } + catch (Exception e) { + if (isLive()) { + logger.warn("Skipped an SSE event from {} that is not a JSON-RPC message", scope, e); + listener.onUnreadable(scope, e, dataBuffer.toString()); + } + } + } + + private boolean isLive() { + return !closed.get() && !listener.transportClosing(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransport.java index 6862ae50..cd81230c 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransport.java @@ -6,43 +6,82 @@ import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CancellationException; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Function; +import java.util.function.Predicate; +import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpClientTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.OutboundSinks; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import reactor.core.publisher.SynchronousSink; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; /** - * Implementation of the ACP Stdio transport that communicates with an agent process using - * standard input/output streams. Messages are exchanged as newline-delimited JSON-RPC - * messages over stdin/stdout, with errors and debug information sent to stderr. + * The client side of the stdio transport: starts the agent as a child process and exchanges + * ACP messages with it as lines of JSON on the process's standard input and output. Use it + * when the client launches the agent itself, as editors and command-line tools do; to reach an + * agent that is already running, use {@link StreamableHttpAcpClientTransport} or + * {@link WebSocketAcpClientTransport}. Describe the process with {@link AgentParameters} and + * pass the transport to {@code AcpClient.sync(transport)} or {@code AcpClient.async(transport)}: * - *

- * This is a full-featured transport with: - *

    - *
  • Thread-safe message processing with dedicated schedulers
  • - *
  • Proper resource management and graceful shutdown
  • - *
  • Error stream handling
  • - *
  • Backpressure support via Reactor Sinks
  • - *
+ *
{@code
+ * AgentParameters params = AgentParameters.builder("my-agent").arg("--acp").build();
+ * try (AcpSyncClient client = AcpClient.sync(new StdioAcpClientTransport(params)).build()) {
+ *     client.initialize();
+ * }
+ * }
+ * + *

The process starts when {@link #connect} runs, which building the client does, so a + * command that cannot be started fails the build. One transport starts one process for one + * client: a second {@code connect} is refused. The process runs in the client's working + * directory and, by default, inherits the client's whole environment, with the variables of + * {@link AgentParameters#getEnv()} added, so the agent can read every secret in that + * environment; parameters built with {@code inheritEnvironment(false)} give it only + * {@link AgentParameters#getEnv()}. A line on the agent's standard output that is not a JSON-RPC + * message is reported to the exception handler, answered with a JSON-RPC error and skipped. + * + *

Each line the agent writes to standard error is logged at INFO as {@code agent: } on + * the logger {@value #AGENT_STDERR_LOGGER}, so it stays visible by default. Set that logger's + * level to WARN to hide it, or pass a handler of your own to {@link #setStdErrorHandler}. + * + *

{@link #closeGracefully()} lets the agent exit by itself. It closes the agent's standard + * input and waits up to {@value #END_OF_INPUT_WAIT_MILLIS} ms for the process to exit, as an + * ACP stdio agent does when its input ends; an agent built with this SDK answers what it + * received, flushes and exits 0. A process still running then is sent SIGTERM + * ({@link Process#destroy()}), and killed ({@link Process#destroyForcibly()}) if it has not + * exited five seconds later. On POSIX systems, a logged exit code of 143 therefore means that + * the agent ignored the end of its input. The process is stopped, and its end logged, once + * however often the transport is closed: {@code closeGracefully()} followed by + * {@code close()}, as try-with-resources does, is safe. The steps run on the thread that + * subscribes to {@code closeGracefully()}, or that calls {@code close()}, and can hold it for + * about seven seconds. + * + *

The transport is thread-safe: messages may be sent from any thread, and one writer thread + * writes them one at a time, in the order they were queued. It uses three daemon threads of + * its own ({@code acp-client-inbound}, {@code acp-client-outbound} and + * {@code acp-client-error}), so it does not keep the JVM alive, but the agent process runs + * until the transport is closed or the process ends by itself. * * @author Mark Pollack * @author Christian Tzolov (MCP Java SDK) @@ -52,23 +91,31 @@ public class StdioAcpClientTransport implements AcpClientTransport { private static final Logger logger = LoggerFactory.getLogger(StdioAcpClientTransport.class); + /** + * The name of the logger that receives the agent's standard error, one INFO event per + * line: {@value}. Set its level to WARN or OFF to hide the agent's output. + */ + public static final String AGENT_STDERR_LOGGER = "com.agentclientprotocol.sdk.client.transport.agent-stderr"; + + private static final Logger agentStderr = LoggerFactory.getLogger(AGENT_STDERR_LOGGER); + private final Sinks.Many inboundSink; private final Sinks.Many outboundSink; - /** The agent process being communicated with */ - private Process process; + /** The agent process being communicated with; null until {@link #connect} starts it */ + private volatile @Nullable Process process; - private AcpJsonMapper jsonMapper; + private final AcpJsonMapper jsonMapper; /** Scheduler for handling inbound messages from the agent process */ - private Scheduler inboundScheduler; + private final Scheduler inboundScheduler; /** Scheduler for handling outbound messages to the agent process */ - private Scheduler outboundScheduler; + private final Scheduler outboundScheduler; /** Scheduler for handling error messages from the agent process */ - private Scheduler errorScheduler; + private final Scheduler errorScheduler; /** Parameters for configuring and starting the agent process */ private final AgentParameters params; @@ -77,6 +124,15 @@ public class StdioAcpClientTransport implements AcpClientTransport { private volatile boolean isClosing = false; + /** Set by {@link #closeGracefully}: the end of the agent process is then expected. */ + private volatile boolean closedLocally = false; + + /** + * Terminates when the transport can no longer deliver: empty once closed locally, with + * an error naming the exit when the agent process ended on its own. + */ + private final Sinks.One terminationSink = Sinks.one(); + /** * A transport instance carries exactly one session. {@link #connect} subscribes the * unicast inbound and outbound sinks and starts the agent process; a second call would @@ -84,21 +140,44 @@ public class StdioAcpClientTransport implements AcpClientTransport { */ private final AtomicBoolean isConnected = new AtomicBoolean(false); + /** + * How long {@link #closeGracefully()} waits, after closing the agent's standard input, for + * the agent process to exit by itself before it sends SIGTERM: {@value} ms. An agent that + * needs longer to answer what it received is ended by SIGTERM before it is done. + */ + public static final long END_OF_INPUT_WAIT_MILLIS = 2_000; + + /** How long to wait for the agent process to exit after SIGTERM before killing it. */ + private static final Duration TERM_WAIT = Duration.ofSeconds(5); + + /** Set by the first {@link #closeGracefully}: the agent process is stopped once. */ + private final AtomicBoolean stopping = new AtomicBoolean(false); + + /** How long to wait for the agent process to exit after its standard output ends. */ + private static final Duration EXIT_WAIT = Duration.ofSeconds(5); + + private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t); + // visible for tests - private Consumer stdErrorHandler = error -> logger.info("STDERR Message received: {}", error); + private Consumer stdErrorHandler = line -> agentStderr.info("agent: {}", line); /** - * Creates a new StdioAcpClientTransport with the specified parameters using the default JsonMapper. - * @param params The parameters for configuring the agent process + * Creates a transport that will start the agent process described by {@code params}, with + * the JSON mapper found on the classpath ({@link AcpJsonMapper#createDefault()}). Nothing + * starts until {@link #connect}. + * @param params the command, arguments and environment of the agent process + * @throws IllegalArgumentException if {@code params} is null */ public StdioAcpClientTransport(AgentParameters params) { this(params, AcpJsonMapper.createDefault()); } /** - * Creates a new StdioAcpClientTransport with the specified parameters and JsonMapper. - * @param params The parameters for configuring the agent process - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization + * Creates a transport that will start the agent process described by {@code params}, with + * the given JSON mapper. Nothing starts until {@link #connect}. + * @param params the command, arguments and environment of the agent process + * @param jsonMapper the mapper that reads and writes the messages + * @throws IllegalArgumentException if an argument is null */ public StdioAcpClientTransport(AgentParameters params, AcpJsonMapper jsonMapper) { Assert.notNull(params, "The params can not be null"); @@ -135,11 +214,17 @@ public StdioAcpClientTransport(AgentParameters params, AcpJsonMapper jsonMapper) } /** - * Starts the agent process and initializes the message processing streams. This - * method sets up the process with the configured command, arguments, and environment, - * then starts the inbound, outbound, and error processing threads. - * @throws RuntimeException if the process fails to start or if the process streams - * are null + * {@inheritDoc} + *

Starts the agent process with the command, arguments and environment of the + * {@link AgentParameters}, and the threads that read its standard output and standard error + * and write its standard input. The work runs on the thread that subscribes to the returned + * Mono, when it subscribes. Messages sent before are kept and written once the process + * runs. What the handler returns is not written: the client session sends its answers + * itself. + * @param handler receives each message the agent writes + * @return a Mono that completes once the process has started; it errors with an + * {@link IllegalStateException} if this transport was connected before, and with a + * {@link RuntimeException} if the process cannot be started */ @Override public Mono connect(Function, Mono> handler) { @@ -152,7 +237,7 @@ public Mono connect(Function, Mono> h + "A transport instance carries exactly one session and cannot be reused: build one client per " + "transport, or share one AcpAsyncClient by wrapping it with new AcpSyncClient(asyncClient).")); } - return Mono.fromRunnable(() -> { + return Mono.fromRunnable(() -> { logger.info("ACP agent starting."); handleIncomingMessages(handler); handleIncomingErrors(); @@ -164,63 +249,89 @@ public Mono connect(Function, Mono> h ProcessBuilder processBuilder = this.getProcessBuilder(); processBuilder.command(fullCommand); + if (!params.isInheritEnvironment()) { + processBuilder.environment().clear(); + } processBuilder.environment().putAll(params.getEnv()); // Start the process + Process started; try { - this.process = processBuilder.start(); + started = processBuilder.start(); } catch (IOException e) { throw new RuntimeException("Failed to start process with command: " + fullCommand, e); } - - // Validate process streams - if (this.process.getInputStream() == null || process.getOutputStream() == null) { - this.process.destroy(); - throw new RuntimeException("Process input or output stream is null"); - } + this.process = started; // Start threads - startInboundProcessing(); - startOutboundProcessing(); - startErrorProcessing(); + startInboundProcessing(started); + startOutboundProcessing(started); + startErrorProcessing(started); logger.info("ACP agent started"); }); }); } /** - * Creates and returns a new ProcessBuilder instance. Protected to allow overriding in - * tests. - * @return A new ProcessBuilder instance + * Returns the {@link ProcessBuilder} that {@link #connect} starts the agent process with, + * after setting its command and adding {@link AgentParameters#getEnv()} to its environment + * (which it first clears when the parameters do not inherit the client's environment). + * A subclass may override it to configure what {@code AgentParameters} cannot, such as the + * working directory. It must leave standard input, output and error as pipes, which the + * transport reads and writes. + * @return a new process builder */ protected ProcessBuilder getProcessBuilder() { return new ProcessBuilder(); } /** - * Sets the handler for processing transport-level errors. - * - *

- * The provided handler will be called when errors occur during transport operations, - * such as connection failures or protocol violations. - *

- * @param errorHandler a consumer that processes error messages + * Replaces the handler for the lines the agent process writes to its standard error. The + * default logs each line at INFO on {@value #AGENT_STDERR_LOGGER}. Call it before + * {@link #connect}. The handler runs on the transport's {@code acp-client-error} thread, one + * line at a time; standard error is not read while it runs, so a handler that blocks can + * stall an agent that writes a lot to standard error. + * @param errorHandler receives each line, without its line terminator; not null */ public void setStdErrorHandler(Consumer errorHandler) { this.stdErrorHandler = errorHandler; } /** - * Waits for the agent process to exit. - * @throws RuntimeException if the process is interrupted while waiting + * {@inheritDoc} + *

The default logs each error at ERROR. The end of the agent process is not reported + * here but by {@link #awaitTermination()}. + * @throws IllegalArgumentException if {@code handler} is null + */ + @Override + public void setExceptionHandler(Consumer handler) { + Assert.notNull(handler, "The handler can not be null"); + this.exceptionHandler = handler; + } + + /** + * Blocks until the agent process exits. It does not stop the process; close the transport + * for that. Unlike {@link #awaitTermination()}, which completes when the transport ends, it + * waits for the operating system process itself. + * @throws IllegalStateException if {@link #connect} has not started the process + * @throws CancellationException if the calling thread is interrupted + * while it waits; the thread's interrupt flag stays set */ - public void awaitForExit() { + public void awaitProcessExit() { + Process process = this.process; + if (process == null) { + throw new IllegalStateException("The agent process has not been started: connect first"); + } try { - this.process.waitFor(); + process.waitFor(); } catch (InterruptedException e) { - throw new RuntimeException("Process interrupted", e); + Thread.currentThread().interrupt(); + CancellationException cancelled = new CancellationException( + "Interrupted while waiting for the agent process to exit"); + cancelled.initCause(e); + throw cancelled; } } @@ -228,36 +339,64 @@ public void awaitForExit() { * Starts the error processing thread that reads from the process's error stream. * Error messages are logged and emitted to the error sink. */ - private void startErrorProcessing() { - this.errorScheduler.schedule(() -> { - try (BufferedReader processErrorReader = new BufferedReader( - new InputStreamReader(process.getErrorStream()))) { - String line; - while (!isClosing && (line = processErrorReader.readLine()) != null) { - try { - if (!this.errorSink.tryEmitNext(line).isSuccess()) { - if (!isClosing) { - logger.error("Failed to emit error message"); - } - break; - } - } - catch (Exception e) { - if (!isClosing) { - logger.error("Error processing error message", e); - } + private void startErrorProcessing(Process process) { + readLines(this.errorScheduler, process.getErrorStream(), "error stream", this::emitError, + failure -> this.errorSink.tryEmitComplete()); + } + + private boolean emitError(String line) { + try { + if (this.errorSink.tryEmitNext(line).isSuccess()) { + return true; + } + if (!isClosing) { + logger.error("Failed to emit error message"); + } + } + catch (Exception e) { + if (!isClosing) { + logger.error("Error processing error message", e); + } + } + return false; + } + + /** + * Reads one of the process's output streams line by line on its own thread, until the + * stream ends, the transport closes, or {@code accept} refuses a line; then marks the + * transport closing and runs {@code onEnd} with what ended the reading when it failed: an + * error reading the stream, or a failure handling a line, such as a JSON library that throws + * an {@link Error} (a {@code NoSuchMethodError} from a version clash). + */ + private void readLines(Scheduler scheduler, InputStream stream, String streamName, Predicate accept, + Consumer<@Nullable Throwable> onEnd) { + scheduler.schedule(() -> { + Throwable failure = null; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { + while (!isClosing) { + String line = reader.readLine(); + if (line == null || !accept.test(line)) { break; } } } catch (IOException e) { if (!isClosing) { - logger.error("Error reading from error stream", e); + failure = e; + logger.error("Error reading from " + streamName, e); + this.exceptionHandler.accept(e); } } + catch (RuntimeException | Error e) { + // Not the stream: handling a line failed. Report the real cause, which would + // otherwise end the reading as if the agent had closed its output. + failure = e; + logger.error("Reading the agent's " + streamName + " failed", e); + this.exceptionHandler.accept(e); + } finally { isClosing = true; - errorSink.tryEmitComplete(); + onEnd.accept(failure); } }); } @@ -267,19 +406,32 @@ private void handleIncomingMessages(Function, Mono Mono.just(message) .transform(inboundMessageHandler) .contextWrite(ctx -> ctx.put("observation", "myObservation"))) - .subscribe(); + .subscribe(ignored -> { + }, error -> logger.warn("Inbound message processing ended with an error", error)); } private void handleIncomingErrors() { - this.errorSink.asFlux().subscribe(e -> { - this.stdErrorHandler.accept(e); - }); + this.errorSink.asFlux() + .subscribe(line -> this.stdErrorHandler.accept(line), + error -> logger.warn("Stopped handing the agent's standard error to its handler", error)); } + /** + * {@inheritDoc} + *

The message is queued when this method is called, not when the returned Mono is + * subscribed, and the Mono completes at once; the writer thread writes it as one line. A + * message sent before {@link #connect} is written once the process starts. Once the + * transport is closed, the Mono fails with an {@link AcpConnectionException}. + */ @Override public Mono sendMessage(JSONRPCMessage message) { - this.outboundSink.emitNext(message, - Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + try { + OutboundSinks.emit(this.outboundSink, message); + } + catch (Sinks.EmissionException e) { + return Mono.error(OutboundSinks.isClosed(e) ? new AcpConnectionException("The transport is closed", e) + : new AcpConnectionException("The message could not be queued: " + e.getReason(), e)); + } return Mono.empty(); } @@ -287,76 +439,159 @@ public Mono sendMessage(JSONRPCMessage message) { * Starts the inbound processing thread that reads JSON-RPC messages from the * process's input stream. Messages are deserialized and emitted to the inbound sink. */ - private void startInboundProcessing() { - this.inboundScheduler.schedule(() -> { - try (BufferedReader processReader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - String line; - while (!isClosing && (line = processReader.readLine()) != null) { - try { - logger.trace("RECV: {}", line); - JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(this.jsonMapper, line); - if (!this.inboundSink.tryEmitNext(message).isSuccess()) { - if (!isClosing) { - logger.error("Failed to enqueue inbound message: {}", message); - } - break; - } - } - catch (Exception e) { - if (!isClosing) { - logger.error("Error processing inbound message for line: {}", line, e); - } - break; - } - } - } - catch (IOException e) { - if (!isClosing) { - logger.error("Error reading from input stream", e); - } - } - finally { - isClosing = true; - inboundSink.tryEmitComplete(); - } + private void startInboundProcessing(Process process) { + readLines(this.inboundScheduler, process.getInputStream(), "input stream", this::emitInbound, failure -> { + this.inboundSink.tryEmitComplete(); + terminated(process, failure); }); } + /** + * The agent's standard output has ended: once the process has exited, reports the end of + * the transport, naming the exit, unless it was closed locally. When reading it failed, the + * failure is the reason. + */ + private void terminated(Process process, @Nullable Throwable readFailure) { + if (this.closedLocally) { + this.terminationSink.tryEmitEmpty(); + return; + } + if (readFailure != null) { + this.terminationSink.tryEmitError( + new AcpConnectionException("Reading the agent's output failed: " + readFailure, readFailure)); + return; + } + String reason; + try { + // The agent normally exits right after its output ends; one that closed its + // output but goes on running can deliver nothing more either. + reason = process.waitFor(EXIT_WAIT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + ? exitDescription(process.exitValue()) + : "ACP agent process closed its standard output but has not exited"; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + reason = "ACP agent process closed its standard output"; + } + if (this.closedLocally) { + this.terminationSink.tryEmitEmpty(); + return; + } + logger.info("{}", reason); + this.terminationSink.tryEmitError(new AcpConnectionException(reason)); + } + + /** + * Names how the agent process ended. On POSIX systems Java reports a process killed by + * signal {@code n} as exit code {@code 128 + n}. + */ + static String exitDescription(int exitCode) { + String description = "ACP agent process exited with code " + exitCode; + boolean windows = System.getProperty("os.name", "").startsWith("Windows"); + if (!windows && exitCode > 128 && exitCode < 128 + 65) { + description += " (signal " + (exitCode - 128) + ")"; + } + return description; + } + + /** + * Emits the message a line of the agent's output carries. A line that is not a JSON-RPC + * message is reported, answered, and skipped; a blank line is skipped. + * @return whether to go on reading + */ + private boolean emitInbound(String line) { + if (line.isBlank()) { + return true; + } + logger.trace("RECV: {}", line); + JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(this.jsonMapper, line); + } + catch (Exception e) { + rejectUnreadable(line, e); + return true; + } + if (this.inboundSink.tryEmitNext(message).isSuccess()) { + return true; + } + if (!isClosing) { + logger.error("Failed to enqueue an inbound {}", message.getClass().getSimpleName()); + logger.debug("Inbound message not enqueued: {}", message); + } + return false; + } + + /** + * Reports a line that is not a JSON-RPC message to the exception handler and answers it + * with the JSON-RPC error for an unreadable message (its id is unknown, so null): the + * agent may send requests, so this side is the server for them. + */ + private void rejectUnreadable(String line, Exception e) { + if (!isClosing) { + logger.error("Skipped an inbound line that is not a JSON-RPC message", e); + logger.debug("Skipped inbound line: {}", line); + } + this.exceptionHandler.accept(e); + try { + OutboundSinks.emit(this.outboundSink, AcpSchema.unreadableMessageResponse(this.jsonMapper, line)); + } + catch (Sinks.EmissionException emission) { + if (!isClosing) { + logger.error("Failed to answer an unreadable inbound line", emission); + } + } + } + /** * Starts the outbound processing thread that writes JSON-RPC messages to the * process's output stream. Messages are serialized to JSON and written with a newline * delimiter. */ - private void startOutboundProcessing() { + private void startOutboundProcessing(Process process) { this.handleOutbound(messages -> messages // this bit is important since writes come from user threads, and we // want to ensure that the actual writing happens on a dedicated thread .publishOn(outboundScheduler) - .handle((message, s) -> { - if (message != null && !isClosing) { - try { - String jsonMessage = jsonMapper.writeValueAsString(message); - // Escape any embedded newlines in the JSON message as per spec: - // Messages are delimited by newlines, and MUST NOT contain - // embedded newlines. - jsonMessage = jsonMessage.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n"); - logger.trace("SEND: {}", jsonMessage); - - var os = this.process.getOutputStream(); - synchronized (os) { - os.write(jsonMessage.getBytes(StandardCharsets.UTF_8)); - os.write("\n".getBytes(StandardCharsets.UTF_8)); - os.flush(); - } - s.next(message); - } - catch (IOException e) { - s.error(new RuntimeException(e)); - } - } - })); + .handle((message, sink) -> write(process, message, sink))); + } + + /** + * Writes one message to the process's standard input as a line of JSON; once the + * transport is closing, drops it. + */ + private void write(Process process, JSONRPCMessage message, SynchronousSink sink) { + if (isClosing) { + return; + } + try { + // Messages are delimited by newlines, and MUST NOT contain embedded newlines. + String jsonMessage = jsonMapper.writeValueAsString(message) + .replace("\r\n", "\\n") + .replace("\n", "\\n") + .replace("\r", "\\n"); + logger.trace("SEND: {}", jsonMessage); + var os = process.getOutputStream(); + synchronized (os) { + os.write(jsonMessage.getBytes(StandardCharsets.UTF_8)); + os.write("\n".getBytes(StandardCharsets.UTF_8)); + os.flush(); + } + sink.next(message); + } + catch (IOException e) { + sink.error(new RuntimeException(e)); + } } + /** + * Applies {@code outboundConsumer} to the stream of messages queued by + * {@link #sendMessage} and subscribes to the result; when that stream completes or fails, + * the transport stops writing. {@link #connect} calls it with the function that writes + * each message to the agent's standard input on the writer thread. A subclass that + * overrides it must apply the function, or nothing is written. + * @param outboundConsumer turns the queued messages into the messages written + */ protected void handleOutbound(Function, Flux> outboundConsumer) { outboundConsumer.apply(outboundSink.asFlux()).doOnComplete(() -> { isClosing = true; @@ -364,21 +599,34 @@ protected void handleOutbound(Function, Flux { if (!isClosing) { logger.error("Error in outbound processing", e); + this.exceptionHandler.accept(e); isClosing = true; outboundSink.tryEmitComplete(); } - }).subscribe(); + // Logged above unless closing; not dropped to Reactor's ERROR hook. + }).subscribe(ignored -> { + }, error -> logger.debug("Outbound processing ended: {}", error.toString())); } /** - * Gracefully closes the transport by destroying the process and disposing of the - * schedulers. This method sends a TERM signal to the process and waits for it to exit - * before cleaning up resources. - * @return A Mono that completes when the transport is closed + * {@inheritDoc} + *

Lets the agent exit by itself, in this order: stops delivering and writing messages, + * closes the agent's standard input, waits up to {@value #END_OF_INPUT_WAIT_MILLIS} ms for + * the process to exit, sends SIGTERM, and after five more seconds kills it; then stops the + * transport's threads and completes {@link #awaitTermination()}. The steps run on the + * subscribing thread. Only the first close stops the process; a later one completes at + * once. + * @return a Mono that completes when the process has exited, or been killed, and the + * transport is closed */ @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { + if (!this.stopping.compareAndSet(false, true)) { + logger.debug("Already closed"); + return; + } + closedLocally = true; isClosing = true; logger.debug("Initiating graceful shutdown"); @@ -387,33 +635,10 @@ public Mono closeGracefully() { outboundSink.tryEmitComplete(); errorSink.tryEmitComplete(); - // Destroy process FIRST - this closes streams and unblocks readLine() - // Use blocking waitFor() instead of Mono.fromFuture(process.onExit()) - // to avoid ForkJoinPool.commonPool (per BEST-PRACTICES-REACTIVE-SCHEDULERS.md Rule 1) - if (this.process != null) { - logger.debug("Sending TERM to process"); - this.process.destroy(); - try { - boolean exited = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS); - if (exited) { - int exitCode = process.exitValue(); - // 143 = SIGTERM (128+15), 137 = SIGKILL (128+9) - expected when we destroy - if (exitCode == 0 || exitCode == 143 || exitCode == 137) { - logger.info("ACP agent process stopped (exit code {})", exitCode); - } - else { - logger.warn("Process terminated unexpectedly with code {}", exitCode); - } - } - else { - logger.warn("Process did not exit within timeout, forcing kill"); - process.destroyForcibly(); - } - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.debug("Interrupted while waiting for process exit"); - } + // Stop the process FIRST - its end closes the streams and unblocks readLine() + Process process = this.process; + if (process != null) { + stop(process); } // Now that process is dead and streams closed, threads should be unblocked @@ -426,9 +651,76 @@ public Mono closeGracefully() { catch (Exception e) { logger.error("Error during graceful shutdown", e); } + terminationSink.tryEmitEmpty(); }); } + /** + * Closes the process's standard input and waits for it to exit by itself; then sends TERM + * and waits up to five seconds, then kills it. Waits with a blocking waitFor() rather than + * Mono.fromFuture(process.onExit()), which would run on ForkJoinPool.commonPool. + */ + private static void stop(Process process) { + try { + var stdin = process.getOutputStream(); + synchronized (stdin) { + stdin.close(); + } + } + catch (IOException e) { + logger.debug("Closing the agent's standard input failed: {}", e.getMessage()); + } + try { + if (process.waitFor(END_OF_INPUT_WAIT_MILLIS, java.util.concurrent.TimeUnit.MILLISECONDS)) { + logExit(process.exitValue()); + return; + } + logger.debug("Agent still running {} ms after the end of its input; sending TERM", + END_OF_INPUT_WAIT_MILLIS); + process.destroy(); + if (process.waitFor(TERM_WAIT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { + logExit(process.exitValue()); + } + else { + logger.warn("Process did not exit within timeout, forcing kill"); + process.destroyForcibly(); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.debug("Interrupted while waiting for process exit"); + } + } + + private static void logExit(int exitCode) { + // 143 = SIGTERM (128+15), 137 = SIGKILL (128+9) - expected when we destroy + if (exitCode == 0 || exitCode == 143 || exitCode == 137) { + logger.info("ACP agent process stopped (exit code {})", exitCode); + } + else { + logger.warn("Process terminated unexpectedly with code {}", exitCode); + } + } + + /** + * {@inheritDoc} + *

It completes when the transport is closed locally. It errors with an + * {@link AcpConnectionException} when the agent process ends by itself, once its standard + * output has been read to the end; the message names the exit code (and, on POSIX systems, + * the signal), or says that the agent closed its standard output without exiting. + * @return a Mono that terminates when the transport does + */ + @Override + public Mono awaitTermination() { + return this.terminationSink.asMono(); + } + + /** + * Returns the sink that carries the lines the agent writes to its standard error. The + * transport subscribes to it when it connects, and the sink accepts a single subscriber, + * so read the lines with {@link #setStdErrorHandler} instead of subscribing here. + * @return the standard-error sink + */ public Sinks.Many getErrorSink() { return this.errorSink; } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java index c72f0066..bcdad297 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransport.java @@ -4,37 +4,20 @@ package com.agentclientprotocol.sdk.client.transport; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.net.CookieManager; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.Map; import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Future; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.RejectedExecutionException; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpRequests.HttpClientBundle; import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.error.AcpErrorCodes; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpClientTransport; @@ -48,14 +31,46 @@ import reactor.core.publisher.Sinks; /** - * Client-side ACP transport for the Streamable HTTP profile. + * The client side of the Streamable HTTP transport: talks to an agent that is already running + * behind an HTTP endpoint, posting the client's messages to it and reading the agent's + * messages from Server-Sent Event (SSE) streams. Use it to reach a remote agent, for instance + * one served by the {@code acp-streamable-http-jetty} module's listener or servlet; to start + * the agent as a child process, use {@link StdioAcpClientTransport}. Pass it to + * {@code AcpClient.sync(transport)} or {@code AcpClient.async(transport)}: * - *

- * Streamable HTTP maps ACP's logical duplex conversation onto HTTP POST requests plus - * long-lived Server-Sent Event (SSE) streams. The transport keeps all HTTP-specific - * routing state internal so the higher-level ACP session can continue to operate only on - * JSON-RPC messages. - *

+ *
{@code
+ * var transport = new StreamableHttpAcpClientTransport(URI.create("http://localhost:8080/acp"),
+ *         AcpJsonMapper.createDefault());
+ * AcpSyncClient client = AcpClient.sync(transport).build();
+ * }
+ * + *

Unlike the stdio transport, connecting does not reach the agent. The {@code initialize} + * request opens the connection, so it must be the first message sent; its answer carries the + * connection's id in the Acp-Connection-Id header, which every later request sends, and + * requests about one ACP session also name it in the Acp-Session-Id header. The transport + * opens one SSE stream for the connection and one for each ACP session, before the session's + * first request, and reconnects a stream that the server or the network closes. If the + * connection's stream, or a session stream that still owes a response, cannot be reopened, + * the transport ends: its {@link #awaitTermination()} errors and the client's pending requests + * fail. The wire format is the one of ACP's Streamable HTTP and WebSocket transport RFD + * (https://agentclientprotocol.com/rfds/streamable-http-websocket-transport). + * + *

The default HTTP client asks for HTTP/2. Over {@code https} it negotiates it; over plain + * {@code http} the transport first sends a bodiless GET so that a server that speaks + * cleartext HTTP/2 (h2c) can upgrade the connection, and uses HTTP/1.1 for every request when + * the server does not. The default client keeps cookies in a cookie manager of its own. + * + *

On JDK 21 and later the transport's work (the default client's, handing over HTTP + * results, and reading each SSE stream) runs on a virtual thread per task; before, on bounded + * pools of daemon threads, which {@link StreamableHttpAcpClientTransportOptions} sizes. The + * options also set the number of SSE streams, or name an executor of the application's that the + * work runs on instead. Either way the JDK's {@link HttpClient} keeps one selector thread of its + * own. Pass an {@link HttpClient} of your own for TLS, proxy or authentication settings. + * + *

{@link #closeGracefully()} closes the streams and sends {@code DELETE} for the connection, + * waiting at most five seconds for the answer; {@link #close()} does the same and blocks for up + * to ten seconds. The transport is thread-safe: messages may be sent from any thread, and are + * posted concurrently. * * @author Kaiser Dandangi */ @@ -63,89 +78,24 @@ public class StreamableHttpAcpClientTransport implements AcpClientTransport { private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpClientTransport.class); - /** Default ACP path used by the remote transport RFD. */ + /** + * The endpoint path that ACP's remote transport RFD names, and that the SDK's listener + * serves by default: {@value}. The transport does not add it; the endpoint URI must + * include the path. + */ public static final String DEFAULT_ACP_PATH = "/acp"; - private static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; - - private static final String HEADER_SESSION_ID = "Acp-Session-Id"; - - private static final String CONTENT_TYPE_JSON = "application/json"; - - private static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; - private static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(10); - private enum ScopeKind { - - BOOTSTRAP, - - CONNECTION, - - SESSION - - } - - private enum RequestKind { - - INITIALIZE, - - SESSION_NEW, - - SESSION_LOAD, - - GENERIC - - } - - private record RouteScope(ScopeKind kind, String sessionId) { - - static RouteScope bootstrap() { - return new RouteScope(ScopeKind.BOOTSTRAP, null); - } - - static RouteScope connection() { - return new RouteScope(ScopeKind.CONNECTION, null); - } - - static RouteScope session(String sessionId) { - return new RouteScope(ScopeKind.SESSION, sessionId); - } - - boolean isSession() { - return kind == ScopeKind.SESSION; - } - - } - - private record OutboundRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { - } - - private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExecutor) { - } - - private final URI endpointUri; - private final AcpJsonMapper jsonMapper; - private final HttpClient httpClient; - - private final ExecutorService ownedHttpExecutor; + private final StreamableHttpRequests requests; - private final ExecutorService httpSignalExecutor; + private final StreamableHttpRoutes routes; - private final ExecutorService sseExecutor; + private final StreamableHttpStreams streams; - private final int maxSseStreams; - - private final Sinks.Many inboundSink; - - /* - * A streamable HTTP client may have one connection SSE reader and multiple session - * SSE readers active at the same time. Reactor unicast sinks require serialized - * producers, so every SSE reader emits through this monitor. - */ - private final Object inboundEmitMonitor = new Object(); + private final StreamableHttpInbound inbound; private final AtomicBoolean connected = new AtomicBoolean(false); @@ -153,40 +103,44 @@ private record HttpClientBundle(HttpClient httpClient, ExecutorService ownedExec private final AtomicBoolean closing = new AtomicBoolean(false); - // Client-originated request id -> where the eventual SSE response is expected. - private final Map outboundRequestRoutes = new ConcurrentHashMap<>(); - - // Agent-originated request id -> HTTP scope required for the later client POST response. - private final Map inboundRequestRoutes = new ConcurrentHashMap<>(); - - private final Map sessionStreams = new ConcurrentHashMap<>(); - - // Session id -> shared open operation so callers reuse one GET while opening. - private final Map> sessionStreamOpenOperations = new ConcurrentHashMap<>(); - - private volatile SseStream connectionStream; - - private volatile String connectionId; - private volatile Consumer exceptionHandler = t -> logger.error("Transport error", t); private final Sinks.One terminationSink = Sinks.one(); /** - * Creates a new Streamable HTTP client transport using a default JDK {@link HttpClient} - * configured with an internal {@link CookieManager}. - * @param endpointUri the remote ACP endpoint URI - * @param jsonMapper JSON mapper used for message serialization + * Creates a transport for the endpoint at {@code endpointUri}, with the default HTTP client, + * limits and JSON mapper ({@link AcpJsonMapper#createDefault()}). + * @param endpointUri the agent's endpoint: an {@code http} or {@code https} URI including + * its path, such as {@code http://localhost:8080/acp} + * @throws IllegalArgumentException if {@code endpointUri} is null or its scheme is not + * {@code http} or {@code https} + */ + public StreamableHttpAcpClientTransport(URI endpointUri) { + this(endpointUri, AcpJsonMapper.createDefault()); + } + + /** + * Creates a transport for the endpoint at {@code endpointUri}, with the default HTTP + * client (HTTP/2, its own {@link CookieManager}) and the default limits. + * @param endpointUri the agent's endpoint: an {@code http} or {@code https} URI including + * its path, such as {@code http://localhost:8080/acp} + * @param jsonMapper the mapper that reads and writes the messages + * @throws IllegalArgumentException if an argument is null or the URI's scheme is not + * {@code http} or {@code https} */ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper) { this(endpointUri, jsonMapper, StreamableHttpAcpClientTransportOptions.defaults()); } /** - * Creates a new Streamable HTTP client transport with explicit resource limits. - * @param endpointUri the remote ACP endpoint URI - * @param jsonMapper JSON mapper used for message serialization - * @param options resource limits for this transport + * Creates a transport for the endpoint at {@code endpointUri}, with the default HTTP client + * sized by {@code options}, or running on the options' executor when it is set. + * @param endpointUri the agent's endpoint: an {@code http} or {@code https} URI including + * its path + * @param jsonMapper the mapper that reads and writes the messages + * @param options the transport's limits and executor + * @throws IllegalArgumentException if an argument is null or the URI's scheme is not + * {@code http} or {@code https} */ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, StreamableHttpAcpClientTransportOptions options) { @@ -194,28 +148,37 @@ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMappe } /** - * Creates a new Streamable HTTP client transport using a caller-provided - * {@link HttpClient}. This allows advanced callers to customize cookies, TLS, - * executors, or proxy behavior. - * @param endpointUri the remote ACP endpoint URI - * @param jsonMapper JSON mapper used for message serialization - * @param httpClient HTTP client to use for requests + * Creates a transport for the endpoint at {@code endpointUri} that sends its requests with + * {@code httpClient}, for TLS, proxy, authentication or cookie settings of your own. The + * transport does not close the client or its executor. + * @param endpointUri the agent's endpoint: an {@code http} or {@code https} URI including + * its path + * @param jsonMapper the mapper that reads and writes the messages + * @param httpClient the client the requests are sent with + * @throws IllegalArgumentException if an argument is null or the URI's scheme is not + * {@code http} or {@code https} */ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClient httpClient) { this(endpointUri, jsonMapper, httpClient, StreamableHttpAcpClientTransportOptions.defaults()); } /** - * Creates a new Streamable HTTP client transport with a caller-provided HTTP client and - * explicit resource limits. - * @param endpointUri the remote ACP endpoint URI - * @param jsonMapper JSON mapper used for message serialization - * @param httpClient HTTP client to use for requests - * @param options resource limits for this transport + * Creates a transport for the endpoint at {@code endpointUri} that sends its requests with + * {@code httpClient} and has the limits of {@code options}. The options' worker threads + * size only the default client, so they do not apply here; their executor, when set, still + * runs the transport's own work (handing over results and reading the SSE streams). The + * transport does not close the client or its executor. + * @param endpointUri the agent's endpoint: an {@code http} or {@code https} URI including + * its path + * @param jsonMapper the mapper that reads and writes the messages + * @param httpClient the client the requests are sent with + * @param options the transport's limits + * @throws IllegalArgumentException if an argument is null or the URI's scheme is not + * {@code http} or {@code https} */ public StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClient httpClient, StreamableHttpAcpClientTransportOptions options) { - this(endpointUri, jsonMapper, new HttpClientBundle(httpClient, null), options); + this(endpointUri, jsonMapper, bundle(httpClient, options), options); } private StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapper, HttpClientBundle bundle, @@ -229,61 +192,53 @@ private StreamableHttpAcpClientTransport(URI endpointUri, AcpJsonMapper jsonMapp || "https".equalsIgnoreCase(endpointUri.getScheme()), "The endpointUri must use http or https"); - this.endpointUri = endpointUri; this.jsonMapper = jsonMapper; - this.httpClient = bundle.httpClient(); - this.ownedHttpExecutor = bundle.ownedExecutor(); - this.httpSignalExecutor = boundedExecutor(options.httpSignalThreads(), options.httpQueueCapacity(), - "acp-streamable-http-signal"); - this.maxSseStreams = options.maxSseStreams(); - this.sseExecutor = new ThreadPoolExecutor(options.maxSseStreams(), options.maxSseStreams(), 0, - TimeUnit.MILLISECONDS, new SynchronousQueue<>(), daemonThreadFactory("acp-streamable-http-sse"), - new ThreadPoolExecutor.AbortPolicy()); - this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + this.requests = new StreamableHttpRequests(endpointUri, bundle, options); + this.routes = new StreamableHttpRoutes(jsonMapper); + this.streams = new StreamableHttpStreams(requests, routes, jsonMapper, options.maxSseStreams(), bundle.workExecutor(), + new StreamableHttpStreams.Owner(this::processInbound, closing::get, this::terminateAfterSseFailure, + error -> this.exceptionHandler.accept(error), this::answerUnreadable)); + this.inbound = new StreamableHttpInbound(routes, streams, jsonMapper); } - private static HttpClientBundle createDefaultHttpClient(StreamableHttpAcpClientTransportOptions options) { + private static HttpClientBundle bundle(HttpClient httpClient, StreamableHttpAcpClientTransportOptions options) { Assert.notNull(options, "The transport options can not be null"); - ExecutorService executor = boundedExecutor(options.httpWorkerThreads(), options.httpQueueCapacity(), - "acp-streamable-http-client"); - HttpClient client = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .cookieHandler(new CookieManager()) - .executor(executor) - .build(); - return new HttpClientBundle(client, executor); - } - - private static ExecutorService boundedExecutor(int threads, int queueCapacity, String threadName) { - return new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, - new ArrayBlockingQueue<>(queueCapacity), daemonThreadFactory(threadName), new ThreadPoolExecutor.AbortPolicy()); + return HttpClientBundle.of(httpClient, options); } - private static ThreadFactory daemonThreadFactory(String threadName) { - return runnable -> { - Thread thread = new Thread(runnable, threadName); - thread.setDaemon(true); - return thread; - }; + private static HttpClientBundle createDefaultHttpClient(StreamableHttpAcpClientTransportOptions options) { + Assert.notNull(options, "The transport options can not be null"); + return HttpClientBundle.createDefault(options); } + /** + * {@inheritDoc} + *

It contacts nothing: it registers the handler and completes at once. The connection + * opens when the {@code initialize} request is sent. A second call fails with an + * {@link IllegalStateException}. + */ @Override public Mono connect(Function, Mono> handler) { Assert.notNull(handler, "The handler can not be null"); if (!connected.compareAndSet(false, true)) { return Mono.error(new IllegalStateException("Already connected")); } - - handleIncomingMessages(handler); - return Mono.empty(); - } - - private void handleIncomingMessages(Function, Mono> handler) { - this.inboundSink.asFlux() + inbound.messages() .flatMap(message -> Mono.just(message).transform(handler)) - .subscribe(); + .subscribe(ignored -> { + }, error -> logger.warn("Inbound message processing ended with an error", error)); + return Mono.empty(); } + /** + * {@inheritDoc} + *

The {@code initialize} request opens the connection: its Mono completes once the + * answer has been read and the connection's SSE stream is open, and a second + * {@code initialize} fails with an {@link IllegalStateException}. Every other message is + * posted on the connection, or on its ACP session's stream, and its Mono completes when + * the server has accepted the POST. Sent before {@code initialize}, or once the transport + * is closing, a message fails with an {@link AcpConnectionException}. + */ @Override public Mono sendMessage(JSONRPCMessage message) { Assert.notNull(message, "The message can not be null"); @@ -299,70 +254,6 @@ public Mono sendMessage(JSONRPCMessage message) { return routeAndPost(message); } - /** - * HTTP version pinned for every request after the cleartext probe, or {@code null} to - * use the client's own setting. Set to HTTP/1.1 when an {@code http://} server does not - * speak h2c, so later requests stop carrying {@code Upgrade: h2c}: some servers hand any - * request with an Upgrade header to their WebSocket handler and answer 405 (the - * TypeScript SDK's example server does). - */ - private volatile HttpClient.Version pinnedVersion; - - private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5); - - /** - * The transport requires HTTP/2 (RFD). Over {@code https} ALPN negotiates it. Over - * cleartext {@code http} the JDK offers an h2c upgrade on every request, but servers - * (Jetty among them) only honour it on a request without a body, and {@code initialize} - * is a POST. A bodiless GET first upgrades the connection when the server speaks - * h2c; every later request reuses it over HTTP/2. When it does not (answer on HTTP/1.1, - * an error, or no answer within five seconds), every later request is pinned to HTTP/1.1. - * A GET rather than OPTIONS because some h2c servers (Hypercorn) upgrade a GET but answer - * OPTIONS with 405 on HTTP/1.1. It carries no connection id, so servers answer it with a - * 4xx without opening a stream; only the negotiated version is used. - */ - private Mono upgradeCleartextToHttp2() { - if (!"http".equalsIgnoreCase(endpointUri.getScheme()) || httpClient.version() != HttpClient.Version.HTTP_2) { - return Mono.empty(); - } - HttpRequest probe = HttpRequest.newBuilder(endpointUri) - .GET() - .build(); - // Cancelling the Mono on timeout cancels the HTTP exchange (see sendAsync). - return sendAsync(probe, HttpResponse.BodyHandlers.discarding()) - .timeout(PROBE_TIMEOUT, AcpSchedulers.timeouts()) - .doOnNext(response -> { - logger.debug("Cleartext probe to {} negotiated {}", endpointUri, response.version()); - if (response.version() != HttpClient.Version.HTTP_2) { - this.pinnedVersion = HttpClient.Version.HTTP_1_1; - } - }) - .then() - .onErrorResume(error -> { - logger.debug("Cleartext HTTP/2 probe to {} failed ({}); using HTTP/1.1", endpointUri, error.getMessage()); - this.pinnedVersion = HttpClient.Version.HTTP_1_1; - return Mono.empty(); - }); - } - - /** Re-stamps a request built before the probe with the version the probe settled on. */ - private HttpRequest pinned(HttpRequest request) { - HttpClient.Version version = this.pinnedVersion; - if (version == null) { - return request; - } - return HttpRequest.newBuilder(request, (name, value) -> true).version(version).build(); - } - - private HttpRequest.Builder newRequest() { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(endpointUri); - HttpClient.Version version = this.pinnedVersion; - if (version != null) { - requestBuilder.version(version); - } - return requestBuilder; - } - private Mono initialize(AcpSchema.JSONRPCRequest request) { if (!initialized.compareAndSet(false, true)) { return Mono.error(new IllegalStateException("Transport is already initialized")); @@ -370,360 +261,105 @@ private Mono initialize(AcpSchema.JSONRPCRequest request) { HttpRequest httpRequest; try { - httpRequest = jsonPostBuilder(RouteScope.bootstrap()) - .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(request), StandardCharsets.UTF_8)) - .build(); + httpRequest = requests.jsonPost(RouteScope.bootstrap(), jsonMapper.writeValueAsString(request)); } catch (IOException e) { initialized.set(false); return Mono.error(new AcpConnectionException("Failed to serialize initialize request", e)); } - HttpRequest initializeRequest = httpRequest; - return upgradeCleartextToHttp2() - .then(Mono.defer(() -> sendAsync(pinned(initializeRequest), HttpResponse.BodyHandlers.ofString()))) - .flatMap(response -> { - if (response.statusCode() != 200) { - return Mono.error(new AcpConnectionException( - "Expected 200 for initialize, got " + response.statusCode())); - } - String contentType = response.headers().firstValue("Content-Type").orElse(""); - if (!contentType.toLowerCase().contains(CONTENT_TYPE_JSON)) { - return Mono.error(new AcpConnectionException( - "Expected " + CONTENT_TYPE_JSON + " initialize response, got " + contentType)); - } - JSONRPCMessage responseMessage; - try { - responseMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, response.body()); - } - catch (Exception e) { - return Mono.error(new AcpConnectionException("Failed to deserialize initialize response", e)); - } - if (!(responseMessage instanceof AcpSchema.JSONRPCResponse initializeResponse)) { - return Mono.error(new AcpConnectionException("ACP initialize response was not a JSON-RPC response")); - } - if (!Objects.equals(request.id(), initializeResponse.id())) { - return Mono.error( - new AcpConnectionException("ACP initialize response id did not match initialize request")); - } - this.connectionId = response.headers() - .firstValue(HEADER_CONNECTION_ID) - .orElseThrow(() -> new AcpConnectionException( - "Initialize response missing " + HEADER_CONNECTION_ID)); - return openConnectionStream().then(emitInbound(responseMessage)); - }) + return requests.upgradeCleartextToHttp2() + .then(Mono.defer(() -> requests.sendAsync(requests.pinned(httpRequest), HttpResponse.BodyHandlers.ofString()))) + .flatMap(response -> readInitializeResponse(request, response)) + .flatMap(responseMessage -> streams.openConnectionStream().then(inbound.emit(responseMessage))) .doOnError(error -> { initialized.set(false); exceptionHandler.accept(error); }); } - private Mono routeAndPost(JSONRPCMessage message) { - return Mono.defer(() -> { - ResolvedOutboundRoute resolved = resolveOutboundRoute(message); - Mono preparation = prepareRoute(resolved); - return preparation.then(postAccepted(message, resolved.scope())) - .doOnSuccess(ignored -> { - if (message instanceof AcpSchema.JSONRPCResponse response) { - inboundRequestRoutes.remove(response.id()); - } - }) - .doOnError(error -> { - if (message instanceof AcpSchema.JSONRPCRequest request) { - outboundRequestRoutes.remove(request.id()); - } - }); - }); - } - - private Mono prepareRoute(ResolvedOutboundRoute resolved) { - if (resolved.message() instanceof AcpSchema.JSONRPCRequest request - && AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) { - // Open the session stream first (the RFD's reconnect order) unless it is already - // open: servers allow one receiver per stream, and the TypeScript and Rust servers - // answer a second GET with 409. - SseStream existing = sessionStreams.get(resolved.scope().sessionId()); - if (existing != null && !existing.closed.get()) { - return Mono.empty(); - } - return openSessionStream(resolved.scope().sessionId()); - } - if (resolved.scope().isSession() && !sessionStreams.containsKey(resolved.scope().sessionId())) { - return openSessionStream(resolved.scope().sessionId()); - } - return Mono.empty(); - } - - private Mono postAccepted(JSONRPCMessage message, RouteScope scope) { - HttpRequest request; + /** + * Checks the answer to {@code initialize} and records the connection id it assigns. + * Emits the JSON-RPC response it carries. + */ + private Mono readInitializeResponse(AcpSchema.JSONRPCRequest request, + HttpResponse response) { + return StreamableHttpRequests.expectStatus(response, 200, "for initialize") + .then(StreamableHttpRequests.expectContentType(response, StreamableHttpRequests.CONTENT_TYPE_JSON, + "initialize response")) + .then(Mono.fromCallable(() -> deserializeInitializeResponse(request, response.body()))) + .doOnNext(ignored -> requests.connectionId(response.headers() + .firstValue(StreamableHttpRequests.HEADER_CONNECTION_ID) + .orElseThrow(() -> new AcpConnectionException( + "Initialize response missing " + StreamableHttpRequests.HEADER_CONNECTION_ID)))); + } + + private JSONRPCMessage deserializeInitializeResponse(AcpSchema.JSONRPCRequest request, String body) { + JSONRPCMessage responseMessage; try { - request = jsonPostBuilder(scope) - .POST(HttpRequest.BodyPublishers.ofString(jsonMapper.writeValueAsString(message), StandardCharsets.UTF_8)) - .build(); + responseMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, body); } - catch (IOException e) { - return Mono.error(new AcpConnectionException("Failed to serialize outbound message", e)); - } - - return sendAsync(request, HttpResponse.BodyHandlers.discarding()) - .flatMap(response -> { - if (response.statusCode() != 202) { - return Mono.error(new AcpConnectionException( - "Expected 202 for POST, got " + response.statusCode())); - } - return Mono.empty(); - }); - } - - private HttpRequest.Builder jsonPostBuilder(RouteScope scope) { - HttpRequest.Builder builder = newRequest() - .header("Content-Type", CONTENT_TYPE_JSON) - .header("Accept", CONTENT_TYPE_JSON); - addScopeHeaders(builder, scope); - return builder; - } - - private Mono openConnectionStream() { - return openSseStream(RouteScope.connection()).doOnSuccess(stream -> { - this.connectionStream = stream; - stream.start(); - }).then(); - } - - private Mono openSessionStream(String sessionId) { - return sessionStreamOpenOperations.computeIfAbsent(sessionId, this::createSessionStreamOpenMono); - } - - private Mono createSessionStreamOpenMono(String sessionId) { - AtomicReference> operation = new AtomicReference<>(); - Mono openOperation = openSseStream(RouteScope.session(sessionId)) - .doOnSuccess(stream -> { - SseStream existing = sessionStreams.putIfAbsent(sessionId, stream); - if (existing == null) { - try { - stream.start(); - } - catch (RuntimeException e) { - // Otherwise a later request for this session would skip the - // reopen and post into a stream that never reads. - sessionStreams.remove(sessionId, stream); - throw e; - } - } - else { - stream.close(); - } - }) - .then() - .doFinally(signal -> sessionStreamOpenOperations.remove(sessionId, operation.get())) - .cache(); - operation.set(openOperation); - return openOperation; - } - - private Mono openSseStream(RouteScope scope) { - HttpRequest.Builder builder = newRequest().GET().header("Accept", CONTENT_TYPE_EVENT_STREAM); - addScopeHeaders(builder, scope); - HttpRequest request = builder.build(); - - return sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) - .flatMap(response -> { - if (response.statusCode() != 200) { - return Mono.error(new AcpConnectionException( - "Expected 200 when opening SSE stream, got " + response.statusCode())); - } - String contentType = response.headers().firstValue("Content-Type").orElse(""); - if (!contentType.toLowerCase().contains(CONTENT_TYPE_EVENT_STREAM)) { - return Mono.error(new AcpConnectionException( - "Expected " + CONTENT_TYPE_EVENT_STREAM + " response, got " + contentType)); - } - return Mono.just(new SseStream(scope, response.body())); - }); - } - - private void addScopeHeaders(HttpRequest.Builder builder, RouteScope scope) { - if (scope.kind() != ScopeKind.BOOTSTRAP) { - String currentConnectionId = requireConnectionId(); - builder.header(HEADER_CONNECTION_ID, currentConnectionId); + catch (Exception e) { + throw new AcpConnectionException("Failed to deserialize initialize response", e); } - if (scope.isSession()) { - builder.header(HEADER_SESSION_ID, scope.sessionId()); + if (!(responseMessage instanceof AcpSchema.JSONRPCResponse initializeResponse)) { + throw new AcpConnectionException("ACP initialize response was not a JSON-RPC response"); } - } - - private String requireConnectionId() { - String currentConnectionId = this.connectionId; - if (currentConnectionId == null || currentConnectionId.isBlank()) { - throw new AcpConnectionException("Missing " + HEADER_CONNECTION_ID); + if (!Objects.equals(request.id(), initializeResponse.id())) { + throw new AcpConnectionException("ACP initialize response id did not match initialize request"); } - return currentConnectionId; + return responseMessage; } - private ResolvedOutboundRoute resolveOutboundRoute(JSONRPCMessage message) { - if (message instanceof AcpSchema.JSONRPCResponse response) { - RouteScope scope = inboundRequestRoutes.get(response.id()); - if (scope == null) { - throw new AcpConnectionException("Cannot route outbound response with unknown id " + response.id()); - } - return new ResolvedOutboundRoute(message, scope, null); - } - - if (message instanceof AcpSchema.JSONRPCRequest request) { - ResolvedOutboundRoute resolved = resolveRequestOrNotificationRoute(message, request.method(), request.params()); - if (resolved.requestRoute() != null && request.id() != null) { - outboundRequestRoutes.put(request.id(), resolved.requestRoute()); - } - return resolved; - } - - if (message instanceof AcpSchema.JSONRPCNotification notification) { - return resolveRequestOrNotificationRoute(message, notification.method(), notification.params()); - } - - throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message); + private Mono routeAndPost(JSONRPCMessage message) { + return Mono.defer(() -> { + RouteScope scope = routes.resolveOutbound(message); + return streams.prepare(message, scope) + .flatMap(postScope -> { + routes.postedIn(message, postScope); + return post(message, postScope); + }) + .doOnSuccess(ignored -> routes.posted(message)) + .doOnError(error -> routes.postFailed(message)); + }); } - private ResolvedOutboundRoute resolveRequestOrNotificationRoute(JSONRPCMessage message, String method, Object params) { - RouteScope requestScope; - RequestKind requestKind = RequestKind.GENERIC; - RouteScope responseScope; - - switch (method) { - case AcpSchema.METHOD_INITIALIZE: - requestScope = RouteScope.bootstrap(); - requestKind = RequestKind.INITIALIZE; - responseScope = RouteScope.bootstrap(); - break; - case AcpSchema.METHOD_AUTHENTICATE: - case AcpSchema.METHOD_SESSION_NEW: - requestScope = RouteScope.connection(); - requestKind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC; - responseScope = RouteScope.connection(); - break; - case AcpSchema.METHOD_SESSION_LOAD: - case AcpSchema.METHOD_SESSION_RESUME: - requestScope = RouteScope.session(requireSessionId(params, method)); - requestKind = RequestKind.SESSION_LOAD; - responseScope = RouteScope.connection(); - break; - case AcpSchema.METHOD_SESSION_PROMPT: - case AcpSchema.METHOD_SESSION_SET_MODE: - case AcpSchema.METHOD_SESSION_SET_MODEL: - case AcpSchema.METHOD_SESSION_CANCEL: - requestScope = RouteScope.session(requireSessionId(params, method)); - responseScope = requestScope; - break; - default: - Optional sessionId = extractSessionId(params); - if (sessionId.isPresent()) { - logger.warn("Falling back to inferred session routing for unknown method '{}'", method); - requestScope = RouteScope.session(sessionId.get()); - } - else { - logger.warn("Falling back to inferred connection routing for unknown method '{}'", method); - requestScope = RouteScope.connection(); - } - responseScope = requestScope; + private Mono post(JSONRPCMessage message, RouteScope scope) { + String json; + try { + json = jsonMapper.writeValueAsString(message); } - - OutboundRequestRoute requestRoute = null; - if (message instanceof AcpSchema.JSONRPCRequest) { - requestRoute = new OutboundRequestRoute(requestKind, requestScope, responseScope); + catch (IOException e) { + return Mono.error(new AcpConnectionException("Failed to serialize outbound message", e)); } - return new ResolvedOutboundRoute(message, requestScope, requestRoute); + return requests.postAccepted(scope, json); } - private Optional extractSessionId(Object params) { - if (params == null) { - return Optional.empty(); + /** + * Answers an SSE event that is no JSON-RPC message, as JSON-RPC 2.0 says (-32700 or + * -32600, with the request's id when it can be read) and as the stdio and WebSocket + * transports do, in the scope of the stream it came on. The answer names no routed + * request, so it is posted directly. + */ + private void answerUnreadable(RouteScope scope, String data) { + if (closing.get()) { + return; } - Map paramsMap = jsonMapper.convertValue(params, Map.class); - Object sessionId = paramsMap.get("sessionId"); - return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); - } - - private String requireSessionId(Object params, String method) { - return extractSessionId(params) - .filter(sessionId -> !sessionId.isBlank()) - .orElseThrow(() -> new AcpConnectionException("Missing sessionId for outbound method " + method)); + post(AcpSchema.unreadableMessageResponse(jsonMapper, data), scope).subscribe(ignored -> { + }, error -> logger.debug("Could not answer an unreadable SSE event: {}", error.getMessage())); } private Mono processInbound(RouteScope actualScope, JSONRPCMessage message) { - if (message instanceof AcpSchema.JSONRPCResponse response) { - OutboundRequestRoute expectedRoute = outboundRequestRoutes.get(response.id()); - if (expectedRoute != null) { - Mono processedResponse; - if (!Objects.equals(expectedRoute.responseScope(), actualScope)) { - // Peers differ on which stream carries session/load and session/resume - // replies (the Rust server uses the session stream, TypeScript the - // connection stream). The reply is still ours by id: deliver it. - logger.warn("Response id {} arrived on {} but was expected on {}; delivering it anyway", - response.id(), actualScope, expectedRoute.responseScope()); - processedResponse = expectedRoute.kind() == RequestKind.SESSION_NEW - ? processNewSessionResponse(response) : emitInbound(message); - } - else if (expectedRoute.kind() == RequestKind.SESSION_NEW) { - processedResponse = processNewSessionResponse(response); - } - else { - processedResponse = emitInbound(message); - } - return processedResponse.doFinally(signal -> outboundRequestRoutes.remove(response.id())); - } - return emitInbound(message); - } - - if (message instanceof AcpSchema.JSONRPCRequest request) { - if (request.id() != null) { - inboundRequestRoutes.put(request.id(), actualScope); - } - return emitInbound(message); - } - - return emitInbound(message); - } - - private Mono processNewSessionResponse(AcpSchema.JSONRPCResponse response) { - if (response.error() != null) { - return emitInbound(response); - } - - String sessionId; - try { - AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(), - new TypeRef() { - }); - sessionId = sessionResponse.sessionId(); - } - catch (Exception e) { - return emitInbound(errorResponse(response.id(), "Failed to read session/new response", e)); - } - if (sessionId == null || sessionId.isBlank()) { - return emitInbound(errorResponse(response.id(), "session/new response missing sessionId", null)); - } - return openSessionStream(sessionId) - .then(emitInbound(response)) - .onErrorResume(error -> emitInbound(errorResponse(response.id(), - "Failed to open session SSE stream for session " + sessionId, error))); - } - - private AcpSchema.JSONRPCResponse errorResponse(Object id, String message, Throwable error) { - Object data = error == null ? null : error.getMessage(); - return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, - new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, message, data)); - } - - private Mono emitInbound(JSONRPCMessage message) { - return Mono.fromRunnable(() -> { - synchronized (inboundEmitMonitor) { - Sinks.EmitResult result = inboundSink.tryEmitNext(message); - if (result.isFailure()) { - throw new AcpConnectionException("Failed to enqueue inbound message: " + result); - } - } - }); + return inbound.process(actualScope, message); } + /** + * {@inheritDoc} + *

Closes the SSE streams, then sends {@code DELETE} for the connection, when one was + * opened, and waits at most five seconds for the answer; a server that does not answer + * releases the connection by itself. Completes {@link #awaitTermination()} first. Only the + * first call has an effect. + */ @Override public Mono closeGracefully() { return Mono.defer(() -> { @@ -731,134 +367,41 @@ public Mono closeGracefully() { return Mono.empty(); } terminationSink.tryEmitEmpty(); - Optional.ofNullable(connectionStream).ifPresent(SseStream::close); - sessionStreams.values().forEach(SseStream::close); - - Mono deleteRequest = Mono.empty(); - if (connectionId != null) { - HttpRequest request = newRequest() - .DELETE() - .header(HEADER_CONNECTION_ID, connectionId) - .build(); - deleteRequest = sendAsync(request, HttpResponse.BodyHandlers.discarding()) - .flatMap(response -> { - if (response.statusCode() != 202) { - return Mono.error(new AcpConnectionException( - "Expected 202 for DELETE, got " + response.statusCode())); - } - return Mono.empty(); - }) - // A dead or unresponsive server must not hang shutdown; the connection is - // released server-side by its own close or idle handling. - .timeout(PROBE_TIMEOUT, AcpSchedulers.timeouts()) - .onErrorResume(error -> { - logger.debug("DELETE of connection {} did not complete: {}", connectionId, error.getMessage()); - return Mono.empty(); - }); - } - - return deleteRequest.doFinally(signal -> clearState()); + streams.closeAll(); + return deleteConnection().doFinally(signal -> clearState()); }); } - @Override - public void close() { - closeGracefully().block(CLOSE_TIMEOUT); - } - - private void clearState() { - Optional.ofNullable(connectionStream).ifPresent(SseStream::close); - sessionStreams.values().forEach(SseStream::close); - connectionStream = null; - sessionStreams.clear(); - sessionStreamOpenOperations.clear(); - inboundRequestRoutes.clear(); - outboundRequestRoutes.clear(); - connectionId = null; - inboundSink.tryEmitComplete(); - sseExecutor.shutdownNow(); - httpSignalExecutor.shutdownNow(); - if (ownedHttpExecutor != null) { - ownedHttpExecutor.shutdownNow(); + private Mono deleteConnection() { + String connectionId = requests.connectionId(); + if (connectionId == null) { + return Mono.empty(); } + return requests.deleteConnection(connectionId) + // A dead or unresponsive server must not hang shutdown; the connection is + // released server-side by its own close or idle handling. + .timeout(StreamableHttpRequests.PROBE_TIMEOUT, AcpSchedulers.timeouts()) + .onErrorResume(error -> { + logger.debug("DELETE of connection {} did not complete: {}", connectionId, error.getMessage()); + return Mono.empty(); + }); } - /** Reconnects in a row that delivered nothing before the transport gives up on a stream. */ - private static final int MAX_BARREN_RECONNECTS = 3; - - private static final Duration RECONNECT_BACKOFF = Duration.ofMillis(200); - /** - * A stream closed that this client did not close: the server detached it (backpressure, - * a restart of the proxy in between), or the network dropped it. The server keeps what - * it has not delivered in the stream's mailbox, so reopening loses nothing: reconnect - * with a short backoff. Give up, as before, when the server answers that the connection - * is gone, or after {@value #MAX_BARREN_RECONNECTS} reconnects in a row that delivered no - * event (a server that keeps closing must not cause an endless loop). + * Closes the transport as {@link #closeGracefully()} does, blocking the calling thread for + * up to ten seconds; if the close has not finished by then, it throws an + * {@link IllegalStateException}. */ - private void handleUnexpectedSseClosure(SseStream stream, Throwable error) { - if (closing.get()) { - return; - } - RouteScope scope = stream.scope; - int barren = stream.delivered ? 0 : stream.barrenReconnects + 1; - if (barren > MAX_BARREN_RECONNECTS) { - giveUpOn(stream, error); - return; - } - logger.info("SSE stream closed unexpectedly; reconnecting: {}", scope); - Mono.defer(() -> openSseStream(scope)) - .retryWhen(reactor.util.retry.Retry.backoff(2, RECONNECT_BACKOFF) - .scheduler(AcpSchedulers.timeouts()) - .filter(e -> !isConnectionGone(e))) - .subscribe(reopened -> { - reopened.barrenReconnects = barren; - if (closing.get() || !replaceStream(stream, reopened)) { - reopened.close(); - return; - } - reopened.start(); - logger.info("SSE stream reconnected: {}", scope); - }, reconnectError -> giveUpOn(stream, reconnectError)); - } - - private boolean replaceStream(SseStream old, SseStream reopened) { - if (old.scope.isSession()) { - return sessionStreams.replace(old.scope.sessionId(), old, reopened); - } - synchronized (this) { - if (this.connectionStream != old) { - return false; - } - this.connectionStream = reopened; - return true; - } - } - - /** 404 on reconnect: the server no longer knows this connection or session. */ - private static boolean isConnectionGone(Throwable error) { - return error instanceof AcpConnectionException && String.valueOf(error.getMessage()).contains("got 404"); - } - - /** The old behaviour: a dead connection stream, or a session stream owing a response, ends the transport. */ - private void giveUpOn(SseStream stream, Throwable error) { - if (closing.get()) { - return; - } - RouteScope scope = stream.scope; - if (!scope.isSession() || hasPendingResponseFor(scope)) { - terminateAfterSseFailure(error); - return; - } - if (sessionStreams.remove(scope.sessionId(), stream)) { - sessionStreamOpenOperations.remove(scope.sessionId()); - logger.info("Session SSE stream closed; it will be reopened before the next session request: {}", scope); - } + @Override + public void close() { + closeGracefully().block(CLOSE_TIMEOUT); } - private boolean hasPendingResponseFor(RouteScope scope) { - return outboundRequestRoutes.values().stream() - .anyMatch(route -> Objects.equals(route.responseScope(), scope)); + private void clearState() { + streams.clear(); + routes.clear(); + inbound.complete(); + requests.shutdown(); } private void terminateAfterSseFailure(Throwable error) { @@ -885,132 +428,4 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { return jsonMapper.convertValue(data, typeRef); } - private record ResolvedOutboundRoute(JSONRPCMessage message, RouteScope scope, OutboundRequestRoute requestRoute) { - } - - private Mono> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) { - return Mono.create(sink -> { - CompletableFuture> future = httpClient.sendAsync(request, bodyHandler); - sink.onCancel(() -> future.cancel(true)); - try { - future.whenCompleteAsync((response, error) -> { - if (error != null) { - sink.error(error); - } - else { - sink.success(response); - } - }, httpSignalExecutor).exceptionally(error -> { - // The signal executor rejected the completion callback: the HTTP call - // finished but nobody would have told the caller. - sink.error(error); - return null; - }); - } - catch (RejectedExecutionException e) { - future.cancel(true); - sink.error(e); - } - }); - } - - private class SseStream { - - private final RouteScope scope; - - private final InputStream body; - - private final AtomicBoolean closed = new AtomicBoolean(false); - - private Future readerTask; - - /** Whether this stream carried at least one event; resets the reconnect budget. */ - private volatile boolean delivered; - - /** Reconnects in a row, ending with this stream, that delivered nothing. */ - private volatile int barrenReconnects; - - SseStream(RouteScope scope, InputStream body) { - this.scope = scope; - this.body = body; - } - - void start() { - try { - this.readerTask = sseExecutor.submit(this::readLoop); - } - catch (RejectedExecutionException e) { - close(); - throw new AcpConnectionException("Maximum active SSE streams exceeded: " + maxSseStreams, e); - } - } - - void close() { - if (closed.compareAndSet(false, true)) { - try { - body.close(); - } - catch (IOException ignored) { - } - if (readerTask != null) { - readerTask.cancel(true); - } - } - } - - private void readLoop() { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { - StringBuilder dataBuffer = new StringBuilder(); - String line; - while (!closed.get() && (line = reader.readLine()) != null) { - if (line.isEmpty()) { - dispatchEvent(dataBuffer); - dataBuffer.setLength(0); - continue; - } - if (line.startsWith(":")) { - continue; - } - if (line.startsWith("data:")) { - if (!dataBuffer.isEmpty()) { - dataBuffer.append('\n'); - } - dataBuffer.append(line.substring(5).stripLeading()); - } - } - dispatchEvent(dataBuffer); - if (!closed.get() && !closing.get()) { - throw new AcpConnectionException("SSE stream closed unexpectedly: " + scope); - } - } - catch (Exception e) { - if (!closed.get()) { - handleUnexpectedSseClosure(this, e); - } - } - } - - private void dispatchEvent(StringBuilder dataBuffer) { - if (dataBuffer.isEmpty()) { - return; - } - try { - JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, dataBuffer.toString()); - delivered = true; - processInbound(scope, message).subscribe(v -> { - }, error -> { - if (!closed.get() && !closing.get()) { - logger.warn("Failed to process SSE event from {}", scope, error); - } - }); - } - catch (Exception e) { - if (!closed.get() && !closing.get()) { - logger.warn("Failed to deserialize SSE event from {}", scope, e); - } - } - } - - } - } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportOptions.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportOptions.java index b55bd24a..cf68ca81 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportOptions.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportOptions.java @@ -4,16 +4,64 @@ package com.agentclientprotocol.sdk.client.transport; +import java.util.concurrent.Executor; + import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; /** - * Resource limits for {@link StreamableHttpAcpClientTransport}. + * The resource limits and threads of a {@link StreamableHttpAcpClientTransport}: how many SSE + * streams it may hold open, and which threads its HTTP work runs on. Pass one to the + * transport's constructor when a default does not fit, most often to raise + * {@link #maxSseStreams()} for a client that keeps many ACP sessions open, or to run the + * transport on the application's own threads. Start from {@link #builder()}, or use + * {@link #defaults()}: + * + *

{@code
+ * var options = StreamableHttpAcpClientTransportOptions.builder().maxSseStreams(256).build();
+ * // The transport's work on the application's executor:
+ * var shared = StreamableHttpAcpClientTransportOptions.builder().executor(appExecutor).build();
+ * }
+ * + *

Every limit is fixed, so an agent that opens many sessions or answers slowly cannot make + * the client's SSE streams, threads or queues grow without bound: work beyond a limit fails at + * once instead of waiting. * - *

SSE readers are long-lived tasks, so {@link #maxSseStreams()} limits active - * connection and session streams rather than queueing them indefinitely.

+ *

Where the work runs: with an {@link #executor()}, on that executor, which the transport + * never shuts down. Without one, on JDK 21 and later, on a virtual thread per task + * ({@code acp-streamable-http}), which the transport stops when it closes. In both cases + * {@link #httpWorkerThreads()}, {@link #httpSignalThreads()} and {@link #httpQueueCapacity()} + * do not apply, and {@link #maxSseStreams()} still bounds the streams. Without an executor on + * JDK 17, or with {@link #virtualThreads()} false, the transport creates three bounded pools of + * daemon platform threads, sized by those limits and shut down when it closes: the default HTTP + * client's + * ({@code acp-streamable-http-client}), the one that hands HTTP results to the transport + * ({@code acp-streamable-http-signal}) and the SSE readers ({@code acp-streamable-http-sse}). + * + * @param maxSseStreams the most SSE streams open at once, each read on a thread of its own: + * one for the connection plus one per ACP session in use; a stream beyond it fails with an + * {@link com.agentclientprotocol.sdk.error.AcpConnectionException}; default 64 + * @param httpWorkerThreads the threads of the default HTTP client on JDK 17; they do not apply + * to an HTTP client passed to the transport, with an executor, or on virtual threads; default 8 + * @param httpSignalThreads the threads that hand the results of HTTP calls to the transport + * on JDK 17, so that the HTTP client's own threads are never held by the transport's work; + * they do not apply with an executor or on virtual threads; default 4 + * @param httpQueueCapacity the tasks that each of those two thread pools may queue before it + * refuses more, which fails the HTTP call; default 256 + * @param executor the application's executor that the transport's HTTP work runs on, or null + * (the default) for virtual threads of the transport's own on JDK 21 and later, and bounded + * pools of its own before. Each open SSE stream holds one of its threads + * for as long as the stream is open, and its tasks block, so it must allow blocking and not cap + * its threads below {@code maxSseStreams} plus a few: a virtual-thread executor or a + * framework's worker pool. The application owns it; the transport does not shut it down. It + * also becomes the executor of the default HTTP client, not of one passed to the transport + * @param virtualThreads whether, without an executor, the transport's work runs on virtual + * threads where the JDK has them (21 and later); false keeps it on bounded pools of platform + * threads on every JDK, for an application that has not opted into virtual threads; default + * true */ public record StreamableHttpAcpClientTransportOptions(int maxSseStreams, int httpWorkerThreads, - int httpSignalThreads, int httpQueueCapacity) { + int httpSignalThreads, int httpQueueCapacity, @Nullable Executor executor, boolean virtualThreads) { private static final int DEFAULT_MAX_SSE_STREAMS = 64; @@ -23,6 +71,17 @@ public record StreamableHttpAcpClientTransportOptions(int maxSseStreams, int htt private static final int DEFAULT_HTTP_QUEUE_CAPACITY = 256; + /** + * Creates options with these limits; prefer {@link #builder()}, which starts from the + * defaults. + * @param maxSseStreams the most SSE streams open at once + * @param httpWorkerThreads the threads of the default HTTP client + * @param httpSignalThreads the threads that hand results of HTTP calls to the transport + * @param httpQueueCapacity the tasks each thread pool may queue + * @param executor the executor the transport's HTTP work runs on, or null for its own + * @param virtualThreads whether its own work runs on virtual threads where the JDK has them + * @throws IllegalArgumentException if a limit is not positive + */ public StreamableHttpAcpClientTransportOptions { Assert.isTrue(maxSseStreams > 0, "maxSseStreams must be positive"); Assert.isTrue(httpWorkerThreads > 0, "httpWorkerThreads must be positive"); @@ -31,24 +90,39 @@ public record StreamableHttpAcpClientTransportOptions(int maxSseStreams, int htt } /** - * Returns the default resource limits. - * @return default transport options + * Creates options with these limits and no executor of the application's. + * @param maxSseStreams the most SSE streams open at once + * @param httpWorkerThreads the threads of the default HTTP client + * @param httpSignalThreads the threads that hand results of HTTP calls to the transport + * @param httpQueueCapacity the tasks each thread pool may queue + * @throws IllegalArgumentException if a value is not positive + */ + public StreamableHttpAcpClientTransportOptions(int maxSseStreams, int httpWorkerThreads, int httpSignalThreads, + int httpQueueCapacity) { + this(maxSseStreams, httpWorkerThreads, httpSignalThreads, httpQueueCapacity, null, true); + } + + /** + * Returns the default limits: 64 SSE streams, 8 worker threads, 4 signal threads and a + * queue capacity of 256, and no executor of the application's. + * @return the default options */ public static StreamableHttpAcpClientTransportOptions defaults() { - return new StreamableHttpAcpClientTransportOptions(DEFAULT_MAX_SSE_STREAMS, DEFAULT_HTTP_WORKER_THREADS, - DEFAULT_HTTP_SIGNAL_THREADS, DEFAULT_HTTP_QUEUE_CAPACITY); + return builder().build(); } /** - * Creates a builder initialized with the default resource limits. - * @return options builder + * Returns a builder that starts from the default limits. + * @return a new builder */ public static Builder builder() { return new Builder(); } /** - * Builder for {@link StreamableHttpAcpClientTransportOptions}. + * Builds {@link StreamableHttpAcpClientTransportOptions}, starting from the defaults; set only + * the limits that should differ. Get one from + * {@link StreamableHttpAcpClientTransportOptions#builder()}. */ public static final class Builder { @@ -60,32 +134,87 @@ public static final class Builder { private int httpQueueCapacity = DEFAULT_HTTP_QUEUE_CAPACITY; + private @Nullable Executor executor; + + private boolean virtualThreads = true; + private Builder() { } + /** + * Sets the most SSE streams open at once; default 64. + * @param maxSseStreams the stream limit; positive + * @return this builder + */ public Builder maxSseStreams(int maxSseStreams) { this.maxSseStreams = maxSseStreams; return this; } + /** + * Sets the threads of the default HTTP client on JDK 17 without an executor; default 8. + * @param httpWorkerThreads the thread count; positive + * @return this builder + */ public Builder httpWorkerThreads(int httpWorkerThreads) { this.httpWorkerThreads = httpWorkerThreads; return this; } + /** + * Sets the threads that hand the results of HTTP calls to the transport, on JDK 17 + * without an executor; default 4. + * @param httpSignalThreads the thread count; positive + * @return this builder + */ public Builder httpSignalThreads(int httpSignalThreads) { this.httpSignalThreads = httpSignalThreads; return this; } + /** + * Sets the tasks each of the transport's HTTP thread pools may queue; default 256. + * @param httpQueueCapacity the queue capacity; positive + * @return this builder + */ public Builder httpQueueCapacity(int httpQueueCapacity) { this.httpQueueCapacity = httpQueueCapacity; return this; } + /** + * Sets the application's executor that the transport's HTTP work runs on, in place of + * the pools it would create; the transport does not shut it down. See + * {@link StreamableHttpAcpClientTransportOptions#executor()} for what it must allow. + * @param executor the executor, such as a virtual-thread executor + * @return this builder + */ + public Builder executor(Executor executor) { + Assert.notNull(executor, "executor must not be null"); + this.executor = executor; + return this; + } + + /** + * Sets whether, without an executor, the transport's work runs on virtual threads + * where the JDK has them; default true. False keeps the bounded pools of platform + * threads on every JDK. + * @param virtualThreads false for platform threads + * @return this builder + */ + public Builder virtualThreads(boolean virtualThreads) { + this.virtualThreads = virtualThreads; + return this; + } + + /** + * Returns the options. + * @return the options + * @throws IllegalArgumentException if a value is not positive + */ public StreamableHttpAcpClientTransportOptions build() { return new StreamableHttpAcpClientTransportOptions(maxSseStreams, httpWorkerThreads, httpSignalThreads, - httpQueueCapacity); + httpQueueCapacity, executor, virtualThreads); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInbound.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInbound.java new file mode 100644 index 00000000..056cb0d0 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInbound.java @@ -0,0 +1,124 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.util.concurrent.locks.ReentrantLock; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Delivers the messages a Streamable HTTP client receives to the session, in one ordered + * stream: routes each through {@link StreamableHttpRoutes}, and opens the new session's + * SSE stream before delivering a {@code session/new} response. + */ +final class StreamableHttpInbound { + + private final StreamableHttpRoutes routes; + + private final StreamableHttpStreams streams; + + private final AcpJsonMapper jsonMapper; + + private final Sinks.Many inboundSink = Sinks.many().unicast().onBackpressureBuffer(); + + /* + * A streamable HTTP client may have one connection SSE reader and multiple session + * SSE readers active at the same time. Reactor unicast sinks require serialized + * producers, so every SSE reader emits through this lock. A lock, not a monitor: + * emitting runs the client's handler chain on the reader's thread, a virtual thread on + * JDK 21 and later, and a virtual thread that parks inside a synchronized block pins + * its carrier on JDK 21 to 23. + */ + private final ReentrantLock inboundEmitLock = new ReentrantLock(); + + StreamableHttpInbound(StreamableHttpRoutes routes, StreamableHttpStreams streams, AcpJsonMapper jsonMapper) { + this.routes = routes; + this.streams = streams; + this.jsonMapper = jsonMapper; + } + + /** The messages received, in order; it may be subscribed once. */ + Flux messages() { + return inboundSink.asFlux(); + } + + /** Routes and delivers a message that arrived on the SSE stream of {@code actualScope}. */ + Mono process(RouteScope actualScope, JSONRPCMessage message) { + StreamableHttpRoutes.InboundRoute route = routes.routeInbound(actualScope, message); + Object answeredRequestId = route.answeredRequestId(); + if (answeredRequestId == null) { + return emit(message); + } + Mono delivered = route.newSessionResponse() && message instanceof AcpSchema.JSONRPCResponse response + ? processNewSessionResponse(response) : emit(message); + return delivered.doFinally(signal -> routes.answered(answeredRequestId)); + } + + private Mono processNewSessionResponse(AcpSchema.JSONRPCResponse response) { + if (response.error() != null) { + return emit(response); + } + Object result = response.result(); + if (result == null) { + return emit(errorResponse(response.id(), "session/new response carried no result", null)); + } + String sessionId; + try { + AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(result, + new TypeRef() { + }); + sessionId = sessionResponse.sessionId(); + } + catch (Exception e) { + return emit(errorResponse(response.id(), "Failed to read session/new response", e)); + } + // Required by the schema, but Jackson does not enforce it: the agent can omit it. + if (sessionId == null || sessionId.isBlank()) { + return emit(errorResponse(response.id(), "session/new response missing sessionId", null)); + } + return streams.openSessionStream(sessionId) + .then(emit(response)) + .onErrorResume(error -> emit(errorResponse(response.id(), + "Failed to open session SSE stream for session " + sessionId, error))); + } + + private static AcpSchema.JSONRPCResponse errorResponse(@Nullable Object id, String message, + @Nullable Throwable error) { + Object data = error == null ? null : error.getMessage(); + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, + new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, message, data)); + } + + /** Emits a message into the ordered inbound stream. */ + Mono emit(JSONRPCMessage message) { + return Mono.fromRunnable(() -> { + inboundEmitLock.lock(); + try { + Sinks.EmitResult result = inboundSink.tryEmitNext(message); + if (result.isFailure()) { + throw new AcpConnectionException("Failed to enqueue inbound message: " + result); + } + } + finally { + inboundEmitLock.unlock(); + } + }); + } + + /** Ends the inbound stream. */ + void complete() { + inboundSink.tryEmitComplete(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRequests.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRequests.java new file mode 100644 index 00000000..e01bf79b --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRequests.java @@ -0,0 +1,310 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.InputStream; +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * The HTTP exchanges of a Streamable HTTP client connection: the scope headers, the HTTP + * version settled by the cleartext probe, and the POST, GET and DELETE requests with the + * status and content type each must answer with. Completion signals are delivered on the + * transport's work executor (the application's, or virtual threads on JDK 21 and later), else + * on a bounded executor of their own rather than the HTTP client's. + */ +final class StreamableHttpRequests { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpRequests.class); + + static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; + + static final String HEADER_SESSION_ID = "Acp-Session-Id"; + + static final String CONTENT_TYPE_JSON = "application/json"; + + static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; + + static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5); + + /** + * An HTTP client, the executor the transport's own work runs on, and the executor this + * transport created and so shuts down, if any. + * @param httpClient the client the requests are sent with + * @param ownedExecutor the executor the transport created, shut down when it closes + * @param workExecutor what hands over HTTP results and reads the SSE streams: the + * application's executor, or a virtual-thread executor of the transport's own; null for + * the transport's bounded platform pools (JDK 17) + */ + record HttpClientBundle(HttpClient httpClient, @Nullable ExecutorService ownedExecutor, + @Nullable Executor workExecutor) { + + /** + * The default JDK client: HTTP/2, an internal cookie manager, and the options' + * executor; without one, a virtual thread per task on JDK 21 and later, else a + * bounded pool of daemon threads. + */ + static HttpClientBundle createDefault(StreamableHttpAcpClientTransportOptions options) { + Executor supplied = options.executor(); + ExecutorService virtual = (supplied != null) ? null : ownVirtualExecutor(options); + Executor work = (supplied != null) ? supplied : virtual; + ExecutorService owned = (work != null) ? virtual : boundedExecutor(options.httpWorkerThreads(), + options.httpQueueCapacity(), "acp-streamable-http-client"); + HttpClient client = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .cookieHandler(new CookieManager()) + .executor((work != null) ? work : Objects.requireNonNull(owned)) + .build(); + return new HttpClientBundle(client, owned, work); + } + + /** + * The application's client, with the transport's own work on the options' executor; + * without one, a virtual thread per task on JDK 21 and later, else bounded pools. + */ + static HttpClientBundle of(HttpClient httpClient, StreamableHttpAcpClientTransportOptions options) { + Executor supplied = options.executor(); + ExecutorService virtual = (supplied != null) ? null : ownVirtualExecutor(options); + return new HttpClientBundle(httpClient, virtual, (supplied != null) ? supplied : virtual); + } + + } + + /** A virtual thread per task, unless the options say platform threads or the JDK has none. */ + private static @Nullable ExecutorService ownVirtualExecutor(StreamableHttpAcpClientTransportOptions options) { + return options.virtualThreads() ? VirtualThreads.newPerTaskExecutor("acp-streamable-http") : null; + } + + private final URI endpointUri; + + private final HttpClient httpClient; + + private final @Nullable ExecutorService ownedHttpExecutor; + + /** Delivers the results of HTTP calls: the options' executor, or a bounded pool of our own. */ + private final Executor httpSignalExecutor; + + private final @Nullable ExecutorService ownedSignalExecutor; + + private volatile @Nullable String connectionId; + + /** + * HTTP version pinned for every request after the cleartext probe, or {@code null} to + * use the client's own setting. Set to HTTP/1.1 when an {@code http://} server does not + * speak h2c, so later requests stop carrying {@code Upgrade: h2c}: some servers hand any + * request with an Upgrade header to their WebSocket handler and answer 405 (the + * TypeScript SDK's example server does). + */ + private volatile HttpClient.@Nullable Version pinnedVersion; + + StreamableHttpRequests(URI endpointUri, HttpClientBundle bundle, StreamableHttpAcpClientTransportOptions options) { + this.endpointUri = endpointUri; + this.httpClient = bundle.httpClient(); + this.ownedHttpExecutor = bundle.ownedExecutor(); + Executor work = bundle.workExecutor(); + this.ownedSignalExecutor = (work != null) ? null + : boundedExecutor(options.httpSignalThreads(), options.httpQueueCapacity(), "acp-streamable-http-signal"); + this.httpSignalExecutor = (work != null) ? work : Objects.requireNonNull(this.ownedSignalExecutor); + } + + static ExecutorService boundedExecutor(int threads, int queueCapacity, String threadName) { + return new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(queueCapacity), daemonThreadFactory(threadName), + new ThreadPoolExecutor.AbortPolicy()); + } + + static ThreadFactory daemonThreadFactory(String threadName) { + return runnable -> { + Thread thread = new Thread(runnable, threadName); + thread.setDaemon(true); + return thread; + }; + } + + @Nullable String connectionId() { + return connectionId; + } + + void connectionId(@Nullable String connectionId) { + this.connectionId = connectionId; + } + + /** + * The transport requires HTTP/2 (RFD). Over {@code https} ALPN negotiates it. Over + * cleartext {@code http} the JDK offers an h2c upgrade on every request, but servers + * (Jetty among them) only honour it on a request without a body, and {@code initialize} + * is a POST. A bodiless GET first upgrades the connection when the server speaks + * h2c; every later request reuses it over HTTP/2. When it does not (answer on HTTP/1.1, + * an error, or no answer within five seconds), every later request is pinned to HTTP/1.1. + * A GET rather than OPTIONS because some h2c servers (Hypercorn) upgrade a GET but answer + * OPTIONS with 405 on HTTP/1.1. It carries no connection id, so servers answer it with a + * 4xx without opening a stream; only the negotiated version is used. + */ + Mono upgradeCleartextToHttp2() { + if (!"http".equalsIgnoreCase(endpointUri.getScheme()) || httpClient.version() != HttpClient.Version.HTTP_2) { + return Mono.empty(); + } + HttpRequest probe = HttpRequest.newBuilder(endpointUri).GET().build(); + // Cancelling the Mono on timeout cancels the HTTP exchange (see sendAsync). + return sendAsync(probe, HttpResponse.BodyHandlers.discarding()) + .timeout(PROBE_TIMEOUT, AcpSchedulers.timeouts()) + .doOnNext(response -> { + logger.debug("Cleartext probe to {} negotiated {}", endpointUri, response.version()); + if (response.version() != HttpClient.Version.HTTP_2) { + this.pinnedVersion = HttpClient.Version.HTTP_1_1; + } + }) + .then() + .onErrorResume(error -> { + logger.debug("Cleartext HTTP/2 probe to {} failed ({}); using HTTP/1.1", endpointUri, + error.getMessage()); + this.pinnedVersion = HttpClient.Version.HTTP_1_1; + return Mono.empty(); + }); + } + + /** Re-stamps a request built before the probe with the version the probe settled on. */ + HttpRequest pinned(HttpRequest request) { + HttpClient.Version version = this.pinnedVersion; + if (version == null) { + return request; + } + return HttpRequest.newBuilder(request, (name, value) -> true).version(version).build(); + } + + private HttpRequest.Builder newRequest() { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(endpointUri); + HttpClient.Version version = this.pinnedVersion; + if (version != null) { + requestBuilder.version(version); + } + return requestBuilder; + } + + /** A JSON POST of {@code json} in {@code scope}, not yet sent. */ + HttpRequest jsonPost(RouteScope scope, String json) { + HttpRequest.Builder builder = newRequest().header("Content-Type", CONTENT_TYPE_JSON) + .header("Accept", CONTENT_TYPE_JSON); + addScopeHeaders(builder, scope); + return builder.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)).build(); + } + + /** Posts a message that the server must accept with 202. */ + Mono postAccepted(RouteScope scope, String json) { + return sendAsync(jsonPost(scope, json), HttpResponse.BodyHandlers.discarding()) + .flatMap(response -> expectStatus(response, 202, "for POST")); + } + + /** Opens the SSE stream of {@code scope}; emits its body or an error, never completes empty. */ + Mono openEventStream(RouteScope scope) { + HttpRequest.Builder builder = newRequest().GET().header("Accept", CONTENT_TYPE_EVENT_STREAM); + addScopeHeaders(builder, scope); + return sendAsync(builder.build(), HttpResponse.BodyHandlers.ofInputStream()) + .flatMap(response -> expectStatus(response, 200, "when opening SSE stream") + .then(expectContentType(response, CONTENT_TYPE_EVENT_STREAM, "response")) + .then(Mono.fromSupplier(response::body))); + } + + /** Deletes the connection; the server must accept with 202. */ + Mono deleteConnection(String id) { + HttpRequest request = newRequest().DELETE().header(HEADER_CONNECTION_ID, id).build(); + return sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .flatMap(response -> expectStatus(response, 202, "for DELETE")); + } + + static Mono expectStatus(HttpResponse response, int status, String what) { + if (response.statusCode() != status) { + return Mono.error(new AcpConnectionException( + "Expected " + status + " " + what + ", got " + response.statusCode())); + } + return Mono.empty(); + } + + static Mono expectContentType(HttpResponse response, String expected, String what) { + String contentType = response.headers().firstValue("Content-Type").orElse(""); + if (!contentType.toLowerCase(Locale.ROOT).contains(expected)) { + return Mono.error(new AcpConnectionException("Expected " + expected + " " + what + ", got " + contentType)); + } + return Mono.empty(); + } + + private void addScopeHeaders(HttpRequest.Builder builder, RouteScope scope) { + if (!scope.isBootstrap()) { + builder.header(HEADER_CONNECTION_ID, requireConnectionId()); + } + if (scope.isSession()) { + builder.header(HEADER_SESSION_ID, scope.boundSessionId()); + } + } + + private String requireConnectionId() { + String currentConnectionId = this.connectionId; + if (currentConnectionId == null || currentConnectionId.isBlank()) { + throw new AcpConnectionException("Missing " + HEADER_CONNECTION_ID); + } + return currentConnectionId; + } + + Mono> sendAsync(HttpRequest request, HttpResponse.BodyHandler bodyHandler) { + return Mono.create(sink -> { + CompletableFuture> future = httpClient.sendAsync(request, bodyHandler); + sink.onCancel(() -> future.cancel(true)); + try { + future.whenCompleteAsync((response, error) -> { + if (error != null) { + sink.error(error); + } + else { + sink.success(response); + } + }, httpSignalExecutor).exceptionally(error -> { + // The signal executor rejected the completion callback: the HTTP call + // finished but nobody would have told the caller. + sink.error(error); + return null; + }); + } + catch (RejectedExecutionException e) { + future.cancel(true); + sink.error(e); + } + }); + } + + /** Releases the executors this transport owns; never the application's. */ + void shutdown() { + connectionId = null; + if (ownedSignalExecutor != null) { + ownedSignalExecutor.shutdownNow(); + } + if (ownedHttpExecutor != null) { + ownedHttpExecutor.shutdownNow(); + } + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutes.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutes.java new file mode 100644 index 00000000..0d487121 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutes.java @@ -0,0 +1,300 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.util.Map; +import java.util.Set; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Routing state of a Streamable HTTP client connection: which HTTP scope each outbound + * message is posted in, on which SSE stream the answer to each client request is + * expected, and in which scope the client must answer each agent request. + */ +final class StreamableHttpRoutes { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpRoutes.class); + + /** What a client request is, as far as routing its response goes. */ + enum RequestKind { + + INITIALIZE, + + SESSION_NEW, + + SESSION_LOAD, + + GENERIC + + } + + /** The HTTP scope a method is posted in, before its session id is known. */ + private enum MethodScope { + + /** {@code initialize}: no connection exists yet. */ + BOOTSTRAP, + + /** Posted with {@code Acp-Connection-Id} only, answered on the connection stream. */ + CONNECTION, + + /** Posted with {@code Acp-Session-Id} naming {@code params.sessionId}, answered on that session's stream. */ + SESSION, + + /** + * Posted like {@link #SESSION}, answered on the connection stream: the RFD's load + * family, which the client sends before it holds the session. + */ + SESSION_ANSWERED_ON_CONNECTION + + } + + /** + * Every client-to-agent method of ACP v1, stable and unstable (schema/v1/meta.json, + * meta.unstable.json), and the {@code $/} protocol methods. The transport RFD (Identity + * Model) asks for {@code Acp-Session-Id} on every session-scoped POST; a method is + * session-scoped when its params require a {@code sessionId}, as in the Rust and + * TypeScript SDKs' tables. {@code nes/start} creates its session and is answered on the + * connection, like {@code session/new}. + */ + private static final Map METHOD_ROUTES = Map.ofEntries( + Map.entry(AcpSchema.METHOD_INITIALIZE, MethodScope.BOOTSTRAP), + Map.entry(AcpSchema.METHOD_AUTHENTICATE, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_LOGOUT, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_SESSION_NEW, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_SESSION_LIST, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_PROVIDERS_LIST, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_PROVIDERS_SET, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_PROVIDERS_DISABLE, MethodScope.CONNECTION), + Map.entry("nes/start", MethodScope.CONNECTION), Map.entry("mcp/message", MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_CANCEL_REQUEST, MethodScope.CONNECTION), + Map.entry(AcpSchema.METHOD_SESSION_LOAD, MethodScope.SESSION_ANSWERED_ON_CONNECTION), + Map.entry(AcpSchema.METHOD_SESSION_RESUME, MethodScope.SESSION_ANSWERED_ON_CONNECTION), + Map.entry(AcpSchema.METHOD_SESSION_PROMPT, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_CANCEL, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_SET_MODE, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_CLOSE, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_DELETE, MethodScope.SESSION), + Map.entry(AcpSchema.METHOD_SESSION_FORK, MethodScope.SESSION), Map.entry("nes/suggest", MethodScope.SESSION), + Map.entry("nes/accept", MethodScope.SESSION), Map.entry("nes/reject", MethodScope.SESSION), + Map.entry("nes/close", MethodScope.SESSION), Map.entry("document/didOpen", MethodScope.SESSION), + Map.entry("document/didChange", MethodScope.SESSION), Map.entry("document/didClose", MethodScope.SESSION), + Map.entry("document/didSave", MethodScope.SESSION), Map.entry("document/didFocus", MethodScope.SESSION)); + + /** Where a client request was posted, and where its response is expected. */ + record OutboundRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { + } + + /** + * How to deliver an inbound message. + * @param newSessionResponse the message answers {@code session/new}, whose session stream + * must be opened before the response is delivered + * @param answeredRequestId the id of the client request the message answers, to forget + * once it is delivered, or null + */ + record InboundRoute(boolean newSessionResponse, @Nullable Object answeredRequestId) { + + static final InboundRoute DELIVER = new InboundRoute(false, null); + + } + + private final AcpJsonMapper jsonMapper; + + // Client-originated request id -> where the eventual SSE response is expected. + private final Map outboundRequestRoutes = new ConcurrentHashMap<>(); + + // Agent-originated request id -> HTTP scope required for the later client POST response. + private final Map inboundRequestRoutes = new ConcurrentHashMap<>(); + + StreamableHttpRoutes(AcpJsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + } + + /** + * The scope to post an outbound message in. A client request's expected response route + * is recorded as a side effect. + */ + RouteScope resolveOutbound(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + return responseScope(response); + } + if (message instanceof AcpSchema.JSONRPCRequest request) { + RouteScope requestScope = requestScope(request.method(), request.params()); + if (request.id() != null) { + outboundRequestRoutes.put(request.id(), new OutboundRequestRoute(requestKind(request.method()), + requestScope, responseScope(request.method(), requestScope))); + } + return requestScope; + } + if (message instanceof AcpSchema.JSONRPCNotification notification) { + return requestScope(notification.method(), notification.params()); + } + throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message); + } + + /** The scope of the agent request this client response answers. */ + private RouteScope responseScope(AcpSchema.JSONRPCResponse response) { + if (response.id() == null) { + // The answer to an agent request posted with "id": null, which was never + // routed (a ConcurrentMap holds no null key). + return RouteScope.connection(); + } + RouteScope scope = inboundRequestRoutes.get(response.id()); + if (scope == null) { + throw new AcpConnectionException("Cannot route outbound response with unknown id " + response.id()); + } + return scope; + } + + private RouteScope requestScope(String method, @Nullable Object params) { + MethodScope methodScope = METHOD_ROUTES.get(method); + if (methodScope == null) { + return inferredScope(method, params); + } + return switch (methodScope) { + case BOOTSTRAP -> RouteScope.bootstrap(); + case CONNECTION -> RouteScope.connection(); + case SESSION, SESSION_ANSWERED_ON_CONNECTION -> RouteScope.session(requireSessionId(params, method)); + }; + } + + /** + * A method outside ACP v1, such as an extension method: session-scoped when its params + * name a session, else connection-scoped. + */ + private RouteScope inferredScope(String method, @Nullable Object params) { + Optional sessionId = extractSessionId(params); + logger.debug("Routing method '{}' by its params: {}", method, + sessionId.isPresent() ? "session-scoped" : "connection-scoped"); + return sessionId.map(RouteScope::session).orElseGet(RouteScope::connection); + } + + private static RequestKind requestKind(String method) { + return switch (method) { + case AcpSchema.METHOD_INITIALIZE -> RequestKind.INITIALIZE; + case AcpSchema.METHOD_SESSION_NEW -> RequestKind.SESSION_NEW; + case AcpSchema.METHOD_SESSION_LOAD, AcpSchema.METHOD_SESSION_RESUME -> RequestKind.SESSION_LOAD; + default -> RequestKind.GENERIC; + }; + } + + /** + * Where the response to a client request is expected: on the connection stream for + * {@code session/load} and {@code session/resume}, else in the request's own scope. + */ + private static RouteScope responseScope(String method, RouteScope requestScope) { + return METHOD_ROUTES.get(method) == MethodScope.SESSION_ANSWERED_ON_CONNECTION ? RouteScope.connection() + : requestScope; + } + + private Optional extractSessionId(@Nullable Object params) { + if (params == null) { + return Optional.empty(); + } + Map paramsMap = jsonMapper.convertValue(params, Map.class); + Object sessionId = paramsMap.get("sessionId"); + return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); + } + + private String requireSessionId(@Nullable Object params, String method) { + return extractSessionId(params) + .filter(sessionId -> !sessionId.isBlank()) + .orElseThrow(() -> new AcpConnectionException("Missing sessionId for outbound method " + method)); + } + + /** + * The message is posted on the connection although it was routed to a session the + * server does not know: its reply comes on the connection stream. + */ + void postedIn(JSONRPCMessage message, RouteScope postScope) { + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null + && !postScope.isSession()) { + outboundRequestRoutes.computeIfPresent(request.id(), + (id, route) -> new OutboundRequestRoute(route.kind(), postScope, postScope)); + } + } + + /** The message was posted: an answered agent request needs no route any more. */ + void posted(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response && response.id() != null) { + inboundRequestRoutes.remove(response.id()); + } + } + + /** The message could not be posted: no response to a failed client request will come. */ + void postFailed(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null) { + outboundRequestRoutes.remove(request.id()); + } + } + + /** + * Routes a message that arrived on the SSE stream of {@code actualScope}: an agent + * request records the scope its answer must be posted in; a response is matched to the + * client request it answers. + */ + InboundRoute routeInbound(RouteScope actualScope, JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + return routeResponse(actualScope, response); + } + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null) { + inboundRequestRoutes.put(request.id(), actualScope); + } + return InboundRoute.DELIVER; + } + + private InboundRoute routeResponse(RouteScope actualScope, AcpSchema.JSONRPCResponse response) { + Object responseId = response.id(); + if (responseId == null) { + // JSON-RPC's answer to a request the agent could not parse: no route to match. + return InboundRoute.DELIVER; + } + OutboundRequestRoute expectedRoute = outboundRequestRoutes.get(responseId); + if (expectedRoute == null) { + return InboundRoute.DELIVER; + } + if (!Objects.equals(expectedRoute.responseScope(), actualScope)) { + // Peers differ on which stream carries session/load and session/resume replies + // (the Rust server uses the session stream, TypeScript the connection stream). + // The reply is still ours by id: deliver it. + logger.warn("Response id {} arrived on {} but was expected on {}; delivering it anyway", responseId, + actualScope, expectedRoute.responseScope()); + } + return new InboundRoute(expectedRoute.kind() == RequestKind.SESSION_NEW, responseId); + } + + /** Forgets a client request whose response has been delivered. */ + void answered(Object requestId) { + outboundRequestRoutes.remove(requestId); + } + + /** Whether a client request still awaits its response on the stream of this scope. */ + boolean hasPendingResponseFor(RouteScope scope) { + return outboundRequestRoutes.values() + .stream() + .anyMatch(route -> Objects.equals(route.responseScope(), scope)); + } + + /** The methods routed from {@link #METHOD_ROUTES}; any other method is routed by inference. */ + static Set routedMethods() { + return METHOD_ROUTES.keySet(); + } + + void clear() { + inboundRequestRoutes.clear(); + outboundRequestRoutes.clear(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpStreams.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpStreams.java new file mode 100644 index 00000000..b2438f94 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpStreams.java @@ -0,0 +1,308 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.time.Duration; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; + +/** + * The SSE streams of a Streamable HTTP client connection: one connection stream and one + * stream per session. Opens each at most once at a time, reconnects a stream the server + * or the network closed, and gives up on one that keeps failing. + */ +final class StreamableHttpStreams { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpStreams.class); + + /** Reconnects in a row that delivered nothing before the transport gives up on a stream. */ + private static final int MAX_BARREN_RECONNECTS = 3; + + private static final Duration RECONNECT_BACKOFF = Duration.ofMillis(200); + + private final StreamableHttpRequests requests; + + private final StreamableHttpRoutes routes; + + private final AcpJsonMapper jsonMapper; + + /** Runs the stream readers: the application's executor, or the transport's own pool. */ + private final Executor sseExecutor; + + /** The pool this transport created for the readers, shut down on clear; else null. */ + private final @Nullable ExecutorService ownedSseExecutor; + + /** One per open stream; bounds the streams on whichever executor reads them. */ + private final Semaphore sseStreamPermits; + + private final int maxSseStreams; + + private final SseStream.Listener listener; + + private final Consumer onFailure; + + private final Map sessionStreams = new ConcurrentHashMap<>(); + + // Session id -> shared open operation so callers reuse one GET while opening. + private final Map> sessionStreamOpenOperations = new ConcurrentHashMap<>(); + + private final AtomicReference<@Nullable SseStream> connectionStream = new AtomicReference<>(); + + /** + * What the streams need from the transport that owns them. + * @param inbound delivers each message read from a stream + * @param closing whether the transport is closing + * @param onFailure ends the transport after a stream failure it cannot recover from + * @param onError reports an error the streams recover from, such as a skipped event + * @param answerUnreadable answers a skipped event, posted in the scope of its stream + */ + record Owner(BiFunction> inbound, BooleanSupplier closing, + Consumer onFailure, Consumer onError, + BiConsumer answerUnreadable) { + } + + StreamableHttpStreams(StreamableHttpRequests requests, StreamableHttpRoutes routes, AcpJsonMapper jsonMapper, + int maxSseStreams, @Nullable Executor executor, Owner owner) { + this.requests = requests; + this.routes = routes; + this.jsonMapper = jsonMapper; + this.maxSseStreams = maxSseStreams; + this.sseStreamPermits = new Semaphore(maxSseStreams); + if (executor != null) { + this.sseExecutor = executor; + this.ownedSseExecutor = null; + } + else { + ExecutorService own = new ThreadPoolExecutor(maxSseStreams, maxSseStreams, 0, TimeUnit.MILLISECONDS, + new SynchronousQueue<>(), StreamableHttpRequests.daemonThreadFactory("acp-streamable-http-sse"), + new ThreadPoolExecutor.AbortPolicy()); + this.sseExecutor = own; + this.ownedSseExecutor = own; + } + this.onFailure = owner.onFailure(); + this.listener = new SseStream.Listener() { + + @Override + public Mono onMessage(RouteScope scope, JSONRPCMessage message) { + return owner.inbound().apply(scope, message); + } + + @Override + public void onUnreadable(RouteScope scope, Throwable error, String data) { + owner.onError().accept(error); + owner.answerUnreadable().accept(scope, data); + } + + @Override + public void onUnexpectedClose(SseStream stream, Throwable error) { + handleUnexpectedClosure(stream, error); + } + + @Override + public boolean transportClosing() { + return owner.closing().getAsBoolean(); + } + + }; + } + + Mono openConnectionStream() { + return open(RouteScope.connection()).doOnNext(stream -> { + this.connectionStream.set(stream); + start(stream); + }).then(); + } + + /** + * Opens the session stream a message posted in {@code scope} needs, unless it is open, + * and emits the scope to post the message in: {@code scope}, or the connection when the + * server knows no stream for the session. + */ + Mono prepare(JSONRPCMessage message, RouteScope scope) { + if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) { + // Open the session stream first (the RFD's reconnect order) unless it is already + // open: servers allow one receiver per stream, and the TypeScript and Rust servers + // answer a second GET with 409. + SseStream existing = sessionStreams.get(scope.boundSessionId()); + if (existing != null && !existing.isClosed()) { + return Mono.just(scope); + } + return openSessionStreamIfKnown(scope); + } + if (scope.isSession() && !sessionStreams.containsKey(scope.boundSessionId())) { + return openSessionStreamIfKnown(scope); + } + return Mono.just(scope); + } + + /** + * Opens a session's stream before a message in that session. A server that knows no + * stream for the session answers 404, as the RFD's GET tree says; the Python server does + * for a session/load on a new connection or a session/delete of an unknown id, and also + * answers 404 to a POST naming such a session. The message is then posted on the + * connection, without {@code Acp-Session-Id}, and its reply comes on the connection + * stream. The stream is opened again before the next message in the session. + */ + private Mono openSessionStreamIfKnown(RouteScope scope) { + String sessionId = scope.boundSessionId(); + return openSessionStream(sessionId).thenReturn(scope) + .onErrorResume(StreamableHttpStreams::isConnectionGone, error -> { + logger.debug("Server has no stream for session {}; posting on the connection: {}", sessionId, + error.getMessage()); + return Mono.just(RouteScope.connection()); + }); + } + + Mono openSessionStream(String sessionId) { + return sessionStreamOpenOperations.computeIfAbsent(sessionId, this::createSessionStreamOpenMono); + } + + private Mono createSessionStreamOpenMono(String sessionId) { + AtomicReference<@Nullable Mono> operation = new AtomicReference<>(); + Mono openOperation = open(RouteScope.session(sessionId)) + .doOnNext(stream -> register(sessionId, stream)) + .then() + .doFinally(signal -> sessionStreamOpenOperations.remove(sessionId, operation.get())) + .cache(); + operation.set(openOperation); + return openOperation; + } + + /** Starts a newly opened session stream, or closes it when another one won the race. */ + private void register(String sessionId, SseStream stream) { + SseStream existing = sessionStreams.putIfAbsent(sessionId, stream); + if (existing != null) { + stream.close(); + return; + } + try { + start(stream); + } + catch (RuntimeException e) { + // Otherwise a later request for this session would skip the reopen and post into + // a stream that never reads. + sessionStreams.remove(sessionId, stream); + throw e; + } + } + + /** Emits the opened stream or an error; it never completes empty. */ + private Mono open(RouteScope scope) { + return requests.openEventStream(scope).map(body -> new SseStream(scope, body, jsonMapper, listener)); + } + + private void start(SseStream stream) { + stream.start(sseExecutor, sseStreamPermits, maxSseStreams); + } + + /** + * A stream closed that this client did not close: the server detached it (backpressure, + * a restart of the proxy in between), or the network dropped it. The server keeps what + * it has not delivered in the stream's mailbox, so reopening loses nothing: reconnect + * with a short backoff. Give up, as before, when the server answers that the connection + * is gone, or after {@value #MAX_BARREN_RECONNECTS} reconnects in a row that delivered no + * event (a server that keeps closing must not cause an endless loop). + */ + private void handleUnexpectedClosure(SseStream stream, Throwable error) { + if (listener.transportClosing()) { + return; + } + RouteScope scope = stream.scope(); + int barren = stream.delivered() ? 0 : stream.barrenReconnects() + 1; + if (barren > MAX_BARREN_RECONNECTS) { + giveUpOn(stream, error); + return; + } + logger.info("SSE stream closed unexpectedly; reconnecting: {}", scope); + Mono.defer(() -> open(scope)) + .retryWhen(Retry.backoff(2, RECONNECT_BACKOFF) + .scheduler(AcpSchedulers.timeouts()) + .filter(e -> !isConnectionGone(e))) + .subscribe(reopened -> { + reopened.barrenReconnects(barren); + if (listener.transportClosing() || !replace(stream, reopened)) { + reopened.close(); + return; + } + start(reopened); + logger.info("SSE stream reconnected: {}", scope); + }, reconnectError -> giveUpOn(stream, reconnectError)); + } + + private boolean replace(SseStream old, SseStream reopened) { + if (old.scope().isSession()) { + return sessionStreams.replace(old.scope().boundSessionId(), old, reopened); + } + return connectionStream.compareAndSet(old, reopened); + } + + /** 404: the server does not know this connection or session. */ + private static boolean isConnectionGone(Throwable error) { + // AcpException.getMessage() is non-null, unlike Throwable's. + return error instanceof AcpConnectionException connectionError + && connectionError.getMessage().contains("got 404"); + } + + /** + * A dead connection stream, or a session stream owing a response, ends the transport; any + * other session stream is dropped and reopened before the next session request. + */ + private void giveUpOn(SseStream stream, Throwable error) { + if (listener.transportClosing()) { + return; + } + RouteScope scope = stream.scope(); + if (!scope.isSession() || routes.hasPendingResponseFor(scope)) { + onFailure.accept(error); + return; + } + if (sessionStreams.remove(scope.boundSessionId(), stream)) { + sessionStreamOpenOperations.remove(scope.boundSessionId()); + logger.info("Session SSE stream closed; it will be reopened before the next session request: {}", scope); + } + } + + /** Closes every open stream; they stay registered until {@link #clear()}. */ + void closeAll() { + Optional.ofNullable(connectionStream.get()).ifPresent(SseStream::close); + sessionStreams.values().forEach(SseStream::close); + } + + /** Closes and forgets every stream and stops the reader threads the transport owns. */ + void clear() { + Optional.ofNullable(connectionStream.getAndSet(null)).ifPresent(SseStream::close); + sessionStreams.values().forEach(SseStream::close); + sessionStreams.clear(); + sessionStreamOpenOperations.clear(); + if (ownedSseExecutor != null) { + ownedSseExecutor.shutdownNow(); + } + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java index c8d424ac..181b1f42 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransport.java @@ -5,22 +5,30 @@ package com.agentclientprotocol.sdk.client.transport; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.WebSocket; import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Function; +import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.spec.AcpClientTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.OutboundSinks; +import com.agentclientprotocol.sdk.util.SerialExecutor; +import com.agentclientprotocol.sdk.util.VirtualThreads; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; @@ -29,21 +37,38 @@ import reactor.core.scheduler.Schedulers; /** - * Implementation of the ACP WebSocket transport for clients that communicates with an - * agent using WebSocket connections. Uses the JDK 11+ {@link java.net.http.WebSocket} API. + * The client side of the WebSocket transport: talks to an agent that is already running behind + * a WebSocket endpoint, one ACP message per text frame. Use it to reach a remote agent that + * accepts WebSocket upgrades, such as the {@code acp-streamable-http-jetty} module's listener + * at {@code ws://host:port/acp}; for an agent served by a servlet in a container, which does + * not accept WebSocket upgrades, use {@link StreamableHttpAcpClientTransport}, and to start the + * agent as a child process, {@link StdioAcpClientTransport}. Pass it to + * {@code AcpClient.sync(transport)} or {@code AcpClient.async(transport)}: * - *

- * Messages are exchanged as JSON-RPC messages over WebSocket text frames. - *

+ *
{@code
+ * var transport = new WebSocketAcpClientTransport(URI.create("ws://localhost:8080/acp"),
+ *         AcpJsonMapper.createDefault());
+ * AcpSyncClient client = AcpClient.sync(transport).build();
+ * }
* - *

- * Key features: - *

    - *
  • Zero external dependencies (uses JDK built-in WebSocket)
  • - *
  • Thread-safe message processing with dedicated schedulers
  • - *
  • Proper resource management and graceful shutdown
  • - *
  • Backpressure support via Reactor Sinks
  • - *
+ *

Unlike the stdio transport, it opens a network connection with the JDK's + * {@link java.net.http.WebSocket} when {@link #connect} runs (building the client does it), and + * it has no process to stop: {@link #closeGracefully()} sends a normal close frame without + * waiting for the agent's. If the connection cannot be opened, the client's requests fail with + * that error. Messages sent before the connection is open wait for it. A frame that is not a + * JSON-RPC message is reported to the exception handler, answered with a JSON-RPC error and + * skipped. When the agent closes the connection, or it fails, {@link #awaitTermination()} ends + * and the client's pending requests fail. + * + *

The transport is thread-safe: messages may be sent from any thread, and a writer sends + * them one frame at a time. On JDK 21 and later the transport's HTTP client and its writer run + * on a virtual thread per task ({@code acp-ws-client}). Before, the HTTP client runs on a pool + * of daemon threads named {@code acp-ws-client}, and the writer on a daemon thread of its own + * ({@code acp-ws-client-outbound}). Given an executor of the application's + * ({@link #WebSocketAcpClientTransport(URI, AcpJsonMapper, Executor)}), both run on it instead, + * and the transport creates no thread of its own; a JDK {@code HttpClient} still keeps one + * selector thread. With an {@code HttpClient} of the application's, the writer keeps its own + * daemon thread. * * @author Mark Pollack */ @@ -51,15 +76,24 @@ public class WebSocketAcpClientTransport implements AcpClientTransport { private static final Logger logger = LoggerFactory.getLogger(WebSocketAcpClientTransport.class); - /** Default path for ACP WebSocket endpoints */ + /** + * The endpoint path on which the SDK's listener accepts WebSocket upgrades by default: + * {@value}. The transport does not add it; the URI must include the path. + */ public static final String DEFAULT_ACP_PATH = "/acp"; private final URI serverUri; + /** The endpoint as logged: without its query, which can carry a token, or user information. */ + private final String loggedUri; + private final AcpJsonMapper jsonMapper; private final HttpClient httpClient; + /** The executor of the HTTP client this transport created itself; shut down on close. */ + private final @Nullable ExecutorService ownExecutor; + private final Sinks.Many inboundSink; private final Sinks.Many outboundSink; @@ -68,69 +102,167 @@ public class WebSocketAcpClientTransport implements AcpClientTransport { private final Sinks.One terminationSink = Sinks.one(); - private WebSocket webSocket; + /** Set once the WebSocket opens; null before connect. */ + private volatile @Nullable WebSocket webSocket; - private Scheduler outboundScheduler; + private final Scheduler outboundScheduler; private final AtomicBoolean isClosing = new AtomicBoolean(false); private final AtomicBoolean isConnected = new AtomicBoolean(false); + /** Set by the first {@link #closeGracefully()}: only that call closes. */ + private final AtomicBoolean closeStarted = new AtomicBoolean(false); + + /** Completes once the first close has finished. */ + private final Sinks.Empty closed = Sinks.empty(); + private Consumer exceptionHandler = t -> logger.error("Transport error", t); private Duration connectTimeout = Duration.ofSeconds(30); /** - * Creates a new WebSocketAcpClientTransport with the specified server URI and JsonMapper. - * @param serverUri The WebSocket URI to connect to (e.g., "ws://localhost:8080/acp") - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization + * Creates a transport for the WebSocket endpoint at {@code serverUri}, with an HTTP client + * of its own and the default JSON mapper ({@link AcpJsonMapper#createDefault()}). + * @param serverUri the agent's endpoint: a {@code ws} or {@code wss} URI including its + * path, such as {@code ws://localhost:8080/acp} + * @throws IllegalArgumentException if {@code serverUri} is null + */ + public WebSocketAcpClientTransport(URI serverUri) { + this(serverUri, AcpJsonMapper.createDefault()); + } + + /** + * Creates a transport for the WebSocket endpoint at {@code serverUri}, with an HTTP client + * of its own. + * @param serverUri the agent's endpoint: a {@code ws} or {@code wss} URI including its + * path, such as {@code ws://localhost:8080/acp} + * @param jsonMapper the mapper that reads and writes the messages + * @throws IllegalArgumentException if an argument is null */ public WebSocketAcpClientTransport(URI serverUri, AcpJsonMapper jsonMapper) { - this(serverUri, jsonMapper, HttpClient.newBuilder() - .executor(Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r, "acp-ws-client"); - t.setDaemon(true); - return t; - })) - .build()); + this(serverUri, jsonMapper, defaultExecutor(), true); + } + + /** A virtual thread per task on JDK 21 and later, else a cached pool of daemon threads. */ + private static ExecutorService defaultExecutor() { + ExecutorService virtual = VirtualThreads.newPerTaskExecutor("acp-ws-client"); + if (virtual != null) { + return virtual; + } + return Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-ws-client"); + t.setDaemon(true); + return t; + }); + } + + /** + * Creates a transport for the WebSocket endpoint at {@code serverUri} that runs on the + * application's {@code executor}: the HTTP client it creates runs there, and so does the + * writer, one frame at a time, so the transport starts no thread of its own beyond the + * one selector thread every JDK {@code HttpClient} has. The transport does not shut the + * executor down. The writer waits for each frame to be sent, so the executor must allow + * blocking: a virtual-thread executor or a framework's worker pool. + * @param serverUri the agent's endpoint: a {@code ws} or {@code wss} URI including its + * path + * @param jsonMapper the mapper that reads and writes the messages + * @param executor the executor the transport runs on; the application owns it + * @throws IllegalArgumentException if an argument is null + */ + public WebSocketAcpClientTransport(URI serverUri, AcpJsonMapper jsonMapper, Executor executor) { + this(serverUri, jsonMapper, executor, false); + } + + private WebSocketAcpClientTransport(URI serverUri, AcpJsonMapper jsonMapper, Executor executor, boolean owned) { + this(serverUri, jsonMapper, HttpClient.newBuilder().executor(requireExecutor(executor)).build(), + writerExecutor(executor, owned), owned ? (ExecutorService) executor : null); + } + + /** + * Where frames are written: the application's executor, or the transport's own when it + * starts virtual threads; null for a platform thread of the writer's own. + */ + private static @Nullable Executor writerExecutor(Executor executor, boolean owned) { + return (!owned || VirtualThreads.isSupported()) ? executor : null; } /** - * Creates a new WebSocketAcpClientTransport with custom HttpClient. - * @param serverUri The WebSocket URI to connect to - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization - * @param httpClient The HttpClient to use for WebSocket connections + * Creates a transport for the WebSocket endpoint at {@code serverUri} that opens its + * connection with {@code httpClient}, for TLS, proxy or authentication settings of your + * own. The transport does not close the client or its executor; it writes frames on a + * daemon thread of its own. + * @param serverUri the agent's endpoint: a {@code ws} or {@code wss} URI including its + * path + * @param jsonMapper the mapper that reads and writes the messages + * @param httpClient the client the WebSocket connection is opened with + * @throws IllegalArgumentException if an argument is null */ public WebSocketAcpClientTransport(URI serverUri, AcpJsonMapper jsonMapper, HttpClient httpClient) { + this(serverUri, jsonMapper, httpClient, null, null); + } + + private WebSocketAcpClientTransport(URI serverUri, AcpJsonMapper jsonMapper, HttpClient httpClient, + @Nullable Executor writerExecutor, @Nullable ExecutorService ownExecutor) { Assert.notNull(serverUri, "The serverUri can not be null"); Assert.notNull(jsonMapper, "The JsonMapper can not be null"); Assert.notNull(httpClient, "The HttpClient can not be null"); this.serverUri = serverUri; + this.loggedUri = withoutQueryOrUserInfo(serverUri); this.jsonMapper = jsonMapper; this.httpClient = httpClient; + this.ownExecutor = ownExecutor; this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); - // Use daemon thread so JVM can exit if closeGracefully() isn't called - this.outboundScheduler = Schedulers.fromExecutorService( - Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "acp-ws-client-outbound"); - t.setDaemon(true); - return t; - }), "ws-client-outbound"); + if (writerExecutor != null) { + // The application's executor: disposing this scheduler leaves it running. + this.outboundScheduler = Schedulers.fromExecutor(new SerialExecutor(writerExecutor)); + } + else { + // Use daemon thread so JVM can exit if closeGracefully() isn't called + this.outboundScheduler = Schedulers.fromExecutorService( + Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "acp-ws-client-outbound"); + t.setDaemon(true); + return t; + }), "ws-client-outbound"); + } + } + + private static String withoutQueryOrUserInfo(URI uri) { + try { + return new URI(uri.getScheme(), null, uri.getHost(), uri.getPort(), uri.getPath(), null, null).toString(); + } + catch (URISyntaxException e) { + return uri.getScheme() + "://" + uri.getHost(); + } + } + + private static Executor requireExecutor(Executor executor) { + Assert.notNull(executor, "The executor can not be null"); + return executor; } /** - * Sets the connection timeout for WebSocket establishment. - * @param timeout The connection timeout - * @return This transport for chaining + * Sets how long {@link #connect} waits for the WebSocket handshake to finish; default 30 + * seconds. Call it before connecting. + * @param timeout the handshake timeout; positive + * @return this transport */ public WebSocketAcpClientTransport connectTimeout(Duration timeout) { this.connectTimeout = timeout; return this; } + /** + * {@inheritDoc} + *

Opens the WebSocket connection when the returned Mono is subscribed, and completes + * once the handshake has finished. A second call fails with an + * {@link IllegalStateException} unless the first one failed: a connect that failed may be + * tried again on the same transport, before it is closed. + */ @Override public Mono connect(Function, Mono> handler) { if (!isConnected.compareAndSet(false, true)) { @@ -138,24 +270,23 @@ public Mono connect(Function, Mono> h } return Mono.fromFuture(() -> { - logger.info("Connecting to WebSocket server at {}", serverUri); - - // Set up inbound message handling - handleIncomingMessages(handler); + logger.info("Connecting to WebSocket server at {}", loggedUri); - // Build WebSocket connection with listener - CompletableFuture wsFuture = httpClient.newWebSocketBuilder() + // Build WebSocket connection with listener; frames that arrive before the handshake + // completes wait in the inbound sink. + return httpClient.newWebSocketBuilder() .connectTimeout(connectTimeout) .buildAsync(serverUri, new AcpWebSocketListener()); - - return wsFuture; }).doOnSuccess(ws -> { this.webSocket = ws; + // Only an open connection takes the inbound sink's one subscriber, so a connect that + // failed can be tried again. + handleIncomingMessages(handler); startOutboundProcessing(); connectionReady.tryEmitValue(null); - logger.info("Connected to WebSocket server at {}", serverUri); + logger.info("Connected to WebSocket server at {}", loggedUri); }).doOnError(e -> { - logger.error("Failed to connect to WebSocket server at {}", serverUri, e); + logger.error("Failed to connect to WebSocket server at {}", loggedUri, e); isConnected.set(false); exceptionHandler.accept(e); }).doOnCancel(() -> { @@ -165,32 +296,16 @@ public Mono connect(Function, Mono> h } private void handleIncomingMessages(Function, Mono> handler) { - this.inboundSink.asFlux() - .flatMap(message -> Mono.just(message).transform(handler)) - .doOnNext(response -> { - if (response != null) { - // Responses are emitted from the inbound thread while sendMessage emits - // from user threads on the same sink; both must go through the serialising - // busy-loop or a collision drops the response (FAIL_NON_SERIALIZED, #14). - try { - this.outboundSink.emitNext(response, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - } - catch (Sinks.EmissionException e) { - logger.error("Dropped response {}: {}", response, e.getReason()); - } - } - }) - .doOnTerminate(() -> { - this.outboundSink.tryEmitComplete(); - }) - .subscribe(); + OutboundSinks.replyThrough(this.inboundSink, handler, this.outboundSink, () -> { + }); } private void startOutboundProcessing() { this.outboundSink.asFlux() .publishOn(outboundScheduler) .subscribe(message -> { - if (message != null && !isClosing.get() && webSocket != null) { + WebSocket webSocket = this.webSocket; + if (!isClosing.get() && webSocket != null) { try { String jsonMessage = jsonMapper.writeValueAsString(message); logger.debug("Sending WebSocket message ({} characters)", jsonMessage.length()); @@ -203,48 +318,101 @@ private void startOutboundProcessing() { } } } - }); + }, error -> logger.debug("Outbound processing ended: {}", error.toString())); } + /** + * {@inheritDoc} + *

Waits until the connection is open, then queues the message for the writer thread; + * the Mono completes once it is queued. A frame that cannot be sent is reported to the + * exception handler, not to the Mono. Once the transport is closed, the Mono fails with an + * {@link AcpConnectionException}. + */ @Override public Mono sendMessage(JSONRPCMessage message) { return connectionReady.asMono().then(Mono.defer(() -> { - outboundSink.emitNext(message, - Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); + if (isClosing.get()) { + return Mono.error(new AcpConnectionException("The transport is closed")); + } + try { + OutboundSinks.emit(outboundSink, message); + } + catch (Sinks.EmissionException e) { + return Mono.error(OutboundSinks.isClosed(e) ? new AcpConnectionException("The transport is closed", e) + : new AcpConnectionException("The message could not be queued: " + e.getReason(), e)); + } return Mono.empty(); })); } + /** + * {@inheritDoc} + *

Stops delivering and sending messages, completes {@link #awaitTermination()}, sends a + * normal close frame (1000) when the connection is open, and stops the writer and, if the + * transport created its own HTTP client, that client's threads; an executor of the + * application's keeps running. It does not wait for the + * agent's close frame. Only the first call closes; a later call completes when that close has + * finished. + */ @Override public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - logger.debug("WebSocket transport closing gracefully"); - isClosing.set(true); - inboundSink.tryEmitComplete(); - outboundSink.tryEmitComplete(); - terminationSink.tryEmitEmpty(); - }).then(Mono.defer(() -> { - if (webSocket != null) { - return Mono.fromFuture(webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Client closing") - .thenApply(ws -> null)); + return Mono.defer(() -> { + if (this.closeStarted.compareAndSet(false, true)) { + close(Mono.fromRunnable(this::stopMessages).then(Mono.defer(this::sendClose))); } - return Mono.empty(); - })).then(Mono.fromRunnable(() -> { + return this.closed.asMono(); + }); + } + + private void close(Mono closing) { + closing.doFinally(signal -> { try { outboundScheduler.dispose(); + if (this.ownExecutor != null) { + // The HTTP client this transport created: its idle threads would otherwise + // linger for a minute after close. + this.ownExecutor.shutdown(); + } logger.debug("WebSocket transport closed"); } catch (Exception e) { logger.error("Error during graceful shutdown", e); } - })); + this.closed.tryEmitEmpty(); + }).subscribe(ignored -> { + }, error -> logger.debug("WebSocket close frame not sent: {}", error.getMessage())); + } + + private void stopMessages() { + logger.debug("WebSocket transport closing gracefully"); + isClosing.set(true); + inboundSink.tryEmitComplete(); + outboundSink.tryEmitComplete(); + terminationSink.tryEmitEmpty(); + } + + private Mono sendClose() { + WebSocket webSocket = this.webSocket; + if (webSocket == null || webSocket.isOutputClosed()) { + return Mono.empty(); + } + return Mono.fromFuture(webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Client closing")).then(); } + /** + * {@inheritDoc} + *

The default logs each error at ERROR. Call it before {@link #connect}. + */ @Override public void setExceptionHandler(Consumer handler) { this.exceptionHandler = handler; } + /** + * {@inheritDoc} + *

It completes when either side closes the connection, and errors with the cause when + * the connection fails. + */ @Override public Mono awaitTermination() { return terminationSink.asMono(); @@ -288,8 +456,9 @@ public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean } catch (Exception e) { if (!isClosing.get()) { - logger.error("Error processing inbound message", e); + logger.error("Skipped an inbound message that is not a JSON-RPC message", e); exceptionHandler.accept(e); + answerUnreadable(message); } } } @@ -298,6 +467,20 @@ public CompletionStage onText(WebSocket webSocket, CharSequence data, boolean return CompletableFuture.completedFuture(null); } + /** + * Answers a message that could not be read with the JSON-RPC error for it (its id + * is unknown, so null): the agent may send requests, so this side is the server + * for them. + */ + private void answerUnreadable(String message) { + try { + OutboundSinks.emit(outboundSink, AcpSchema.unreadableMessageResponse(jsonMapper, message)); + } + catch (Sinks.EmissionException emission) { + logger.error("Failed to answer an unreadable inbound message", emission); + } + } + @Override public CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { logger.info("WebSocket connection closed: {} - {}", statusCode, reason); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/package-info.java new file mode 100644 index 00000000..36b9342c --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/client/transport/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Client-side transports: stdio (launching an agent process), WebSocket and Streamable HTTP. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.client.transport; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpCapabilityException.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpCapabilityException.java index 3d6b041c..61acb46b 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpCapabilityException.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpCapabilityException.java @@ -5,39 +5,59 @@ package com.agentclientprotocol.sdk.error; /** - * Exception thrown when attempting to use a capability that the peer does not support. + * Thrown when a call needs a capability the peer did not advertise in the {@code initialize} + * exchange, so the SDK refuses it without sending anything. ACP lets a side call an optional method + * only when the other side advertised it; check + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities} first to avoid it. + * {@link #getCapability()} names the missing capability. * - *

- * This exception is thrown during capability negotiation when: + *

Where the SDK raises it: *

    - *
  • An agent tries to call {@code fs/read_text_file} but the client didn't advertise - * {@code fs.readTextFile} capability
  • - *
  • An agent tries to use terminal features but the client didn't advertise - * {@code terminal} capability
  • - *
  • A client tries to send image content but the agent didn't advertise - * {@code promptCapabilities.image} capability
  • + *
  • On a client, the calls the agent must advertise: {@code loadSession}, {@code listSessions}, + * {@code closeSession}, {@code deleteSession}, {@code resumeSession}, {@code logout} and the + * unstable fork and provider calls. The capabilities are {@code loadSession}, + * {@code sessionCapabilities.list} and its siblings, and {@code auth.logout}. Also a + * {@code session/new}, {@code session/load}, {@code session/resume} or {@code session/fork} that + * names additional directories, which needs {@code sessionCapabilities.additionalDirectories}.
  • + *
  • On an agent, the requests to the client the client must advertise: reading and writing files + * ({@code fs.readTextFile}, {@code fs.writeTextFile}), terminals ({@code terminal}) and elicitation + * ({@code elicitation.form} or {@code elicitation.url} for the mode asked). Before the client's + * {@code initialize} has arrived nothing is known, and the agent sends the request.
  • + *
  • The {@code requireX()} methods of {@code NegotiatedCapabilities}.
  • *
* - *

- * Example usage: + *

On the asynchronous API the call's {@code Mono} fails with it; the sync API throws it. The SDK + * does not check prompt content against {@code promptCapabilities}. + * + *

It is the caller's failure and is never sent. The one case where a peer must answer a request + * for a capability that was not advertised is ACP's: a client asked for an elicitation in a mode + * it did not declare answers {@code -32602} (invalid params), which the SDK's client does. A + * handler with a similar case throws an {@link AcpProtocolException} with + * {@link AcpErrorCodes#INVALID_PARAMS}. + * + *

Example: *

{@code
  * try {
- *     agent.readTextFile(request);
- * } catch (AcpCapabilityException e) {
- *     logger.warn("Client doesn't support file reading: {}", e.getCapability());
+ *     client.loadSession(new AcpSchema.LoadSessionRequest(sessionId, "/workspace", List.of()));
+ * }
+ * catch (AcpCapabilityException e) {
+ *     client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()));
  * }
  * }
* * @author Mark Pollack - * @see AcpErrorCodes#CAPABILITY_NOT_SUPPORTED + * @see com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities */ public class AcpCapabilityException extends AcpException { + /** The missing capability, as its path in the ACP capabilities. */ private final String capability; /** - * Constructs a new capability exception for the specified capability. - * @param capability the capability that is not supported (e.g., "fs.readTextFile") + * Creates the exception for a capability, with the message + * {@code "Capability not supported by peer: "}. + * @param capability the missing capability, as its path in the ACP capabilities, such as + * {@code "fs.readTextFile"} */ public AcpCapabilityException(String capability) { super(formatMessage(capability)); @@ -45,9 +65,9 @@ public AcpCapabilityException(String capability) { } /** - * Constructs a new capability exception with a custom message. - * @param capability the capability that is not supported - * @param message a custom error message + * Creates the exception for a capability, with a message of your own. + * @param capability the missing capability, as its path in the ACP capabilities + * @param message the message */ public AcpCapabilityException(String capability, String message) { super(message); @@ -55,21 +75,14 @@ public AcpCapabilityException(String capability, String message) { } /** - * Returns the name of the unsupported capability. - * @return the capability name (e.g., "fs.readTextFile", "terminal") + * Returns the missing capability, as its path in the ACP capabilities. + * @return the capability, such as {@code "fs.readTextFile"}, {@code "sessionCapabilities.list"} + * or {@code "elicitation.form"} */ public String getCapability() { return capability; } - /** - * Converts this exception to a JSON-RPC protocol exception. - * @return an AcpProtocolException with the appropriate error code - */ - public AcpProtocolException toProtocolException() { - return new AcpProtocolException(AcpErrorCodes.CAPABILITY_NOT_SUPPORTED, getMessage(), capability); - } - private static String formatMessage(String capability) { return String.format("Capability not supported by peer: %s", capability); } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpConnectionException.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpConnectionException.java index a08cb7a0..4648b655 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpConnectionException.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpConnectionException.java @@ -5,25 +5,31 @@ package com.agentclientprotocol.sdk.error; /** - * Exception thrown when there is a connection or transport error. + * Raised when a message cannot be carried because the connection is gone, on the client and the + * agent side alike: a request still waiting for its answer when the connection ends fails with it + * ({@code "ACP session with agent terminated"}), and so does a request or notification sent + * afterwards ({@code "ACP client transport is not connected: ..."}) or after the transport closed + * ({@code "The transport is closed"}). It fails the {@code Mono} on the asynchronous API and is + * thrown by the sync API. Its cause, when there is one, is why the connection ended, such as the + * transport's own failure. Catch it around a call to notice a connection that is gone; a closed + * transport does not open again, so connect a new transport and client or agent. * - *

- * This exception is thrown for transport-level issues such as: - *

    - *
  • Failed to establish connection to the agent
  • - *
  • Connection was closed unexpectedly
  • - *
  • Transport timeout
  • - *
  • I/O errors during communication
  • - *
+ *

Transports also raise it when a message cannot be queued, when a stdio agent sends a request + * after the client closed the agent's input, for Streamable HTTP and WebSocket protocol failures, + * and as the error of {@code awaitTermination()} when the connection ends, for example + * {@code "ACP agent process exited with code 137 (signal 9)"} from a stdio client. * - *

- * Example usage: + *

Building a client or agent on a transport that refuses to connect or start at once, such as + * one already in use, fails with an {@link IllegalStateException} instead: that is a misuse, not a + * lost connection. + * + *

Example: *

{@code
  * try {
- *     client.initialize(request).block();
- * } catch (AcpConnectionException e) {
- *     logger.error("Failed to connect to agent", e);
- *     // Handle reconnection or notify user
+ *     client.sendExtNotification("_example.com/file_opened", Map.of("path", path));
+ * }
+ * catch (AcpConnectionException e) {
+ *     // the transport is closed: start a new transport and client to go on
  * }
  * }
* @@ -32,25 +38,25 @@ public class AcpConnectionException extends AcpException { /** - * Constructs a new connection exception with the specified message. - * @param message the detail message + * Creates the exception with this message. + * @param message what failed */ public AcpConnectionException(String message) { super(message); } /** - * Constructs a new connection exception with the specified message and cause. - * @param message the detail message - * @param cause the underlying cause + * Creates the exception with this message and cause. + * @param message what failed + * @param cause the underlying failure, such as an {@code IOException} */ public AcpConnectionException(String message, Throwable cause) { super(message, cause); } /** - * Constructs a new connection exception with the specified cause. - * @param cause the underlying cause + * Creates the exception for this cause; the message is the cause's description. + * @param cause the underlying failure */ public AcpConnectionException(Throwable cause) { super(cause); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpErrorCodes.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpErrorCodes.java index db71b99f..2c513be5 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpErrorCodes.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpErrorCodes.java @@ -4,16 +4,20 @@ package com.agentclientprotocol.sdk.error; +import java.util.Map; + /** - * Standard JSON-RPC 2.0 error codes used by ACP. + * The JSON-RPC error codes ACP defines, as constants: use them to answer a request with an error by + * throwing an {@link AcpProtocolException} from a handler, and to compare with + * {@link com.agentclientprotocol.sdk.spec.AcpError#getCode()} when a request you sent failed. + * {@link #getDescription(int)} gives a code's short name for messages and logs. * - *

- * The error codes follow the JSON-RPC 2.0 specification: - *

    - *
  • -32700 to -32600: Parse/transport errors
  • - *
  • -32603 to -32600: Request errors
  • - *
  • -32099 to -32000: Server/implementation errors (reserved for ACP)
  • - *
+ *

Every constant is a code from the ACP v1 schema ({@code $defs.ErrorCode}): the JSON-RPC 2.0 + * standard codes, {@code -32800} (request cancelled), and the two codes ACP defines in its reserved + * range {@code -32000..-32099}: {@code -32000} (authentication required) and {@code -32002} + * (resource not found). The SDK uses no other code in the reserved range, because a peer reads any + * code there as the meaning ACP gives it, or will give it. Each constant says when the SDK answers + * with it by itself; the last two it never sends on its own, they are for handlers. * * @author Mark Pollack * @see JSON-RPC Error Object @@ -29,86 +33,83 @@ private AcpErrorCodes() { // -------------------------- /** - * Parse error: Invalid JSON was received by the server. An error occurred on the - * server while parsing the JSON text. + * Parse error ({@code -32700}): the message was not valid JSON. The SDK's transports answer a + * message they cannot parse with it, with a {@code null} id, and keep reading. */ public static final int PARSE_ERROR = -32700; /** - * Invalid Request: The JSON sent is not a valid Request object. + * Invalid request ({@code -32600}): the message is JSON but not a valid JSON-RPC request, or + * the request is not valid in the current state. The SDK answers with it a JSON message it + * cannot read as a JSON-RPC message, and a second {@code session/prompt} on a session whose + * prompt turn is still running, with data {@code {"sessionId": ...}}. */ public static final int INVALID_REQUEST = -32600; /** - * Method not found: The method does not exist or is not available. + * Method not found ({@code -32601}): the peer has no handler for the method. Both sides answer + * with it a request for which no handler is registered, extension methods included. */ public static final int METHOD_NOT_FOUND = -32601; /** - * Invalid params: Invalid method parameter(s). + * Invalid params ({@code -32602}): the params do not fit the method. Both sides answer with it + * a request whose params cannot be read as the handler's params type, or lack a field the ACP + * schema requires; handlers use it for params they read but do not accept. */ public static final int INVALID_PARAMS = -32602; /** - * Internal error: Internal JSON-RPC error. + * Internal error ({@code -32603}): the handler failed. Both sides answer with it a request + * whose handler threw an exception other than {@link AcpProtocolException}, or produced no + * response. It is also the code of the {@link com.agentclientprotocol.sdk.spec.AcpError} that + * fails a request whose response the SDK rejected. */ public static final int INTERNAL_ERROR = -32603; - // -------------------------- - // ACP-Specific Errors - // -------------------------- - /** - * Concurrent prompt: There is already an active prompt execution on this session. - * ACP enforces single-turn semantics - only one prompt can be active at a time. + * Request cancelled ({@code -32800}): the method's work was stopped, because the caller + * cancelled the request ({@code $/cancel_request}) or because of resource constraints or + * shutdown. ACP answers an internally cancelled request (an internal timeout, for one) with + * this code too. The SDK answers with it a request whose handler it cancelled for a + * {@code $/cancel_request}, a handler that failed with a + * {@link java.util.concurrent.CancellationException} or an interrupt, and a prompt that ran + * past the agent's maximum prompt duration. */ - public static final int CONCURRENT_PROMPT = -32000; + public static final int REQUEST_CANCELLED = -32800; - /** - * Capability not supported: The peer does not support the requested capability. - */ - public static final int CAPABILITY_NOT_SUPPORTED = -32001; + // -------------------------- + // ACP-Specific Errors + // -------------------------- /** - * Session not found: The specified session ID does not exist. + * Authentication required ({@code -32000}): the agent requires {@code authenticate} before this + * request. An agent's handler answers with it; the SDK never does by itself. */ - public static final int SESSION_NOT_FOUND = -32002; + public static final int AUTHENTICATION_REQUIRED = -32000; /** - * Not initialized: A method was called before the connection was initialized. + * Resource not found ({@code -32002}): a resource the request names, such as a file or a + * session, does not exist. A handler answers with it; the SDK never does by itself. */ - public static final int NOT_INITIALIZED = -32003; + public static final int RESOURCE_NOT_FOUND = -32002; - /** - * Authentication required: The operation requires authentication. - */ - public static final int AUTHENTICATION_REQUIRED = -32004; + private static final Map DESCRIPTIONS = Map.ofEntries(Map.entry(PARSE_ERROR, "Parse error"), + Map.entry(INVALID_REQUEST, "Invalid request"), Map.entry(METHOD_NOT_FOUND, "Method not found"), + Map.entry(INVALID_PARAMS, "Invalid params"), Map.entry(INTERNAL_ERROR, "Internal error"), + Map.entry(REQUEST_CANCELLED, "Request cancelled"), + Map.entry(AUTHENTICATION_REQUIRED, "Authentication required"), + Map.entry(RESOURCE_NOT_FOUND, "Resource not found")); - /** - * Permission denied: The user denied permission for the requested operation. - */ - public static final int PERMISSION_DENIED = -32005; /** - * Returns a human-readable description for the given error code. + * Returns the short name of an error code as JSON-RPC and ACP spell it, such as + * {@code "Method not found"} for {@code -32601}. * @param code the error code - * @return a description of the error code + * @return the code's name, or {@code "Unknown error"} for a code not defined here */ public static String getDescription(int code) { - return switch (code) { - case PARSE_ERROR -> "Parse error"; - case INVALID_REQUEST -> "Invalid request"; - case METHOD_NOT_FOUND -> "Method not found"; - case INVALID_PARAMS -> "Invalid params"; - case INTERNAL_ERROR -> "Internal error"; - case CONCURRENT_PROMPT -> "Concurrent prompt"; - case CAPABILITY_NOT_SUPPORTED -> "Capability not supported"; - case SESSION_NOT_FOUND -> "Session not found"; - case NOT_INITIALIZED -> "Not initialized"; - case AUTHENTICATION_REQUIRED -> "Authentication required"; - case PERMISSION_DENIED -> "Permission denied"; - default -> "Unknown error"; - }; + return DESCRIPTIONS.getOrDefault(code, "Unknown error"); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpException.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpException.java index d6d9472e..0ce38f22 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpException.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpException.java @@ -5,31 +5,39 @@ package com.agentclientprotocol.sdk.error; /** - * Base exception for all ACP-related errors. + * The base class of the SDK's own exceptions: catch it to handle, in one place, every failure of + * a call the SDK raises, whether the peer answered with an error, a capability was missing, the + * connection is gone or no answer came in time. * - *

- * This is the root exception class for the ACP SDK. All ACP-specific exceptions extend - * this class, enabling catch blocks to handle all ACP errors uniformly when desired. - *

- * - *

- * Exception hierarchy: + *

Subclasses: *

    - *
  • {@link AcpException} - Base class for all ACP errors
  • - *
  • {@link AcpProtocolException} - JSON-RPC protocol errors with error codes
  • - *
  • {@link AcpCapabilityException} - Capability negotiation errors
  • - *
  • {@link AcpConnectionException} - Transport/connection errors
  • + *
  • {@link com.agentclientprotocol.sdk.spec.AcpError}: the peer answered the request with an + * error, or with a response the SDK rejected
  • + *
  • {@link AcpProtocolException}: what a handler throws to answer a request with a JSON-RPC + * error
  • + *
  • {@link AcpCapabilityException}: a call needs a capability the peer did not advertise
  • + *
  • {@link AcpConnectionException}: the transport could not send a message, or the connection + * ended
  • + *
  • {@link AcpTimeoutException}: a blocking call of the sync API got no answer in time
  • *
* + *

The sync API also throws a plain {@code AcpException} when a call fails with a checked + * exception, which it carries as the cause. + * + *

Every instance has a message (see {@link #getMessage()}), since a JSON-RPC error built from it + * needs one. + * * @author Mark Pollack + * @see com.agentclientprotocol.sdk.spec.AcpError * @see AcpProtocolException * @see AcpCapabilityException * @see AcpConnectionException + * @see AcpTimeoutException */ public class AcpException extends RuntimeException { /** - * Constructs a new ACP exception with the specified detail message. + * Creates an exception with this message. * @param message the detail message */ public AcpException(String message) { @@ -37,7 +45,7 @@ public AcpException(String message) { } /** - * Constructs a new ACP exception with the specified detail message and cause. + * Creates an exception with this message and cause. * @param message the detail message * @param cause the cause of this exception */ @@ -46,11 +54,23 @@ public AcpException(String message, Throwable cause) { } /** - * Constructs a new ACP exception with the specified cause. + * Creates an exception for this cause; the message is the cause's description. * @param cause the cause of this exception */ public AcpException(Throwable cause) { super(cause); } + /** + * Returns the detail message. Every constructor sets one (the cause-only constructor + * uses the cause's description), so an ACP exception always has a message: a + * JSON-RPC error built from it needs one. + * @return the detail message, never {@code null} + */ + @Override + public String getMessage() { + String message = super.getMessage(); + return (message != null) ? message : getClass().getName(); + } + } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpProtocolException.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpProtocolException.java index 48422da6..5a34f416 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpProtocolException.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpProtocolException.java @@ -4,81 +4,92 @@ package com.agentclientprotocol.sdk.error; -import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; + /** - * Exception representing a JSON-RPC protocol error from the peer. + * The exception a request handler throws to answer the request with a JSON-RPC error: the peer + * receives its code, message and data as the error response. Throw it, or fail the handler's + * {@code Mono} with it, for a failure the caller should see, with a code from + * {@link AcpErrorCodes}; this works the same in agent and client handlers, builder lambdas and + * annotated methods. Its message and data go to the peer as they are, so keep secrets out of them. + * An {@code AcpError} that escapes a handler passes on the error it carries, and a + * {@link java.util.concurrent.CancellationException} is answered {@code -32800} (request + * cancelled) with its own message, as ACP asks for a request cancelled internally. Any other + * exception from a handler is answered {@code -32603} (internal error) with the generic message + * "Internal error"; that exception is logged on the handling side and not sent. * - *

- * This exception is thrown when the peer returns a JSON-RPC error response. It wraps - * the error code, message, and optional data from the protocol response. - *

+ *

The caller of the request never receives this type. On the caller's side the error response + * fails the request with {@link com.agentclientprotocol.sdk.spec.AcpError}, which carries the same + * code, message and data. A handler that calls the peer and lets an {@code AcpError} it received + * escape passes that error on unchanged; it need not convert it. * - *

- * Common error codes are defined in {@link AcpErrorCodes}: + *

Codes a handler commonly answers with: *

    - *
  • {@link AcpErrorCodes#METHOD_NOT_FOUND} (-32601): Method not available
  • - *
  • {@link AcpErrorCodes#INVALID_PARAMS} (-32602): Invalid parameters
  • - *
  • {@link AcpErrorCodes#INTERNAL_ERROR} (-32603): Internal error
  • - *
  • {@link AcpErrorCodes#CONCURRENT_PROMPT} (-32000): Already processing a prompt
  • + *
  • {@link AcpErrorCodes#INVALID_PARAMS} ({@code -32602}): the params are readable but not + * acceptable, such as an unknown config option value
  • + *
  • {@link AcpErrorCodes#RESOURCE_NOT_FOUND} ({@code -32002}): a session, file or other resource + * the request names does not exist
  • + *
  • {@link AcpErrorCodes#AUTHENTICATION_REQUIRED} ({@code -32000}): the client must call + * {@code authenticate} first
  • + *
  • {@link AcpErrorCodes#INVALID_REQUEST} ({@code -32600}): the request is not valid in the + * current state
  • + *
  • {@link AcpErrorCodes#REQUEST_CANCELLED} ({@code -32800}): the handler stopped because its + * work was cancelled
  • *
* - *

- * Example usage: + *

Thrown from a notification handler, it is logged and dropped: a notification gets no answer. + * {@link #getMessage()} puts the code in front of the message, for logs; {@link #getErrorMessage()} + * is the message the peer receives. + * + *

Example, in a handler that looks a session up: *

{@code
- * try {
- *     client.prompt(request).block();
- * } catch (AcpProtocolException e) {
- *     if (e.getCode() == AcpErrorCodes.CONCURRENT_PROMPT) {
- *         // Handle concurrent prompt error
- *     }
+ * if (!sessions.containsKey(request.sessionId())) {
+ *     throw new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND,
+ *             "Unknown session: " + request.sessionId());
  * }
  * }
* * @author Mark Pollack * @see AcpErrorCodes + * @see com.agentclientprotocol.sdk.spec.AcpError */ public class AcpProtocolException extends AcpException { + /** The JSON-RPC error code. */ private final int code; - private final Object data; + /** The error data, or null for none. */ + private final @Nullable Object data; - /** - * Constructs a new protocol exception from a JSON-RPC error. - * @param error the JSON-RPC error from the protocol response - */ - public AcpProtocolException(AcpSchema.JSONRPCError error) { - super(formatMessage(error.code(), error.message())); - this.code = error.code(); - this.data = error.data(); - } + /** The error message without the code. */ + private final String errorMessage; /** - * Constructs a new protocol exception with the specified code and message. - * @param code the JSON-RPC error code - * @param message the error message + * Creates the exception for an error response with this code and message and no data. + * @param code the JSON-RPC error code, usually one of {@link AcpErrorCodes} + * @param message the error message the peer receives */ public AcpProtocolException(int code, String message) { - super(formatMessage(code, message)); - this.code = code; - this.data = null; + this(code, message, null); } /** - * Constructs a new protocol exception with the specified code, message, and data. - * @param code the JSON-RPC error code - * @param message the error message - * @param data optional additional error data + * Creates the exception for an error response with this code, message and data. + * @param code the JSON-RPC error code, usually one of {@link AcpErrorCodes} + * @param message the error message the peer receives + * @param data more detail for the peer, any value the JSON mapper can write, or {@code null} + * for none */ - public AcpProtocolException(int code, String message, Object data) { + public AcpProtocolException(int code, String message, @Nullable Object data) { super(formatMessage(code, message)); this.code = code; this.data = data; + this.errorMessage = message; } /** - * Returns the JSON-RPC error code. + * Returns the JSON-RPC error code the peer receives. * @return the error code * @see AcpErrorCodes */ @@ -87,55 +98,56 @@ public int getCode() { } /** - * Returns the optional additional error data. - * @return the error data, or null if none was provided + * Returns the data the peer receives with the error. + * @return the data, or {@code null} if there is none */ - public Object getData() { + public @Nullable Object getData() { return data; } /** - * Converts this exception to a JSON-RPC error object for sending over the wire. - * @return a JSON-RPC error object + * Returns the error message the peer receives, without the code. {@link #getMessage()} puts the + * code in front of it, as {@code [-32602] message}, for logs. + * @return the error message */ - public AcpSchema.JSONRPCError toJsonRpcError() { - return new AcpSchema.JSONRPCError(code, getMessage(), data); + public String getErrorMessage() { + return errorMessage; } /** - * Returns true if this error indicates the method was not found. - * @return true if method not found error + * Returns whether the code is {@code -32601} ("Method not found"). + * @return true for {@link AcpErrorCodes#METHOD_NOT_FOUND} */ public boolean isMethodNotFound() { return code == AcpErrorCodes.METHOD_NOT_FOUND; } /** - * Returns true if this error indicates a concurrent prompt violation. - * @return true if concurrent prompt error + * Returns whether the code is {@code -32000} (authentication required). + * @return true for {@link AcpErrorCodes#AUTHENTICATION_REQUIRED} */ - public boolean isConcurrentPrompt() { - return code == AcpErrorCodes.CONCURRENT_PROMPT; + public boolean isAuthenticationRequired() { + return code == AcpErrorCodes.AUTHENTICATION_REQUIRED; } /** - * Returns true if this error indicates invalid parameters. - * @return true if invalid params error + * Returns whether the code is {@code -32602} ("Invalid params"). + * @return true for {@link AcpErrorCodes#INVALID_PARAMS} */ public boolean isInvalidParams() { return code == AcpErrorCodes.INVALID_PARAMS; } /** - * Returns true if this error indicates an internal error. - * @return true if internal error + * Returns whether the code is {@code -32603} ("Internal error"). + * @return true for {@link AcpErrorCodes#INTERNAL_ERROR} */ public boolean isInternalError() { return code == AcpErrorCodes.INTERNAL_ERROR; } private static String formatMessage(int code, String message) { - return String.format("[%d] %s", code, message); + return "[" + code + "] " + message; } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpTimeoutException.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpTimeoutException.java new file mode 100644 index 00000000..920f9669 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/AcpTimeoutException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.error; + +import java.util.concurrent.TimeoutException; + +/** + * Thrown by a blocking call of the sync API ({@code AcpSyncClient}, {@code AcpSyncAgent}, + * {@code SyncPromptContext}) when the peer did not answer in time: the request timeout, the + * client's prompt timeout or the sync agent's block timeout passed. Catch it to handle a peer that + * is slow or gone; the peer may still have done the work. Its cause is the + * {@link TimeoutException}. By then the SDK has given up on the request and, if it was sent, told + * the peer with a {@code $/cancel_request}, which makes a Java peer cancel its handler. The + * asynchronous API fails its {@code Mono} with the {@link TimeoutException} itself. + * + *

The sync API throws the other failures of a call as they are: a peer's error as + * {@link com.agentclientprotocol.sdk.spec.AcpError}, a missing capability as + * {@link AcpCapabilityException}, and an interrupt of the waiting thread as a + * {@link java.util.concurrent.CancellationException}, with the thread's interrupt flag set again. + * + * @author Mark Pollack + */ +public class AcpTimeoutException extends AcpException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the exception for a timeout; the message starts {@code "No answer in time"} and names + * the timeout. + * @param cause the timeout + */ + public AcpTimeoutException(TimeoutException cause) { + super("No answer in time: " + cause.getMessage(), cause); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/package-info.java index 23cd6983..94b38a1e 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/error/package-info.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/error/package-info.java @@ -3,28 +3,29 @@ */ /** - * Exception types and error codes for the ACP SDK. - * - *

- * This package provides a structured exception hierarchy for handling errors in ACP - * communication: - *

+ * The SDK's exceptions and the JSON-RPC error codes ACP uses. Catch + * {@link com.agentclientprotocol.sdk.error.AcpException} to handle every failure of a call in one + * place, or one of its subclasses for a single case: * *
    - *
  • {@link com.agentclientprotocol.sdk.error.AcpException} - Base class for all ACP - * errors
  • - *
  • {@link com.agentclientprotocol.sdk.error.AcpProtocolException} - JSON-RPC protocol - * errors
  • - *
  • {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} - Capability - * negotiation errors
  • - *
  • {@link com.agentclientprotocol.sdk.error.AcpConnectionException} - Transport/connection - * errors
  • + *
  • {@link com.agentclientprotocol.sdk.spec.AcpError}: the peer answered the request with an + * error, or with a response the SDK rejected
  • + *
  • {@link com.agentclientprotocol.sdk.error.AcpProtocolException}: what a handler throws to + * answer a request with a JSON-RPC error; the caller receives it as an {@code AcpError}
  • + *
  • {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}: a call needs a capability + * the peer did not advertise, so the SDK refused it without sending it
  • + *
  • {@link com.agentclientprotocol.sdk.error.AcpConnectionException}: the transport could not + * send a message, or the connection ended
  • + *
  • {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}: a blocking call of the sync + * API got no answer in time
  • *
* *

- * Standard error codes are defined in {@link com.agentclientprotocol.sdk.error.AcpErrorCodes}. - *

+ * The error codes are constants of {@link com.agentclientprotocol.sdk.error.AcpErrorCodes}. * * @see com.agentclientprotocol.sdk.error.AcpErrorCodes */ +@NullMarked package com.agentclientprotocol.sdk.error; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapper.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapper.java index 82f38843..83cf76cc 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapper.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapper.java @@ -7,119 +7,141 @@ import java.io.IOException; import java.util.ServiceLoader; +import org.jspecify.annotations.Nullable; + /** - * Abstraction for JSON serialization/deserialization to decouple the SDK from any - * specific JSON library. + * Reads and writes the JSON of ACP messages for the SDK, so that acp-core itself depends on no JSON + * library. The transports use it to write each message they send, to read each message that + * arrives, and to convert a message's params or result into the + * {@link com.agentclientprotocol.sdk.spec.AcpSchema} record the method expects. Most applications + * never call it: a transport created without a mapper gets one from {@link #createDefault()}. * - *

- * {@code acp-core} contains no implementation. One comes from a JSON module on the - * classpath: {@code acp-json-jackson2} (Jackson 2, which the transport and agent-support - * modules bring in) or {@code acp-json-jackson3} (Jackson 3). Each registers an - * {@link AcpJsonMapperSupplier} discovered via {@link ServiceLoader}. - *

+ *

The implementation comes from a JSON module on the classpath: {@code acp-json-jackson2} + * ({@code JacksonAcpJsonMapper}, Jackson 2) or {@code acp-json-jackson3} + * ({@code Jackson3AcpJsonMapper}, Jackson 3). {@code acp-agent-support}, + * {@code acp-streamable-http-jetty} and {@code acp-test} bring in {@code acp-json-jackson2}; with + * acp-core alone, add one of the two. Each module registers an {@link AcpJsonMapperSupplier}, and + * {@link #createDefault()} picks one. To configure Jackson yourself, create the module's mapper + * around your own Jackson mapper and pass it to the transport's constructor. * - *

- * Alternative implementations (e.g., for Micronaut serialization) can be provided by - * implementing {@link AcpJsonMapperSupplier} and registering it in - * {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier}. - *

+ *

Implementations: the schema records carry Jackson annotations ({@code @JsonProperty}, + * {@code @JsonInclude}, {@code @JsonTypeInfo}, {@code @JsonSubTypes}, {@code @JsonCreator}, + * {@code @JsonValue}, {@code @JsonAnyGetter} and {@code @JsonAnySetter}), so a mapper must honour + * them to read and write ACP. A transport calls its mapper from several threads at once, so it must + * be safe for concurrent use. Reads and writes report failures as {@link IOException}; the SDK + * answers text that is not JSON with a parse error ({@code -32700}). A conversion whose value does + * not fit the target type must fail with {@link IllegalArgumentException}, which the SDK answers as + * invalid params ({@code -32602}). Register an implementation through an + * {@link AcpJsonMapperSupplier}. * * @author Mark Pollack */ public interface AcpJsonMapper { /** - * Creates a default AcpJsonMapper from the {@link AcpJsonMapperSupplier}s found via - * {@link ServiceLoader}. The choice is deterministic: + * Returns a new mapper from the {@link AcpJsonMapperSupplier}s that {@link ServiceLoader} finds + * with the current thread's context class loader. The choice does not depend on classpath + * order: *

    - *
  1. if the system property {@code acp.json.mapper.supplier} is set, the supplier - * with exactly that fully qualified class name is used (and it is an error if there is - * none);
  2. - *
  3. otherwise the supplier with the highest {@link AcpJsonMapperSupplier#priority()} - * is used, ties broken by class name. With both SDK modules present, - * {@code acp-json-jackson3} (priority -100) wins over {@code acp-json-jackson2} - * (-200); an application supplier that keeps the default priority (0) wins over - * both.
  4. + *
  5. if the system property {@code acp.json.mapper.supplier} is set (and not blank), the + * supplier whose class has exactly that fully qualified name is used;
  6. + *
  7. otherwise the supplier with the highest {@link AcpJsonMapperSupplier#priority()} is used, + * ties broken by class name. With both SDK modules present, {@code acp-json-jackson3} (priority + * {@code -100}) wins over {@code acp-json-jackson2} ({@code -200}); an application supplier + * that keeps the default priority ({@code 0}) wins over both.
  8. *
- * @return a new AcpJsonMapper instance - * @throws java.util.ServiceConfigurationError if no supplier is found, or none - * matches the system property + * + *

Each call looks the suppliers up again and asks the chosen one for a mapper; the SDK's + * suppliers build a new one every time, so keep the result rather than calling this per + * message. + * @return the chosen supplier's mapper + * @throws java.util.ServiceConfigurationError if no supplier is found, or none has the class + * named by the system property + * @throws IllegalStateException if the chosen SDK module finds a Jackson older than it supports + * (2.18.1 for Jackson 2, 3.0.0 for Jackson 3) */ static AcpJsonMapper createDefault() { return AcpJsonMapperSelector.select().get(); } /** - * Deserialize JSON string into a target type. - * @param content JSON as String - * @param type target class - * @return deserialized instance - * @param generic type - * @throws IOException on parse errors + * Reads a JSON text as the given type. + * @param content the JSON text + * @param type the type to read + * @param the type to read + * @return the value, or {@code null} when the text is the JSON literal {@code null} + * @throws IOException if the text is not JSON or does not fit the type */ - T readValue(String content, Class type) throws IOException; + @Nullable T readValue(String content, Class type) throws IOException; /** - * Deserialize JSON bytes into a target type. - * @param content JSON as bytes - * @param type target class - * @return deserialized instance - * @param generic type - * @throws IOException on parse errors + * Reads JSON bytes as the given type, like {@link #readValue(String, Class)}. + * @param content the JSON bytes + * @param type the type to read + * @param the type to read + * @return the value, or {@code null} when the bytes are the JSON literal {@code null} + * @throws IOException if the bytes are not JSON or do not fit the type */ - T readValue(byte[] content, Class type) throws IOException; + @Nullable T readValue(byte[] content, Class type) throws IOException; /** - * Deserialize JSON string into a parameterized target type. - * @param content JSON as String - * @param type parameterized type reference - * @return deserialized instance - * @param generic type - * @throws IOException on parse errors + * Reads a JSON text as a generic type, such as {@code List}, that a + * {@link TypeRef} captures. + * @param content the JSON text + * @param type the type to read + * @param the type to read + * @return the value, or {@code null} when the text is the JSON literal {@code null} + * @throws IOException if the text is not JSON or does not fit the type */ - T readValue(String content, TypeRef type) throws IOException; + @Nullable T readValue(String content, TypeRef type) throws IOException; /** - * Deserialize JSON bytes into a parameterized target type. - * @param content JSON as bytes - * @param type parameterized type reference - * @return deserialized instance - * @param generic type - * @throws IOException on parse errors + * Reads JSON bytes as a generic type that a {@link TypeRef} captures, like + * {@link #readValue(String, TypeRef)}. + * @param content the JSON bytes + * @param type the type to read + * @param the type to read + * @return the value, or {@code null} when the bytes are the JSON literal {@code null} + * @throws IOException if the bytes are not JSON or do not fit the type */ - T readValue(byte[] content, TypeRef type) throws IOException; + @Nullable T readValue(byte[] content, TypeRef type) throws IOException; /** - * Convert a value to a given type, useful for mapping nested JSON structures. - * @param fromValue source value - * @param type target class - * @return converted value - * @param generic type + * Converts a value to the given type as if it were written as JSON and read back. The + * transports use it to turn the params or result of a message, read as maps and lists, into the + * record the method expects. + * @param fromValue the value, usually maps and lists read from JSON + * @param type the type to convert to + * @param the type to convert to + * @return the converted value + * @throws IllegalArgumentException if the value does not fit the type */ T convertValue(Object fromValue, Class type); /** - * Convert a value to a given parameterized type. - * @param fromValue source value - * @param type target type reference - * @return converted value - * @param generic type + * Converts a value to a generic type that a {@link TypeRef} captures, like + * {@link #convertValue(Object, Class)}. + * @param fromValue the value, usually maps and lists read from JSON + * @param type the type to convert to + * @param the type to convert to + * @return the converted value + * @throws IllegalArgumentException if the value does not fit the type */ T convertValue(Object fromValue, TypeRef type); /** - * Serialize an object to JSON string. - * @param value object to serialize - * @return JSON as String - * @throws IOException on serialization errors + * Writes a value, such as a JSON-RPC message, as JSON text. + * @param value the value to write + * @return the JSON text + * @throws IOException if the value cannot be written as JSON */ String writeValueAsString(Object value) throws IOException; /** - * Serialize an object to JSON bytes. - * @param value object to serialize - * @return JSON as bytes - * @throws IOException on serialization errors + * Writes a value as JSON bytes in UTF-8. + * @param value the value to write + * @return the JSON bytes + * @throws IOException if the value cannot be written as JSON */ byte[] writeValueAsBytes(Object value) throws IOException; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSelector.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSelector.java index 1ec9866e..6ac7ada4 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSelector.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSelector.java @@ -11,6 +11,8 @@ import java.util.ServiceLoader; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + /** * The selection rule behind {@link AcpJsonMapper#createDefault()}, kept out of the * interface so it can be tested with suppliers that are not registered as services. @@ -47,7 +49,7 @@ static AcpJsonMapperSupplier select() { * @throws ServiceConfigurationError if there is no candidate, or none matches the * override */ - static AcpJsonMapperSupplier select(Iterable candidates, String override) { + static AcpJsonMapperSupplier select(Iterable candidates, @Nullable String override) { List all = new ArrayList<>(); candidates.forEach(all::add); if (all.isEmpty()) { diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSupplier.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSupplier.java index 93a3349f..6c5fb398 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSupplier.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/AcpJsonMapperSupplier.java @@ -7,19 +7,20 @@ import java.util.function.Supplier; /** - * Strategy interface for providing an {@link AcpJsonMapper} implementation. + * Supplies the {@link AcpJsonMapper} that {@link AcpJsonMapper#createDefault()} returns. Each JSON + * module registers one with {@link java.util.ServiceLoader}, and {@code createDefault()} picks one + * by {@link #priority()}. Implement it to plug in a JSON library the SDK does not ship, or a + * Jackson mapper configured your way for every transport that creates its own mapper. * - *

- * Implementations are discovered at runtime via {@link java.util.ServiceLoader}. - * To register an implementation, create a file at - * {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier} - * containing the fully qualified class name of the supplier. - *

+ *

To register an implementation, list its fully qualified class name in + * {@code META-INF/services/com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier}; ServiceLoader + * needs a public class with a public no-argument constructor. With the default priority it wins + * over the SDK's own suppliers. The system property {@code acp.json.mapper.supplier} chooses a + * supplier by class name instead (see {@link AcpJsonMapper#createDefault()}). * - *

- * When several suppliers are on the classpath, {@link AcpJsonMapper#createDefault()} - * picks the one with the highest {@link #priority()}; see there for the full rule. - *

+ *

Implementations: {@link #get()} is called on every {@code createDefault()} call, and the + * mapper it returns must meet the {@link AcpJsonMapper} requirements, including use from several + * threads at once. {@link #priority()} returns {@link #DEFAULT_PRIORITY} unless overridden. * * @author Mark Pollack * @see AcpJsonMapper#createDefault() @@ -27,17 +28,19 @@ public interface AcpJsonMapperSupplier extends Supplier { /** - * The priority of a supplier that does not override {@link #priority()}. It is higher - * than the priority of both SDK-provided suppliers, so an application's own supplier - * wins over them without further configuration. + * The priority of a supplier that does not override {@link #priority()}: {@value}. It is higher + * than the priority of both SDK suppliers, so an application's own supplier wins over them + * without further configuration. */ int DEFAULT_PRIORITY = 0; /** - * This supplier's priority when more than one supplier is on the classpath: the - * highest value wins. The SDK's own suppliers use negative values ({@code - * acp-json-jackson2} is {@code -200}, {@code acp-json-jackson3} is {@code -100}). - * @return the priority, {@link #DEFAULT_PRIORITY} unless overridden + * Returns this supplier's rank when {@link AcpJsonMapper#createDefault()} finds more than one: + * the highest wins, ties broken by class name. The SDK's suppliers use negative values, + * {@code -200} for {@code acp-json-jackson2} and {@code -100} for {@code acp-json-jackson3}. + * The system property {@code acp.json.mapper.supplier}, when set, decides instead. + * @implSpec Returns {@link #DEFAULT_PRIORITY}. + * @return the priority */ default int priority() { return DEFAULT_PRIORITY; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/TypeRef.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/TypeRef.java index f82f7758..b324622d 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/TypeRef.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/TypeRef.java @@ -8,10 +8,26 @@ import java.lang.reflect.Type; /** - * Captures generic type information at runtime for parameterized JSON (de)serialization. - * Usage: TypeRef<List<Foo>> ref = new TypeRef<>(){}; + * A Java type, generics included, for the SDK to read JSON as. Create one as an anonymous subclass, + * {@code new TypeRef>() {}}, and pass it where the SDK reads a value whose type only + * you know: an extension method's result ({@code sendExtRequest(method, params, resultType)}), an + * extension handler's params ({@code extRequestHandler(method, paramsType, handler)}), or + * {@link AcpJsonMapper#readValue(String, TypeRef)} and + * {@link AcpJsonMapper#convertValue(Object, TypeRef)}. A plain {@code Class} cannot carry a type + * argument such as {@code String} in {@code List}; the anonymous subclass keeps it. * - * @param the type to capture + *

For a type known only at runtime, such as a reflected method parameter's generic type, use + * {@link #of(Type)}. Instances are immutable and safe to share between threads. + * + *

Example, reading an extension method's result as a record: + *

{@code
+ * record Pong(String text) {}
+ *
+ * TypeRef pongType = new TypeRef<>() {};
+ * Pong pong = client.sendExtRequest("_example.com/ping", Map.of("text", "hi"), pongType);
+ * }
+ * + * @param the type to read * @author Mark Pollack */ public abstract class TypeRef { @@ -19,13 +35,10 @@ public abstract class TypeRef { private final Type type; /** - * Constructs a new TypeRef instance, capturing the generic type information of the - * subclass. This constructor should be called from an anonymous subclass to capture - * the actual type arguments. For example:
-	 * TypeRef<List<Foo>> ref = new TypeRef<>(){};
-	 * 
- * @throws IllegalStateException if TypeRef is not subclassed with actual type - * information + * Captures the type argument of the anonymous subclass being created, such as + * {@code List} in {@code new TypeRef>() {}}. + * @throws IllegalStateException if the subclass gives no type argument, as in a raw + * {@code new TypeRef() {}} */ protected TypeRef() { Type superClass = getClass().getGenericSuperclass(); @@ -35,10 +48,37 @@ protected TypeRef() { this.type = ((ParameterizedType) superClass).getActualTypeArguments()[0]; } + private TypeRef(Type type) { + this.type = type; + } + + /** + * Returns a type reference for a type known only at runtime, such as the reflected generic type + * of a handler method's parameter. + * @param type the type + * @return a type reference to it + * @throws IllegalArgumentException if the type is null + */ + public static TypeRef of(Type type) { + if (type == null) { + throw new IllegalArgumentException("Type must not be null"); + } + return new RuntimeTypeRef(type); + } + + /** The type reference {@link #of(Type)} returns. */ + private static final class RuntimeTypeRef extends TypeRef { + + RuntimeTypeRef(Type type) { + super(type); + } + + } + /** - * Returns the captured type information. - * @return the Type representing the actual type argument captured by this TypeRef - * instance + * Returns the captured type: a {@code Class} for a plain type, or a {@code ParameterizedType} + * for a generic one. + * @return the type */ public Type getType() { return type; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/json/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/package-info.java new file mode 100644 index 00000000..84ca3d45 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/json/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The JSON mapping abstraction the SDK serializes the protocol through, and its ServiceLoader-based selection. + * + *

+ * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

+ */ +@NullMarked +package com.agentclientprotocol.sdk.json; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java index 9c7b57df..e9f8807e 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentSession.java @@ -7,22 +7,22 @@ import java.time.Duration; import java.util.Map; import java.util.Set; -import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Function; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; +import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.error.AcpErrorCodes; -import com.agentclientprotocol.sdk.error.AcpProtocolException; import com.agentclientprotocol.sdk.util.AcpSchedulers; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.HandlerFailures; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; -import reactor.core.publisher.MonoSink; /** * Agent-side implementation of the ACP (Agent Client Protocol) session that manages @@ -36,7 +36,8 @@ *
  • Notification processing
  • *
  • Message timeout management
  • *
  • Transport layer abstraction
  • - *
  • Single-turn enforcement (only one prompt active at a time per session)
  • + *
  • Single-turn enforcement (only one prompt active at a time per session; a turn ends + * with its prompt's response, also after {@code session/cancel})
  • * * *

    @@ -51,19 +52,11 @@ public class AcpAgentSession implements AcpSession { private static final Logger logger = LoggerFactory.getLogger(AcpAgentSession.class); - /** Duration to wait for request responses before timing out */ - private final Duration requestTimeout; - - /** - * The JVM-wide daemon timer shared by every session (see AcpSchedulers); never disposed here. - */ - private final Scheduler timeoutScheduler; - /** Transport layer implementation for message exchange */ private final AcpAgentTransport transport; - /** Map of pending responses keyed by request ID */ - private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); + /** Requests and notifications sent to the client, and the requests waiting for a response */ + private final OutboundMessages outbound; /** Map of request handlers keyed by method name */ private final ConcurrentHashMap> requestHandlers = new ConcurrentHashMap<>(); @@ -71,36 +64,20 @@ public class AcpAgentSession implements AcpSession { /** Map of notification handlers keyed by method name */ private final ConcurrentHashMap notificationHandlers = new ConcurrentHashMap<>(); - /** Session-specific prefix for request IDs */ - private final String sessionPrefix = UUID.randomUUID().toString().substring(0, 8); + /** Requests from the client still being handled, which a $/cancel_request can cancel */ + private final InboundRequests inbound = new InboundRequests(); - /** Atomic counter for generating unique request IDs */ - private final AtomicLong requestCounter = new AtomicLong(0); + /** Single-turn enforcement: the active prompt of each logical ACP sessionId. */ + private final ActivePrompts activePrompts = new ActivePrompts(); - /** - * Active prompt tracking for single-turn enforcement, keyed by logical ACP - * sessionId. - * - *

    - * Kotlin SDK precedent: its Agent.SessionWrapper owns a single active prompt guard - * per logical session wrapper. This Java session can multiplex multiple logical ACP - * sessionIds over one transport connection, so the same single-turn rule needs to - * be applied per sessionId instead of once for the whole connection. - *

    - */ - private final ConcurrentHashMap activePrompts = new ConcurrentHashMap<>(); + /** The cancel grace period and maximum prompt duration of each prompt. */ + private final PromptDeadlines promptDeadlines; /** * Set when the transport's {@code start()} fails (already started, port in use, ...). * Requests to the client are then failed immediately with the cause. */ - private volatile Throwable startFailure; - - /** - * Represents an active prompt session for single-turn enforcement. - */ - private record ActivePrompt(String sessionId, Object requestId) { - } + private volatile @Nullable Throwable startFailure; /** * Functional interface for handling incoming JSON-RPC requests. Implementations @@ -113,8 +90,9 @@ public interface RequestHandler { /** * Handles an incoming request with the given parameters. - * @param params The request parameters - * @return A Mono containing the response object + * @param params The request parameters; an omitted params arrives as an empty + * object + * @return A Mono containing the response object; it must not complete empty */ Mono handle(Object params); @@ -129,7 +107,8 @@ public interface NotificationHandler { /** * Handles an incoming notification with the given parameters. - * @param params The notification parameters + * @param params The notification parameters; an omitted params arrives as an empty + * object * @return A Mono that completes when the notification is processed */ Mono handle(Object params); @@ -137,7 +116,8 @@ public interface NotificationHandler { } /** - * Creates a new AcpAgentSession with the specified configuration and handlers. + * Creates a new AcpAgentSession with the specified configuration and handlers, and the + * default prompt timeouts ({@link PromptTimeouts#DEFAULTS}). * @param requestTimeout Duration to wait for responses * @param transport Transport implementation for message exchange * @param requestHandlers Map of method names to request handlers @@ -145,20 +125,42 @@ public interface NotificationHandler { */ public AcpAgentSession(Duration requestTimeout, AcpAgentTransport transport, Map> requestHandlers, Map notificationHandlers) { + this(requestTimeout, transport, requestHandlers, notificationHandlers, PromptTimeouts.DEFAULTS); + } + + /** + * Creates a new AcpAgentSession with the specified configuration and handlers. + * @param requestTimeout Duration to wait for responses + * @param transport Transport implementation for message exchange + * @param requestHandlers Map of method names to request handlers + * @param notificationHandlers Map of method names to notification handlers + * @param promptTimeouts when the session answers a prompt its handler has not: the cancel + * grace period and the maximum prompt duration + */ + public AcpAgentSession(Duration requestTimeout, AcpAgentTransport transport, + Map> requestHandlers, Map notificationHandlers, + PromptTimeouts promptTimeouts) { + this(requestTimeout, transport, requestHandlers, notificationHandlers, promptTimeouts, AcpSchedulers::after); + } + + /** As above, with the timer the prompt deadlines run on (a virtual-time one in tests). */ + AcpAgentSession(Duration requestTimeout, AcpAgentTransport transport, + Map> requestHandlers, Map notificationHandlers, + PromptTimeouts promptTimeouts, Function> timer) { Assert.notNull(requestTimeout, "The requestTimeout can not be null"); + Assert.notNull(promptTimeouts, "The promptTimeouts can not be null"); Assert.notNull(transport, "The transport can not be null"); Assert.notNull(requestHandlers, "The requestHandlers can not be null"); Assert.notNull(notificationHandlers, "The notificationHandlers can not be null"); - this.requestTimeout = requestTimeout; this.transport = transport; + this.promptDeadlines = new PromptDeadlines(promptTimeouts, timer); + this.outbound = new OutboundMessages(transport, requestTimeout, () -> this.startFailure, + AcpAgentSession::notStarted, "client"); this.requestHandlers.putAll(requestHandlers); this.notificationHandlers.putAll(notificationHandlers); - // One shared daemon timer for every session in the JVM (see AcpSchedulers). - this.timeoutScheduler = AcpSchedulers.timeouts(); - this.transport.start(mono -> mono.flatMap(this::handle)).subscribe(v -> { }, this::onStartFailure); @@ -166,7 +168,8 @@ public AcpAgentSession(Duration requestTimeout, AcpAgentTransport transport, // twice) fails construction rather than handing back a session that can never talk. Throwable failure = this.startFailure; if (failure != null) { - throw notStarted(failure); + // A refusal to start at all (a reused transport) is a misuse, not a lost connection. + throw new IllegalStateException("ACP agent transport is not started: " + failure.getMessage(), failure); } } @@ -177,20 +180,16 @@ private void onStartFailure(Throwable error) { dismissPendingResponses(error); } - private static IllegalStateException notStarted(Throwable cause) { - return new IllegalStateException("ACP agent transport is not started: " + cause.getMessage(), cause); + private static AcpConnectionException notStarted(Throwable cause) { + return new AcpConnectionException("ACP agent transport is not started: " + cause.getMessage(), cause); } private void dismissPendingResponses() { dismissPendingResponses(null); } - private void dismissPendingResponses(Throwable cause) { - this.pendingResponses.forEach((id, sink) -> { - logger.warn("Abruptly terminating exchange for request {}", id); - sink.error(new RuntimeException("ACP session with client terminated", cause)); - }); - this.pendingResponses.clear(); + private void dismissPendingResponses(@Nullable Throwable cause) { + this.outbound.dismissPending(cause); } /** @@ -200,49 +199,25 @@ private void dismissPendingResponses(Throwable cause) { */ private Mono handle(AcpSchema.JSONRPCMessage message) { if (message instanceof AcpSchema.JSONRPCResponse response) { - logger.debug("Received response for id {}", response.id()); - if (response.id() != null) { - var sink = pendingResponses.remove(response.id()); - if (sink == null) { - logger.warn("Unexpected response for unknown id {}", response.id()); - } - else { - sink.success(response); - } - } - else { - logger.error("Discarded ACP request response without session id. " - + "This is an indication of a bug in the request sender code that can lead to memory " - + "leaks as pending requests will never be completed."); - } + this.outbound.complete(response); return Mono.empty(); } - else if (message instanceof AcpSchema.JSONRPCRequest request) { + if (message instanceof AcpSchema.JSONRPCRequest request) { logger.debug("Received request method={} id={}", request.method(), request.id()); - return handleIncomingRequest(request).onErrorResume(error -> { - // Preserve error codes from AcpProtocolException, wrap others in INTERNAL_ERROR - int errorCode; - Object errorData = null; - if (error instanceof AcpProtocolException protocolException) { - errorCode = protocolException.getCode(); - errorData = protocolException.getData(); - } - else { - errorCode = AcpErrorCodes.INTERNAL_ERROR; - } - var errorResponse = new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(errorCode, error.getMessage(), errorData)); - return Mono.just(errorResponse); - }).map(response -> (AcpSchema.JSONRPCMessage) response); + // Mono.from widens the response Mono to the message type without an operator. + // The whole answer is tracked, so a $/cancel_request races it inside one Mono. + AtomicReference promptTurn = new AtomicReference<>(); + return Mono.from(this.inbound + .track(request, handleIncomingRequest(request, promptTurn), + () -> whenCancelled(request, promptTurn.get())) + .onErrorResume(error -> Mono.just(InboundMessages.error(request, error)))); } - else if (message instanceof AcpSchema.JSONRPCNotification notification) { + if (message instanceof AcpSchema.JSONRPCNotification notification) { logger.debug("Received notification method={}", notification.method()); return handleIncomingNotification(notification).then(Mono.empty()); } - else { - logger.warn("Received unknown message type: {}", message); - return Mono.empty(); - } + logger.warn("Received unknown message type: {}", message.getClass().getName()); + return Mono.empty(); } /** @@ -251,75 +226,136 @@ else if (message instanceof AcpSchema.JSONRPCNotification notification) { * @param request The incoming JSON-RPC request * @return A Mono containing the JSON-RPC response */ - private Mono handleIncomingRequest(AcpSchema.JSONRPCRequest request) { + private Mono handleIncomingRequest(AcpSchema.JSONRPCRequest request, + AtomicReference promptTurn) { return Mono.defer(() -> { var handler = this.requestHandlers.get(request.method()); if (handler == null) { MethodNotFoundError error = getMethodNotFoundError(request.method()); - return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(-32601, error.message(), error.data()))); + return Mono.just(InboundMessages.error(request, AcpErrorCodes.METHOD_NOT_FOUND, error.message(), + error.data())); } // Single-turn enforcement for session/prompt requests if (AcpSchema.METHOD_SESSION_PROMPT.equals(request.method())) { - // Extract sessionId from params String sessionId = extractSessionId(request.params()); - ActivePrompt newPrompt = new ActivePrompt(sessionId, request.id()); - - // Try to set as active prompt - fails if this logical session already has - // a prompt active. - ActivePrompt current = activePrompts.putIfAbsent(sessionId, newPrompt); - if (current != null) { - logger.warn("Rejected concurrent prompt request for sessionId={}. Active requestId={}", sessionId, - current.requestId()); - return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(-32000, "There is already an active prompt execution", null))); + ActivePrompts.Turn turn = activePrompts.tryStart(sessionId, request.id()); + if (turn == null) { + // -32600, not -32000: ACP v1 defines -32000 as "Authentication required". + return Mono.just(InboundMessages.error(request, AcpErrorCodes.INVALID_REQUEST, + "There is already an active prompt on session " + sessionId, + Map.of("sessionId", sessionId))); } - - // The prompt response ends the turn (ACP semantics), so the lock is released - // *before* the response is handed downstream to the transport. Releasing in - // doFinally instead ran after the response had already reached the client; - // under CPU contention the client's next prompt then arrived before the - // release and was rejected with -32000 (#14). doOnNext and doOnError run before - // the signal propagates, and a Mono emits at most once, so the release is - // ordered before publication on every path; doFinally covers cancellation. - // Mono.defer keeps a handler that throws synchronously from holding the lock - // forever: the throw becomes an error signal that passes through the release. - return Mono.defer(() -> handler.handle(request.params())) - .map(result -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)) - .doOnNext(response -> releasePrompt(sessionId, newPrompt, "response")) - .doOnError(error -> releasePrompt(sessionId, newPrompt, "error")) - .doFinally(signal -> releasePrompt(sessionId, newPrompt, signal.toString())); + promptTurn.set(turn); + + // The turn ends before the response is published (#14): see + // ActivePrompts.endBeforePublishing. Mono.defer keeps a handler that throws + // synchronously from holding the turn forever: the throw becomes an error + // signal that passes through the release. + // The deadlines may answer instead of the handler; either answer passes + // through the release. + // Once session/cancel was received, the prompt answers stop reason cancelled, + // whatever its handler returns or fails with (ACP v1, prompt turn, Cancellation). + return activePrompts.endBeforePublishing(turn, promptDeadlines.answer(turn, request, + InboundMessages.requireResult(HandlerFailures.invoke(() -> handler.handle(InboundMessages.paramsOrEmpty(request.params()))), + request.method()) + .map(result -> InboundMessages.result(request, cancelledIfCancelling(turn, request, result))) + .onErrorResume(error -> turn.answer().isCancelling(), + error -> Mono.just(cancelledPromptAfterFailure(request, error))))); } - return Mono.defer(() -> handler.handle(request.params())) - .map(result -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)); + Mono response = InboundMessages + .requireResult(HandlerFailures.invoke(() -> handler.handle(InboundMessages.paramsOrEmpty(request.params()))), + request.method()) + .map(result -> InboundMessages.result(request, result)); + if (AcpSchema.METHOD_SESSION_CLOSE.equals(request.method())) { + return cancelOngoingWork(extractSessionId(request.params())).then(response); + } + return response; }); } /** - * Releases the single-turn lock if {@code prompt} still holds it. Idempotent: the - * release is attempted on the response, on an error and on the terminal signal, and - * only the first attempt does anything. + * Cancels a closing session's ongoing work as if session/cancel had been received (ACP + * v1, CloseSessionRequest: the agent must cancel any ongoing work related to the + * session, treating it as if session/cancel was called, and then free its resources): + * the session/cancel handler is told, and an active prompt is cancelled and answers + * cancelled. Completes once that prompt's turn has ended, so the close handler frees + * the session after its work has stopped. + */ + private Mono cancelOngoingWork(String sessionId) { + ActivePrompts.Turn turn = this.activePrompts.current(sessionId); + this.activePrompts.cancel(sessionId); + Mono cancelHandler = Mono.empty(); + if (this.notificationHandlers.containsKey(AcpSchema.METHOD_SESSION_CANCEL)) { + cancelHandler = InboundMessages.deliver(logger, + new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, AcpSchema.METHOD_SESSION_CANCEL, + Map.of("sessionId", sessionId)), + this.notificationHandlers, NotificationHandler::handle); + } + return turn == null ? cancelHandler : cancelHandler.then(turn.ended()); + } + + /** + * The answer to a request a $/cancel_request cancelled: -32800, except for a prompt + * already cancelled by session/cancel, which must answer stop reason cancelled (ACP v1, + * prompt turn, Cancellation). */ - private void releasePrompt(String sessionId, ActivePrompt prompt, String reason) { - if (activePrompts.remove(sessionId, prompt)) { - logger.debug("Prompt lock released for sessionId={} requestId={} ({})", sessionId, prompt.requestId(), - reason); + private static AcpSchema.JSONRPCResponse whenCancelled(AcpSchema.JSONRPCRequest request, + ActivePrompts.@Nullable Turn promptTurn) { + if (promptTurn != null && promptTurn.answer().isCancelling()) { + return cancelledPrompt(request); } + return InboundRequests.cancelled(request); + } + + private static AcpSchema.JSONRPCResponse cancelledPrompt(AcpSchema.JSONRPCRequest request) { + return InboundMessages.result(request, new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + } + + /** + * A prompt handler's answer, with stop reason {@code cancelled} once session/cancel was + * received for the prompt: the agent must then answer cancelled (ACP v1, prompt turn, + * Cancellation), also when its handler returned another stop reason. {@code _meta} is kept. + */ + private static Object cancelledIfCancelling(ActivePrompts.Turn turn, AcpSchema.JSONRPCRequest request, + Object result) { + if (turn.answer().isCancelling() && result instanceof AcpSchema.PromptResponse response + && !AcpSchema.StopReason.CANCELLED.equals(response.stopReason())) { + logger.debug("Prompt {} answered {} after session/cancel; sending cancelled", request.id(), + response.stopReason()); + return new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED, response.meta()); + } + return result; + } + + /** + * The answer to a prompt whose handler failed after session/cancel: stop reason + * {@code cancelled}, whatever the failure (ACP v1, prompt turn, Cancellation: agents must + * catch the errors aborted work raises and answer cancelled). The failure is logged here. + */ + private static AcpSchema.JSONRPCResponse cancelledPromptAfterFailure(AcpSchema.JSONRPCRequest request, + Throwable error) { + logger.debug("Prompt {} failed after session/cancel; answering cancelled", request.id(), error); + return cancelledPrompt(request); } /** * Extracts the sessionId from request parameters, which arrive as a {@code Map} from * the JSON transports and as the typed record from in-process transports. */ - private String extractSessionId(Object params) { + private String extractSessionId(@Nullable Object params) { + // The typed records come from in-process callers, which no nullness checker may + // have seen; a null key would fail the activePrompts map. if (params instanceof AcpSchema.PromptRequest promptRequest) { return promptRequest.sessionId() != null ? promptRequest.sessionId() : "unknown"; } if (params instanceof AcpSchema.CancelNotification cancelNotification) { return cancelNotification.sessionId() != null ? cancelNotification.sessionId() : "unknown"; } + if (params instanceof AcpSchema.CloseSessionRequest closeSessionRequest) { + return closeSessionRequest.sessionId() != null ? closeSessionRequest.sessionId() : "unknown"; + } if (params instanceof Map map) { Object sessionId = map.get("sessionId"); return sessionId != null ? sessionId.toString() : "unknown"; @@ -327,7 +363,7 @@ private String extractSessionId(Object params) { return "unknown"; } - record MethodNotFoundError(String method, String message, Object data) { + record MethodNotFoundError(String method, String message, @Nullable Object data) { } private MethodNotFoundError getMethodNotFoundError(String method) { @@ -336,37 +372,28 @@ private MethodNotFoundError getMethodNotFoundError(String method) { /** * Handles an incoming JSON-RPC notification by routing it to the appropriate handler. - * For session/cancel notifications, clears the active prompt. + * A session/cancel notification does not end the active prompt's turn: the cancelled + * prompt's response does (see ActivePrompts#cancel). A $/cancel_request cancels the + * request it names, which is then answered with -32800 unless it already answered; a + * cancelled prompt's turn ends before that answer is published. * @param notification The incoming JSON-RPC notification * @return A Mono that completes when the notification is processed */ private Mono handleIncomingNotification(AcpSchema.JSONRPCNotification notification) { return Mono.defer(() -> { - // Handle cancel notification specially - if (AcpSchema.METHOD_SESSION_CANCEL.equals(notification.method())) { - String sessionId = extractSessionId(notification.params()); - ActivePrompt current = activePrompts.remove(sessionId); - if (current != null) { - logger.debug("Cancelled active prompt for session: {}", sessionId); - } + // $/cancel_request: answered by the session itself, never by a handler + if (AcpSchema.METHOD_CANCEL_REQUEST.equals(notification.method())) { + this.inbound.cancel(notification); + return Mono.empty(); } - var handler = notificationHandlers.get(notification.method()); - if (handler == null) { - logger.warn("No handler registered for notification method: {}", notification.method()); - return Mono.empty(); + // session/cancel: the turn stays until the cancelled prompt answers + if (AcpSchema.METHOD_SESSION_CANCEL.equals(notification.method())) { + activePrompts.cancel(extractSessionId(notification.params())); } - return handler.handle(notification.params()); - }); - } - /** - * Generates a unique request ID in a non-blocking way. Combines a session-specific - * prefix with an atomic counter to ensure uniqueness. - * @return A unique request ID string - */ - private String generateRequestId() { - return this.sessionPrefix + "-" + this.requestCounter.getAndIncrement(); + return InboundMessages.deliver(logger, notification, this.notificationHandlers, NotificationHandler::handle); + }); } /** @@ -376,49 +403,12 @@ private String generateRequestId() { * @param method the name of the method to call on the client * @param requestParams the parameters to send with the request * @param typeRef the TypeReference describing the expected response type - * @return a Mono that will emit the response when received + * @return a Mono that will emit the response when received; an error response fails + * it with {@link AcpError} */ @Override public Mono sendRequest(String method, Object requestParams, TypeRef typeRef) { - String requestId = this.generateRequestId(); - - return Mono.deferContextual(ctx -> Mono.create(pendingResponseSink -> { - Throwable failure = this.startFailure; - if (failure != null) { - pendingResponseSink.error(notStarted(failure)); - return; - } - logger.debug("Sending request for method {} with id {}", method, requestId); - this.pendingResponses.put(requestId, pendingResponseSink); - // Re-check after registering: a failure recorded between the check above and the - // put may already have dismissed the map without seeing this request. - Throwable lateFailure = this.startFailure; - if (lateFailure != null) { - this.pendingResponses.remove(requestId); - pendingResponseSink.error(notStarted(lateFailure)); - return; - } - AcpSchema.JSONRPCRequest jsonrpcRequest = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, requestId, - method, requestParams); - this.transport.sendMessage(jsonrpcRequest).contextWrite(ctx).subscribe(v -> { - }, error -> { - this.pendingResponses.remove(requestId); - pendingResponseSink.error(error); - }); - })).transform(response -> AcpSchedulers.withTimeout(response, this.requestTimeout)).handle((jsonRpcResponse, deliveredResponseSink) -> { - if (jsonRpcResponse.error() != null) { - logger.error("Error handling request: {}", jsonRpcResponse.error()); - deliveredResponseSink.error(new AcpError(jsonRpcResponse.error())); - } - else { - if (typeRef.getType().equals(Void.class)) { - deliveredResponseSink.complete(); - } - else { - deliveredResponseSink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef)); - } - } - }); + return this.outbound.sendRequest(method, requestParams, typeRef); } /** @@ -429,16 +419,8 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t * @return a Mono that completes when the notification is sent */ @Override - public Mono sendNotification(String method, Object params) { - return Mono.defer(() -> { - Throwable failure = this.startFailure; - if (failure != null) { - return Mono.error(notStarted(failure)); - } - AcpSchema.JSONRPCNotification jsonrpcNotification = new AcpSchema.JSONRPCNotification( - AcpSchema.JSONRPC_VERSION, method, params); - return this.transport.sendMessage(jsonrpcNotification); - }); + public Mono sendNotification(String method, @Nullable Object params) { + return this.outbound.sendNotification(method, params); } /** @@ -457,7 +439,25 @@ public boolean hasActivePrompt() { */ public boolean hasActivePrompt(String sessionId) { Assert.hasText(sessionId, "The sessionId can not be empty"); - return activePrompts.containsKey(sessionId); + return activePrompts.isActive(sessionId); + } + + /** + * Whether the prompt now running on {@code sessionId} has been answered, by its handler or by + * the session when a prompt deadline passed, as the session sees it later: the SDK's prompt + * contexts ask it before each update, since ACP requires a prompt's updates to precede its + * answer. The answer is about the prompt running at this call; when none is, it is always + * {@code false}. + * @param sessionId the logical ACP session ID + * @return a check, true once that prompt has been answered + */ + public BooleanSupplier promptAnswered(String sessionId) { + Assert.hasText(sessionId, "The sessionId can not be empty"); + ActivePrompts.Turn turn = activePrompts.current(sessionId); + if (turn == null) { + return () -> false; + } + return turn::isAnswered; } /** @@ -469,8 +469,8 @@ public boolean hasActivePrompt(String sessionId) { *

    * @return one active session ID or null if no prompt is active */ - public String getActivePromptSessionId() { - return activePrompts.keySet().stream().findFirst().orElse(null); + public @Nullable String getActivePromptSessionId() { + return activePrompts.anySessionId(); } /** @@ -478,7 +478,7 @@ public String getActivePromptSessionId() { * @return an immutable snapshot of active prompt session IDs */ public Set getActivePromptSessionIds() { - return Set.copyOf(activePrompts.keySet()); + return activePrompts.sessionIds(); } /** @@ -488,6 +488,7 @@ public Set getActivePromptSessionIds() { @Override public Mono closeGracefully() { return Mono.fromRunnable(() -> { + this.inbound.cancelAll(); activePrompts.clear(); dismissPendingResponses(); }).then(this.transport.closeGracefully()); @@ -498,61 +499,10 @@ public Mono closeGracefully() { */ @Override public void close() { + this.inbound.cancelAll(); activePrompts.clear(); dismissPendingResponses(); transport.close(); } - /** - * ACP-specific error wrapper for JSON-RPC errors. - * Provides detailed error information including code, message, and data. - */ - public static class AcpError extends RuntimeException { - - private final AcpSchema.JSONRPCError error; - - public AcpError(AcpSchema.JSONRPCError error) { - super(buildErrorMessage(error)); - this.error = error; - } - - private static String buildErrorMessage(AcpSchema.JSONRPCError error) { - StringBuilder sb = new StringBuilder(); - sb.append(error.message()); - sb.append(" [code=").append(error.code()).append("]"); - if (error.data() != null) { - sb.append(": ").append(formatErrorData(error.data())); - } - return sb.toString(); - } - - private static String formatErrorData(Object data) { - if (data instanceof java.util.Map map) { - // Extract common fields for better readability - Object details = map.get("details"); - if (details != null) { - return details.toString(); - } - Object reason = map.get("reason"); - if (reason != null) { - return reason.toString(); - } - } - return data.toString(); - } - - public AcpSchema.JSONRPCError getError() { - return error; - } - - public int getCode() { - return error.code(); - } - - public Object getData() { - return error.data(); - } - - } - } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentTransport.java index bcbca171..2eddbcfa 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpAgentTransport.java @@ -10,14 +10,27 @@ import reactor.core.publisher.Mono; /** - * Interface for the agent side of the {@link AcpTransport}. It allows setting handlers - * for messages that are incoming from the ACP client and hooking in to exceptions raised - * on the transport layer. + * The agent side of an ACP connection: the transport through which an agent built with + * {@link com.agentclientprotocol.sdk.agent.AcpAgent} serves one client. Pass it to + * {@link com.agentclientprotocol.sdk.agent.AcpAgent#sync(AcpAgentTransport) + * AcpAgent.sync(transport)} or + * {@link com.agentclientprotocol.sdk.agent.AcpAgent#async(AcpAgentTransport) + * AcpAgent.async(transport)}; starting the agent starts the transport, and the agent's + * {@code awaitTermination()}, which {@code AcpSyncAgent.run()} blocks on, waits for this + * transport's {@link #awaitTermination()}. * - *

    - * The agent transport is used by agent implementations that want to receive requests from - * code editors/clients through the Agent Client Protocol. - *

    + *

    {@link com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport} serves the + * client that started the agent process. Listeners that serve many clients over the network + * are not agent transports themselves: the {@code acp-streamable-http-jetty} module's + * {@code StreamableHttpAcpAgentTransport} and {@code StreamableHttpAcpServlet} make one per + * connection ({@link com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection}) and hand + * it to an {@link com.agentclientprotocol.sdk.agent.AcpAgentFactory}. For tests, the + * {@code acp-test} module's {@code InMemoryTransportPair} connects an agent and a client inside + * one JVM. + * + *

    Implementations carry one connection each and must refuse a second {@link #start}. They + * pass each message from the client to the session's handler and write what the handler's + * Mono emits, the response to a request, back to the client. * * @author Mark Pollack * @author Christian Tzolov @@ -25,34 +38,40 @@ public interface AcpAgentTransport extends AcpTransport { /** - * Used to register the incoming messages' handler and start listening for client - * connections. - * @param handler a transformer for incoming messages - * @return a {@link Mono} that terminates upon successful agent setup. The successful - * termination of the returned {@link Mono} simply means the agent can now receive - * requests. An error can be retried according to the application requirements. + * Starts reading messages from the client and registers the handler that answers them. + * The agent session calls it once, when the agent starts. + * @param handler receives each message from the client as a one-element Mono, and emits + * the response to write back, or nothing for a notification or a response + * @return a Mono that completes once the transport can receive and send; it errors when + * the transport cannot start or was started before */ Mono start(Function, Mono> handler); /** - * Sets the exception handler for exceptions raised on the transport layer. - * @param handler Allows reacting to transport level exceptions by the higher layers + * Sets the handler for errors that belong to no single request: a line or frame from the + * client that is not a JSON-RPC message, or a failed read or write. The SDK's agents + * install none; its transports log such errors by default. The interface's default + * ignores the handler. + * @param handler receives the transport's errors */ default void setExceptionHandler(Consumer handler) { } /** - * Returns a Mono that completes when the transport terminates. - * This is useful for agents that need to block until the transport is done, - * particularly when using daemon threads. + * Returns a Mono that completes when the transport has ended: it was closed, or the client + * went away (for stdio, once standard input ended and every request received before it was + * answered). An agent's main thread blocks on it so that the process stays up while the + * transport serves the client; {@code AcpSyncAgent.run()} does this for you. * - *

    Example usage: *

    {@code
    -	 * transport.start(handler).block();
    -	 * transport.awaitTermination().block(); // Block until stdin closes
    +	 * AcpAgentTransport transport = new StdioAcpAgentTransport();
    +	 * AcpSyncAgent agent = AcpAgent.sync(transport)
    +	 *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
    +	 *     .build();
    +	 * agent.start();
    +	 * transport.awaitTermination().block(); // until standard input ends and every reply is written
     	 * }
    - * - * @return a {@link Mono} that completes when the transport terminates + * @return a Mono that completes when the transport terminates */ Mono awaitTermination(); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java index d79225a7..7753674f 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientSession.java @@ -4,28 +4,26 @@ package com.agentclientprotocol.sdk.spec; +import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.error.AcpErrorCodes; -import com.agentclientprotocol.sdk.error.AcpProtocolException; import com.agentclientprotocol.sdk.util.AcpSchedulers; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.HandlerFailures; +import org.jspecify.annotations.Nullable; import org.reactivestreams.Publisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.Disposable; import reactor.core.publisher.Mono; -import reactor.core.publisher.MonoSink; import reactor.core.publisher.Sinks; import java.time.Duration; import java.util.Map; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; /** * Default implementation of the ACP (Agent Client Protocol) client session that manages @@ -65,8 +63,11 @@ public class AcpClientSession implements AcpSession { /** Transport layer implementation for message exchange */ private final AcpClientTransport transport; - /** Map of pending responses keyed by request ID */ - private final ConcurrentHashMap> pendingResponses = new ConcurrentHashMap<>(); + /** Requests and notifications sent to the agent, and the requests waiting for a response */ + private final OutboundMessages outbound; + + /** Requests from the agent still being handled, which a $/cancel_request can cancel */ + private final InboundRequests inbound = new InboundRequests(); /** Map of request handlers keyed by method name */ private final ConcurrentHashMap> requestHandlers = new ConcurrentHashMap<>(); @@ -74,16 +75,13 @@ public class AcpClientSession implements AcpSession { /** Map of notification handlers keyed by method name */ private final ConcurrentHashMap notificationHandlers = new ConcurrentHashMap<>(); - /** Session-specific prefix for request IDs */ - private final String sessionPrefix = UUID.randomUUID().toString().substring(0, 8); - - /** Atomic counter for generating unique request IDs */ - private final AtomicLong requestCounter = new AtomicLong(0); - - /** Sink for serializing notification delivery in arrival order */ - private final Sinks.Many notificationSink; + /** + * Notifications waiting for in-order delivery, and responses waiting for the notifications + * before them to be handled; completed when the session closes + */ + private final InboundOrder notifications = new InboundOrder<>(); - /** Subscription draining the notification sink via concatMap */ + /** Subscription draining the notifications in order */ private final Disposable notificationSubscription; /** Completes when the notification drain terminates; awaited by {@link #closeGracefully()} */ @@ -95,7 +93,7 @@ public class AcpClientSession implements AcpSession { * response, so every request is failed immediately with the cause instead of waiting * out the request timeout. */ - private volatile Throwable connectFailure; + private volatile @Nullable Throwable connectFailure; /** Set once this session starts closing itself, so a transport termination it caused is not reported. */ private volatile boolean closing; @@ -111,8 +109,9 @@ public interface RequestHandler { /** * Handles an incoming request with the given parameters. - * @param params The request parameters - * @return A Mono containing the response object + * @param params The request parameters; an omitted params arrives as an empty + * object + * @return A Mono containing the response object; it must not complete empty */ Mono handle(Object params); @@ -127,7 +126,8 @@ public interface NotificationHandler { /** * Handles an incoming notification with the given parameters. - * @param params The notification parameters + * @param params The notification parameters; an omitted params arrives as an empty + * object * @return A Mono that completes when the notification is processed */ Mono handle(Object params); @@ -154,6 +154,10 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, this.requestTimeout = requestTimeout; this.transport = transport; + // A response completes its caller only once the notifications that arrived before it + // have been handled: a prompt turn's updates come before its response. + this.outbound = new OutboundMessages(transport, requestTimeout, () -> this.connectFailure, + AcpClientSession::notConnected, "agent", this.notifications); this.requestHandlers.putAll(requestHandlers); this.notificationHandlers.putAll(notificationHandlers); @@ -165,17 +169,20 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, // One shared daemon timer for every session in the JVM (see AcpSchedulers). this.timeoutScheduler = AcpSchedulers.timeouts(); - // Serialize notification delivery: concatMap ensures each notification's Mono - // completes before the next one starts, preserving arrival order even when - // handlers do async work. - this.notificationSink = Sinks.many().unicast().onBackpressureBuffer(); - this.notificationSubscription = this.notificationSink.asFlux() - .concatMap(notification -> handleIncomingNotification(notification).onErrorComplete(t -> { + // Serialize notification delivery: each notification's Mono completes before the next + // one starts, preserving arrival order even when handlers do async work. A response + // is released in its place among them (InboundOrder). + this.notificationSubscription = this.notifications + .drain(notification -> handleIncomingNotification(notification).onErrorComplete(t -> { logger.error("Error handling notification: {}", t.getMessage()); return true; })) - .doFinally(signal -> this.notificationDrainTerminated.tryEmitEmpty()) - .subscribe(); + .doFinally(signal -> { + this.notifications.releaseHeld(); + this.notificationDrainTerminated.tryEmitEmpty(); + }) + .subscribe(ignored -> { + }, error -> logger.warn("Notification delivery ended with an error", error)); this.transport.connect(mono -> mono.doOnNext(this::handle).then(Mono.empty())) .transform(connectHook) @@ -187,9 +194,10 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, // whose first request would time out. Throwable failure = this.connectFailure; if (failure != null) { - this.notificationSink.tryEmitComplete(); + this.notifications.complete(); this.notificationSubscription.dispose(); - throw notConnected(failure); + // A refusal to connect at all (a reused transport) is a misuse, not a lost connection. + throw new IllegalStateException("ACP client transport is not connected: " + failure.getMessage(), failure); } // When the transport later terminates (peer gone, stream failed for good), pending @@ -198,7 +206,7 @@ public AcpClientSession(Duration requestTimeout, AcpClientTransport transport, }, this::onTransportTerminated, () -> onTransportTerminated(null)); } - private void onTransportTerminated(Throwable cause) { + private void onTransportTerminated(@Nullable Throwable cause) { if (this.closing) { return; } @@ -220,86 +228,83 @@ private void onConnectFailure(Throwable error) { dismissPendingResponses(error); } - private static IllegalStateException notConnected(Throwable cause) { - return new IllegalStateException("ACP client transport is not connected: " + cause.getMessage(), cause); + private static AcpConnectionException notConnected(Throwable cause) { + return new AcpConnectionException("ACP client transport is not connected: " + cause.getMessage(), cause); } private void dismissPendingResponses() { dismissPendingResponses(null); } - private void dismissPendingResponses(Throwable cause) { - this.pendingResponses.forEach((id, sink) -> { - logger.warn("Abruptly terminating exchange for request {}", id); - sink.error(new RuntimeException("ACP session with agent terminated", cause)); - }); - this.pendingResponses.clear(); + private void dismissPendingResponses(@Nullable Throwable cause) { + this.outbound.dismissPending(cause); } private void handle(AcpSchema.JSONRPCMessage message) { if (message instanceof AcpSchema.JSONRPCResponse response) { - logger.debug("Received response for id {}", response.id()); - if (response.id() != null) { - var sink = pendingResponses.remove(response.id()); - if (sink == null) { - logger.warn("Unexpected response for unknown id {}", response.id()); - } - else { - logger.trace("Completing pending response for id {}", response.id()); - sink.success(response); - } + this.outbound.complete(response); + } + else if (message instanceof AcpSchema.JSONRPCRequest request) { + respondTo(request); + } + else if (message instanceof AcpSchema.JSONRPCNotification notification) { + if (AcpSchema.METHOD_CANCEL_REQUEST.equals(notification.method())) { + // Not queued behind the ordered notification drain: a slow session/update + // handler must not delay cancelling a pending request. + this.inbound.cancel(notification); } else { - logger.error("Discarded ACP request response without session id. " - + "This is an indication of a bug in the request sender code that can lead to memory " - + "leaks as pending requests will never be completed."); + enqueue(notification); } } - else if (message instanceof AcpSchema.JSONRPCRequest request) { - logger.debug("Received request method={} id={}", request.method(), request.id()); - logger.trace("Incoming request method='{}' id={}", request.method(), request.id()); - handleIncomingRequest(request).onErrorResume(error -> { - // Preserve error codes from AcpProtocolException, wrap others in INTERNAL_ERROR - int errorCode; - Object errorData = null; - if (error instanceof AcpProtocolException protocolException) { - errorCode = protocolException.getCode(); - errorData = protocolException.getData(); - } - else { - errorCode = AcpErrorCodes.INTERNAL_ERROR; - } - var errorResponse = new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(errorCode, error.getMessage(), errorData)); - return Mono.just(errorResponse); - }).flatMap(this.transport::sendMessage).onErrorComplete(t -> { + else { + logger.warn("Received unknown message type: {}", message.getClass().getName()); + } + } + + /** + * Answers a request from the agent; a failed handler is answered with an error response, + * a request cancelled by $/cancel_request with -32800 unless it already answered. The + * handler is called only once the notifications that arrived before the request have been + * handled (InboundOrder), so a permission request follows the tool_call update before it; + * the request is tracked from its arrival, so a $/cancel_request reaches it while it waits. + */ + private void respondTo(AcpSchema.JSONRPCRequest request) { + logger.debug("Received request method={} id={}", request.method(), request.id()); + logger.trace("Incoming request method='{}' id={}", request.method(), request.id()); + Mono inOrder = Mono.create(sink -> this.notifications.inOrder(sink::success)); + this.inbound.track(request, inOrder.then(handleIncomingRequest(request))) + .onErrorResume(error -> Mono.just(InboundMessages.error(request, error))) + .flatMap(this.transport::sendMessage) + .onErrorComplete(t -> { logger.warn("Issue sending response to the agent, ", t); return true; - }).subscribe(); + }) + .subscribe(); + } + + /** Queues a notification for in-order delivery by the notification drain. */ + private void enqueue(AcpSchema.JSONRPCNotification notification) { + logger.debug("Received notification method={}", notification.method()); + logger.trace("Incoming notification method='{}' params={}", notification.method(), notification.params()); + Sinks.EmitResult result = this.notifications.offer(notification); + if (!result.isFailure()) { + return; } - else if (message instanceof AcpSchema.JSONRPCNotification notification) { - logger.debug("Received notification method={}", notification.method()); - logger.trace("Incoming notification method='{}' params={}", notification.method(), notification.params()); - Sinks.EmitResult result = notificationSink.tryEmitNext(notification); - if (result.isFailure()) { - if (result == Sinks.EmitResult.FAIL_TERMINATED || result == Sinks.EmitResult.FAIL_CANCELLED) { - // The session is shutting down. Refusing newly arriving notifications - // is the intended behaviour: a graceful shutdown stops accepting new - // work and drains what is already queued, and JSON-RPC notifications - // carry no delivery guarantee by design. - logger.debug("Session is closing; dropping notification method='{}' ({})", notification.method(), - result); - } - else { - // Overflow, no subscriber, or concurrent (non-serialized) emission: - // protocol traffic lost on a live session, which is never expected. - logger.error("Dropped notification method='{}': sink emission failed with {}", - notification.method(), result); - } - } + if (result == Sinks.EmitResult.FAIL_TERMINATED || result == Sinks.EmitResult.FAIL_CANCELLED + || this.notifications.isCompleting()) { + // The session is shutting down. Refusing newly arriving notifications + // is the intended behaviour: a graceful shutdown stops accepting new + // work and drains what is already queued, and JSON-RPC notifications + // carry no delivery guarantee by design. + logger.debug("Session is closing; dropping notification method='{}' ({})", notification.method(), + result); } else { - logger.warn("Received unknown message type: {}", message); + // Overflow, no subscriber, or concurrent (non-serialized) emission: + // protocol traffic lost on a live session, which is never expected. + logger.error("Dropped notification method='{}': sink emission failed with {}", notification.method(), + result); } } @@ -317,20 +322,21 @@ private Mono handleIncomingRequest(AcpSchema.JSONRPCR request.method(), error.message(), error.data() != null ? error.data() : "register a handler to support this operation"); logger.trace("Available handlers: {}", this.requestHandlers.keySet()); - return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(-32601, error.message(), error.data()))); + return Mono.just(InboundMessages.error(request, AcpErrorCodes.METHOD_NOT_FOUND, error.message(), + error.data())); } logger.debug("Invoking handler for method '{}'", request.method()); logger.trace("Handler params for '{}': {}", request.method(), request.params()); - return handler.handle(request.params()) + return InboundMessages.requireResult(HandlerFailures.invoke(() -> handler.handle(InboundMessages.paramsOrEmpty(request.params()))), + request.method()) .doOnSuccess(result -> logger.debug("Handler for '{}' completed successfully", request.method())) .doOnError(error -> logger.debug("Handler for '{}' threw error: {}", request.method(), error.getMessage())) - .map(result -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)); + .map(result -> InboundMessages.result(request, result)); }); } - record MethodNotFoundError(String method, String message, Object data) { + record MethodNotFoundError(String method, String message, @Nullable Object data) { } private MethodNotFoundError getMethodNotFoundError(String method) { @@ -364,74 +370,40 @@ private MethodNotFoundError getMethodNotFoundError(String method) { */ private Mono handleIncomingNotification(AcpSchema.JSONRPCNotification notification) { return Mono.defer(() -> { - var handler = notificationHandlers.get(notification.method()); - if (handler == null) { - logger.warn("No handler registered for notification method: {}", notification); - return Mono.empty(); - } - return handler.handle(notification.params()); + return InboundMessages.deliver(logger, notification, this.notificationHandlers, NotificationHandler::handle); }); } - /** - * Generates a unique request ID in a non-blocking way. Combines a session-specific - * prefix with an atomic counter to ensure uniqueness. - * @return A unique request ID string - */ - private String generateRequestId() { - return this.sessionPrefix + "-" + this.requestCounter.getAndIncrement(); - } - /** * Sends a JSON-RPC request and returns the response. * @param The expected response type * @param method The method name to call * @param requestParams The request parameters * @param typeRef Type reference for response deserialization - * @return A Mono containing the response + * @return A Mono containing the response; an error response fails it with {@link AcpError} */ @Override public Mono sendRequest(String method, Object requestParams, TypeRef typeRef) { - String requestId = this.generateRequestId(); + return this.outbound.sendRequest(method, requestParams, typeRef); + } - return Mono.deferContextual(ctx -> Mono.create(pendingResponseSink -> { - Throwable failure = this.connectFailure; - if (failure != null) { - pendingResponseSink.error(notConnected(failure)); - return; - } - logger.debug("Sending message for method {} with id {}", method, requestId); - logger.trace("Outgoing request method='{}' id={} params={}", method, requestId, requestParams); - this.pendingResponses.put(requestId, pendingResponseSink); - // Re-check after registering: a failure recorded between the check above and the - // put may already have dismissed the map without seeing this request. - Throwable lateFailure = this.connectFailure; - if (lateFailure != null) { - this.pendingResponses.remove(requestId); - pendingResponseSink.error(notConnected(lateFailure)); - return; - } - AcpSchema.JSONRPCRequest jsonrpcRequest = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, requestId, - method, requestParams); - this.transport.sendMessage(jsonrpcRequest).contextWrite(ctx).subscribe(v -> { - }, error -> { - this.pendingResponses.remove(requestId); - pendingResponseSink.error(error); - }); - })).transform(response -> AcpSchedulers.withTimeout(response, this.requestTimeout)).handle((jsonRpcResponse, deliveredResponseSink) -> { - if (jsonRpcResponse.error() != null) { - logger.error("Error handling request: {}", jsonRpcResponse.error()); - deliveredResponseSink.error(new AcpError(jsonRpcResponse.error())); - } - else { - if (typeRef.getType().equals(Void.class)) { - deliveredResponseSink.complete(); - } - else { - deliveredResponseSink.next(this.transport.unmarshalFrom(jsonRpcResponse.result(), typeRef)); - } - } - }); + /** + * Sends a JSON-RPC request with a timeout of its own instead of the session's request + * timeout. The client sends {@code session/prompt} this way, since a prompt's answer comes + * only at the end of its turn. When {@code timeout} passes, the request fails with a + * {@link java.util.concurrent.TimeoutException} and the agent is sent a + * {@code $/cancel_request}, as with the request timeout. + * @param The expected response type + * @param method The method name to call + * @param requestParams The request parameters + * @param typeRef Type reference for response deserialization + * @param timeout how long to wait for the response; {@code null} waits until it arrives, the + * caller disposes the request, or the transport fails + * @return A Mono containing the response; an error response fails it with {@link AcpError} + */ + public Mono sendRequest(String method, Object requestParams, TypeRef typeRef, + @Nullable Duration timeout) { + return this.outbound.sendRequest(method, requestParams, typeRef, timeout); } /** @@ -441,16 +413,8 @@ public Mono sendRequest(String method, Object requestParams, TypeRef t * @return A Mono that completes when the notification is sent */ @Override - public Mono sendNotification(String method, Object params) { - return Mono.defer(() -> { - Throwable failure = this.connectFailure; - if (failure != null) { - return Mono.error(notConnected(failure)); - } - AcpSchema.JSONRPCNotification jsonrpcNotification = new AcpSchema.JSONRPCNotification( - AcpSchema.JSONRPC_VERSION, method, params); - return this.transport.sendMessage(jsonrpcNotification); - }); + public Mono sendNotification(String method, @Nullable Object params) { + return this.outbound.sendNotification(method, params); } /** @@ -461,8 +425,10 @@ public Mono sendNotification(String method, Object params) { public Mono closeGracefully() { return Mono.fromRunnable(() -> { this.closing = true; + this.inbound.cancelAll(); dismissPendingResponses(); - notificationSink.tryEmitComplete(); + // Never lost to a notification being delivered concurrently (NotificationQueue). + this.notifications.complete(); }) // Wait for queued notifications to drain before tearing the session down; // disposing immediately would discard them. Bounded so a handler that never @@ -480,61 +446,12 @@ public Mono closeGracefully() { @Override public void close() { this.closing = true; + this.inbound.cancelAll(); dismissPendingResponses(); - notificationSink.tryEmitComplete(); + this.notifications.complete(); notificationSubscription.dispose(); - } - - /** - * ACP-specific error wrapper for JSON-RPC errors. - * Provides detailed error information including code, message, and data. - */ - public static class AcpError extends RuntimeException { - - private final AcpSchema.JSONRPCError error; - - public AcpError(AcpSchema.JSONRPCError error) { - super(buildErrorMessage(error)); - this.error = error; - } - - private static String buildErrorMessage(AcpSchema.JSONRPCError error) { - StringBuilder sb = new StringBuilder(); - sb.append(error.message()); - sb.append(" [code=").append(error.code()).append("]"); - if (error.data() != null) { - sb.append(": ").append(formatErrorData(error.data())); - } - return sb.toString(); - } - - private static String formatErrorData(Object data) { - if (data instanceof java.util.Map map) { - // Extract common fields for better readability - Object details = map.get("details"); - if (details != null) { - return details.toString(); - } - Object reason = map.get("reason"); - if (reason != null) { - return reason.toString(); - } - } - return data.toString(); - } - - public AcpSchema.JSONRPCError getError() { - return error; - } - - public int getCode() { - return error.code(); - } - - public Object getData() { - return error.data(); - } - + // A response held behind a notification still being handled completes now. + this.notifications.releaseHeld(); } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientTransport.java index e50a3741..de605ed5 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpClientTransport.java @@ -10,14 +10,24 @@ import reactor.core.publisher.Mono; /** - * Interface for the client side of the {@link AcpTransport}. It allows setting handlers - * for messages that are incoming from the ACP agent and hooking in to exceptions raised - * on the transport layer. + * The client side of an ACP connection: the transport through which a client built with + * {@link com.agentclientprotocol.sdk.client.AcpClient} reaches its agent. Create one per agent + * connection and pass it to {@code AcpClient.sync(transport)} or + * {@code AcpClient.async(transport)}; building the client connects the transport, and + * closing the client closes it. * - *

    - * The client transport is used by applications that want to communicate with an external - * coding agent through the Agent Client Protocol. - *

    + *

    The SDK has three. + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport} starts the agent + * as a child process; + * {@link com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport} and + * {@link com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport} reach an + * agent that is already running behind a URL. For tests, the {@code acp-test} module's + * {@code InMemoryTransportPair} connects a client and an agent inside one JVM. + * + *

    Implementations carry one connection each and must refuse a second {@link #connect}. The + * client session passes the agent's messages to the handler given to {@code connect} and + * sends its answers with {@link #sendMessage}; its handler returns an empty Mono, so an + * implementation need not write what the handler returns. * * @author Mark Pollack * @author Christian Tzolov @@ -25,30 +35,30 @@ public interface AcpClientTransport extends AcpTransport { /** - * Used to register the incoming messages' handler and potentially (eagerly) connect - * to the agent. - * @param handler a transformer for incoming messages - * @return a {@link Mono} that terminates upon successful client setup. It can mean - * establishing a connection (which can be later disposed) but it doesn't have to, - * depending on the transport type. The successful termination of the returned - * {@link Mono} simply means the client can now be used. An error can be retried - * according to the application requirements. + * Connects to the agent, or prepares to, and registers the handler for every message the + * agent sends. The client session calls it once, when the client is built. + * @param handler receives each message from the agent as a one-element Mono + * @return a Mono that completes once the transport can be used, which need not mean that a + * connection is open (the Streamable HTTP transport opens it with the first request); it + * errors when the connection cannot be made or the transport was connected before */ Mono connect(Function, Mono> handler); /** - * Sets the exception handler for exceptions raised on the transport layer. - * @param handler Allows reacting to transport level exceptions by the higher layers + * Sets the handler for errors that belong to no single request: a line or frame from the + * agent that is not a JSON-RPC message, or a failed read or write. The SDK installs none; + * its transports log such errors by default. The interface's default ignores the handler. + * @param handler receives the transport's errors */ default void setExceptionHandler(Consumer handler) { } /** - * Completes when this transport can no longer deliver messages: the peer closed the - * connection, the transport failed for good, or it was closed locally. Errors with the - * cause when it failed. The session uses it to fail pending requests at once instead - * of waiting out the request timeout. The default never completes, which is the - * behaviour of transports that do not report termination. + * Returns a Mono that completes when this transport can no longer deliver messages: the + * agent closed the connection, the transport failed for good, or it was closed locally. + * It errors with the cause when the transport failed. The client session waits on it to + * fail pending requests at once instead of letting them run into the request timeout. + * The interface's default never completes, as suits a transport that cannot tell. * @return a Mono that terminates when the transport does */ default Mono awaitTermination() { diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpError.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpError.java new file mode 100644 index 00000000..933d2cf9 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpError.java @@ -0,0 +1,188 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpException; +import org.jspecify.annotations.Nullable; + +/** + * The failure a caller catches when a request it sent did not succeed: the peer answered with a + * JSON-RPC error, or answered with a result the SDK rejected. It fails the request's {@code Mono} + * on the asynchronous API and is thrown as it is by the sync API, on the client side and the agent + * side alike, so one {@code catch (AcpError e)} covers both cases. Branch on {@link #getCode()} + * (see {@link AcpErrorCodes}). To answer a request with an error from a handler, throw + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} instead. + * + *

    The two cases, which the data tells apart: + *

      + *
    • The peer answered with an error. {@link #getCode()}, the message and {@link #getData()} are + * the peer's. A Java peer answers {@code -32601} ("Method not found") when it has no handler for + * the method, {@code -32602} ("Invalid params") when it cannot read the params, {@code -32800} when + * the request was cancelled, and {@code -32603} when its handler failed; a handler can answer any + * code it chooses.
    • + *
    • The peer answered with a success response the SDK rejected: the result lacks a field the ACP + * schema requires, there is no result, or it cannot be read as the method's result type. The code + * is {@code -32603} (internal error; JSON-RPC 2.0 defines none for an invalid response), the + * message names the method and the problem, and {@link #getData()} is a map whose {@code "reason"} + * is {@code "missing-required-field"} (with the field's path under {@code "field"}), + * {@code "missing-result"} or {@code "unreadable-result"}, and whose {@code "method"} names the + * request's method. A prompt context's {@code askChoice} answered with an option it did not offer + * fails the same way, with reason {@code "unoffered-option"} and the {@code "optionId"}. Nothing is + * sent back to the peer.
    • + *
    + * + *

    Other failures of a request have their own types: + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException AcpCapabilityException} for a + * call the peer did not advertise, refused before sending; + * {@link com.agentclientprotocol.sdk.error.AcpConnectionException AcpConnectionException} for a + * request the connection could not carry: one waiting when the connection ended, or sent after it + * ended or the transport closed; and a + * {@link java.util.concurrent.TimeoutException} for a request that got no answer in time, which the + * sync API throws as + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException AcpTimeoutException}. All of these, + * and this class, extend {@link AcpException}, so {@code catch (AcpException e)} covers every way + * a request can fail in the SDK; catch this class to handle the peer's answer alone. + * + *

    A handler that lets an {@code AcpError} escape answers its own request with the error this + * exception carries, unchanged: the peer's code, message and data, or for a rejected response the + * SDK's {@code -32603} and data. So a handler that calls the other side and has nothing to add + * just lets the failure propagate. To answer with a different error, catch it and throw an + * {@link com.agentclientprotocol.sdk.error.AcpProtocolException} instead. + * + *

    {@link #getMessage()} is the peer's message, followed by the detail its data carries, if any; + * for a rejected response it is the SDK's message alone. It leaves out the code, which + * {@link #getCode()} returns, so {@code e.getCode() + " " + e.getMessage()} names it once; + * {@link #toString()}, which stack traces show, includes it. + * + *

    Example, a client that falls back when the agent does not know an extension method: + *

    {@code
    + * Object buffers;
    + * try {
    + *     buffers = client.sendExtRequest("_example.com/workspace/buffers", Map.of());
    + * }
    + * catch (AcpError e) {
    + *     if (e.getCode() != AcpErrorCodes.METHOD_NOT_FOUND) {
    + *         throw e;
    + *     }
    + *     buffers = Map.of();
    + * }
    + * }
    + */ +public class AcpError extends AcpException { + + /** The error the peer sent, or the SDK built for a rejected response. */ + private final AcpSchema.JSONRPCError error; + + /** + * Creates the failure for an error response from the peer. The SDK creates it when a request is + * answered with an error; an application needs it only to fake a failed request, in a test for + * example. + * @param error the error as the peer sent it + */ + public AcpError(AcpSchema.JSONRPCError error) { + super(buildErrorMessage(error, false)); + this.error = error; + } + + private AcpError(AcpSchema.JSONRPCError error, String message) { + super(message); + this.error = error; + } + + /** + * Returns the failure for a success response the SDK rejected: code {@code -32603}, the given + * message as it is, and data naming the reason and the method, followed by the detail (see the + * class description). The SDK calls it from more than one of its packages, which is why it is + * public. + * @param method the method of the request whose response was rejected + * @param reason why, for example {@code "missing-required-field"} + * @param message the message, naming the method and the problem + * @param detail more data entries, for example the missing field's path; empty for none + * @return the failure + */ + public static AcpError rejectedResponse(String method, String reason, String message, Map detail) { + Map data = new LinkedHashMap<>(); + data.put("reason", reason); + data.put("method", method); + data.putAll(detail); + return new AcpError(new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, message, + Collections.unmodifiableMap(data)), message); + } + + /** + * Returns the class name, the message, the code and the data's detail, as stack traces show + * them, for example + * {@code com.agentclientprotocol.sdk.spec.AcpError: No such session [code=-32002]}. Unlike + * {@link #getMessage()}, it includes the code. + * @return the description + */ + // getMessage() leaves the code out on purpose (getCode() returns it); a stack trace and a + // log of the exception itself still name it. + @SuppressWarnings("OverrideThrowableToString") + @Override + public String toString() { + return getClass().getName() + ": " + buildErrorMessage(this.error, true); + } + + private static String buildErrorMessage(AcpSchema.JSONRPCError error, boolean withCode) { + StringBuilder sb = new StringBuilder(); + sb.append(error.message()); + if (withCode) { + sb.append(" [code=").append(error.code()).append("]"); + } + if (error.data() != null) { + sb.append(": ").append(formatErrorData(error.data())); + } + return sb.toString(); + } + + private static String formatErrorData(Object data) { + if (data instanceof java.util.Map map) { + // Extract common fields for better readability + Object details = map.get("details"); + if (details != null) { + return details.toString(); + } + Object reason = map.get("reason"); + if (reason != null) { + return reason.toString(); + } + } + return data.toString(); + } + + /** + * Returns the JSON-RPC error itself: its code, message and data, as the peer sent them or as + * the SDK built them for a rejected response. + * @return the error + */ + public AcpSchema.JSONRPCError getError() { + return error; + } + + /** + * Returns the error code, such as {@link AcpErrorCodes#METHOD_NOT_FOUND}. + * @return the code + */ + public int getCode() { + return error.code(); + } + + /** + * Returns the error's data: the detail the peer added, any JSON value (a {@code Map} for an + * object), or for a rejected response the map the class description lists. + * @return the data, or {@code null} if there is none + */ + public @Nullable Object getData() { + return error.data(); + } + +} + diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSchema.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSchema.java index 8923faac..3cb43812 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSchema.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSchema.java @@ -5,26 +5,104 @@ package com.agentclientprotocol.sdk.spec; import java.io.IOException; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Function; import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonValue; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Agent Client Protocol (ACP) Schema based on - * Agent Client Protocol specification. + * The ACP v1 messages as Java records, with the method names and the JSON-RPC 2.0 envelope + * that carries them. Each request, response and notification a client and an agent exchange + * is a nested type here: {@link PromptRequest} and {@link PromptResponse} for + * {@code session/prompt}, {@link SessionNotification} for {@code session/update}, and so on. + * Application code builds and reads these records; the client and agent facades take and + * return them, and the transports turn them into JSON with an {@link AcpJsonMapper}. * - * This schema defines all request, response, and notification types used in ACP. ACP is a - * protocol for communication between code editors (clients) and coding agents. + *

    How the types are named

    + *

    + * Each method has a {@code METHOD_*} constant with its wire name. A request method has a + * params record whose name ends in {@code Request} and a result record whose name ends in + * {@code Response}: {@link #METHOD_SESSION_PROMPT} has {@link PromptRequest} and + * {@link PromptResponse}. A notification has a params record whose name ends in + * {@code Notification}. A record's components are its JSON properties (the {@code Unknown*} + * records described below also keep a map of the fields they do not know), and {@code meta}, + * the {@code _meta} property reserved for extensions, is the last component of every record + * the schema gives it. The canonical constructor takes every component; a shorter + * constructor leaves some optional ones out, usually as {@code null}. + * + *

    Required and optional components

    + *

    + * A component marked {@code @Nullable} is optional in the schema and is left out of the JSON + * when it is {@code null}. Every other component is required and must not be {@code null}. + * The SDK checks this for the params of inbound requests and notifications: params without a + * required component never reach a handler. A request is answered with {@code -32602} + * (Invalid params), and a notification is logged and skipped. The result of a response is + * checked the same way: a result without a required component fails the request with + * {@code -32603} (Internal error), naming the component. + * + *

    Forward compatibility

    + *

    + * A peer on a newer protocol version can send a union variant or an enumeration value this + * SDK does not know. It never fails the message that carries it: + *

      + *
    • A union whose schema names a default variant reads an unknown variant as that default: + * an MCP server without a known {@code type} is a {@link McpServerStdio}, and an + * authentication method without a known {@code type} is an {@link AuthMethodAgent}.
    • + *
    • Every other union reads an unknown variant as its {@code Unknown*} record + * ({@link UnknownSessionUpdate}, {@link UnknownContentBlock}, + * {@link UnknownToolCallContent}, {@link UnknownSessionConfigOption}, + * {@link UnknownPermissionOutcome}, {@link UnknownElicitationPropertySchema}). The record + * keeps the discriminator and every other field and writes them back unchanged, so a proxy + * forwards what it received and an application can log or count what it does not understand. + * A receiver that does not understand the variant ignores it, as the schema's + * {@code x-deserialize-skip-invalid-items} asks for list items.
    • + *
    • {@link MultiSelectItems} is the exception: its shapes have no shared discriminator, so + * the SDK tells them apart by their members. Items of an unknown {@code type} read as + * {@link UntitledMultiSelectItems} and lose the members that record does not have; only items + * with no {@code type}, {@code enum} or {@code anyOf} member read as + * {@link UnknownMultiSelectItems}.
    • + *
    • {@link ToolKind}, whose schema has the catch-all value {@code other}, reads an unknown + * kind as {@link ToolKind#OTHER}. It is a Java enum.
    • + *
    • Every other enumeration ({@link StopReason}, {@link ToolCallStatus}, + * {@link PermissionOptionKind}, {@link PlanEntryStatus}, {@link PlanEntryPriority}, + * {@link Role}, {@link ElicitationAction}) is an open value: a record over the wire string, + * with a constant for each value ACP v1 defines. An unknown value is kept and written back + * unchanged, and its {@code isKnown()} is {@code false}. Compare open values with + * {@code equals}, not {@code ==}, or switch on {@code value()}.
    • + *
    • Open strings in the schema stay {@code String}: a config option's {@code category}, + * whose reserved values are the constants of {@link SessionConfigOptionCategory}, and a + * string property's {@code format}.
    • + *
    + *

    + * Only {@link JSONRPCMessage} is sealed. The union interfaces are not, so a chain of + * {@code instanceof} checks over a union is never complete: handle the {@code Unknown*} + * record and end with a branch for anything else. + *

    + * Each union variant record has its discriminator as a component (for example + * {@code TextContent.type}). Pass {@code null} for it, or use a shorter constructor, and it + * becomes the variant's own name; any other value is rejected with + * {@link IllegalArgumentException}, so a record never claims to be a different variant. * * @author Mark Pollack * @author Christian Tzolov @@ -39,19 +117,152 @@ public final class AcpSchema { private AcpSchema() { } + /** + * The discriminator a known union variant writes: {@code expected} when the caller + * passed {@code null}, so the convenience of not repeating it is kept. + * @throws IllegalArgumentException when the caller passed a different name, which + * would otherwise go on the wire as is + */ + static String discriminator(@Nullable String given, String expected) { + if (given == null) { + return expected; + } + if (!given.equals(expected)) { + throw new IllegalArgumentException( + "Discriminator '" + given + "' does not name this variant; expected '" + expected + "'"); + } + return given; + } + + /** + * The fields of an unknown union variant, copied and unmodifiable, in wire order. + */ + static Map unknownFields(@Nullable Map fields) { + // Null when the variant had no fields besides its discriminator under some mappers + return fields == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(fields)); + } + + /** + * A value a builder requires. + * @throws IllegalStateException when it was not set + */ + static T required(@Nullable T value, String name) { + if (value == null) { + throw new IllegalStateException(name + " is required"); + } + return value; + } + + /** + * The known value whose wire value is {@code value}, or a new one: the factory of the + * open enum-like values. + */ + static T knownOrNew(List known, Function wire, String value, Function create) { + Objects.requireNonNull(value, "value"); + for (T candidate : known) { + if (wire.apply(candidate).equals(value)) { + return candidate; + } + } + return create.apply(value); + } + + /** The JSON-RPC version every message carries in its {@code jsonrpc} member: {@value}. */ public static final String JSONRPC_VERSION = "2.0"; + /** + * The ACP protocol version this SDK speaks: {@value}. The client sends it in + * {@link InitializeRequest#protocolVersion()} unless told otherwise, and + * {@link InitializeResponse#ok()} answers with the same version. + */ public static final int LATEST_PROTOCOL_VERSION = 1; /** - * Deserializes a JSON-RPC message from a JSON string into the appropriate message - * type (request, response, or notification). - * @param jsonMapper The JSON mapper to use for deserialization - * @param jsonText The JSON text to deserialize - * @return The deserialized JSON-RPC message - * @throws IOException If deserialization fails - * @throws IllegalArgumentException If the JSON structure doesn't match any known - * message type + * Returns the error response JSON-RPC 2.0 prescribes for a message that could not be read: + * {@code -32700} (Parse error) when the text is not JSON, {@code -32600} (Invalid Request) + * when it is JSON but not a valid JSON-RPC message. The response carries the request's id + * when the text is a request whose id can be read, and otherwise a {@code null} id, written + * as {@code "id": null}. The SDK's transports send it when + * {@link #deserializeJsonRpcMessage} refuses a message; a custom transport can do the same. + * @param jsonMapper the JSON mapper that failed to read the message + * @param jsonText the text that {@link #deserializeJsonRpcMessage} refused + * @return the error response to send to the peer + */ + public static JSONRPCResponse unreadableMessageResponse(AcpJsonMapper jsonMapper, String jsonText) { + boolean isJson; + try { + jsonMapper.readValue(jsonText, Object.class); + isJson = true; + } + catch (IOException e) { + isJson = false; + } + JSONRPCError error = isJson ? new JSONRPCError(AcpErrorCodes.INVALID_REQUEST, "Invalid Request", null) + : new JSONRPCError(AcpErrorCodes.PARSE_ERROR, "Parse error", null); + return new JSONRPCResponse(JSONRPC_VERSION, isJson ? readableRequestId(jsonMapper, jsonText) : null, null, + error); + } + + /** + * The id of an invalid request, when it can be read: JSON-RPC 2.0 section 5 answers with + * null only when the id could not be detected. Only a message with a method is a request; + * an unreadable response is never answered with its own id. + */ + private static @Nullable Object readableRequestId(AcpJsonMapper jsonMapper, String jsonText) { + try { + Map map = jsonMapper.readValue(jsonText, MAP_TYPE_REF); + if (map == null || !map.containsKey("method")) { + return null; + } + Object id = map.get("id"); + return isValidId(id) ? id : null; + } + catch (IOException | RuntimeException e) { + return null; + } + } + + /** JSON-RPC 2.0 section 4: an id is a string, a number or null. */ + private static boolean isValidId(@Nullable Object id) { + return id == null || id instanceof String || id instanceof Number; + } + + /** + * Refuses a request or notification that JSON-RPC 2.0 section 4 does not allow: + * {@code jsonrpc} other than "2.0", a method that is not a string, or an id that is not a + * string, number or null. Its answer is -32600 Invalid Request. The exception names the + * member at fault and never quotes the message: a transport logs it, and the message is the + * peer's payload. + */ + private static void validateRequest(Map message) { + if (!JSONRPC_VERSION.equals(message.get("jsonrpc"))) { + throw new IllegalArgumentException("Invalid Request: jsonrpc must be \"2.0\""); + } + if (!(message.get("method") instanceof String)) { + throw new IllegalArgumentException("Invalid Request: method must be a string"); + } + if (!isValidId(message.get("id"))) { + throw new IllegalArgumentException("Invalid Request: id must be a string, number or null"); + } + } + + /** + * Reads one JSON-RPC message from its JSON text. An object with a {@code method} is a + * {@link JSONRPCRequest} when it has an {@code id} and a {@link JSONRPCNotification} when it + * has none; an object with an {@code id}, a {@code result} or an {@code error} but no method + * is a {@link JSONRPCResponse}. Params and results stay as the mapper read them (maps, lists + * and plain values); the protocol sessions convert them to the method's record. The SDK's + * transports call this for each message they receive and answer a failure with + * {@link #unreadableMessageResponse}. + * @param jsonMapper the JSON mapper to read with + * @param jsonText the text of one message + * @return the message + * @throws IOException if the text is not JSON or not a JSON object; a JSON-RPC batch (an + * array) is not supported and fails here + * @throws IllegalArgumentException if the text is the JSON {@code null} literal, an object + * with none of {@code method}, {@code id}, {@code result} and {@code error}, or a request or + * notification JSON-RPC 2.0 does not allow: a {@code jsonrpc} other than {@code "2.0"}, a + * method that is not a string, or an id that is not a string, a number or {@code null} */ public static JSONRPCMessage deserializeJsonRpcMessage(AcpJsonMapper jsonMapper, String jsonText) throws IOException { @@ -59,94 +270,230 @@ public static JSONRPCMessage deserializeJsonRpcMessage(AcpJsonMapper jsonMapper, logger.debug("Received JSON message ({} characters)", jsonText.length()); var map = jsonMapper.readValue(jsonText, MAP_TYPE_REF); - - // Determine message type based on specific JSON structure - if (map.containsKey("method") && map.containsKey("id")) { - return jsonMapper.convertValue(map, JSONRPCRequest.class); + if (map == null) { + // The JSON null literal: valid JSON, but no JSON-RPC message + throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: the JSON null literal"); } - else if (map.containsKey("method") && !map.containsKey("id")) { - return jsonMapper.convertValue(map, JSONRPCNotification.class); + + Class messageType = messageType(map); + if (messageType == null) { + // Not the text, here or in validateRequest: a transport logs these exceptions, and + // the text is the peer's payload. + throw new IllegalArgumentException( + "Cannot deserialize JSONRPCMessage: a JSON object with no method, id, result or error"); } - else if (map.containsKey("result") || map.containsKey("error")) { - return jsonMapper.convertValue(map, JSONRPCResponse.class); + if (messageType != JSONRPCResponse.class) { + validateRequest(map); } + return jsonMapper.convertValue(map, messageType); + } - throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); + /** + * The kind of JSON-RPC message a JSON object is, by its members: a method makes a request + * (with an id) or a notification (without); otherwise a result, an error or an id makes a + * response, since a message with an id and no method answers a request even without a + * result. Null when it is none of them. + */ + private static @Nullable Class messageType(Map message) { + boolean hasId = message.containsKey("id"); + if (message.containsKey("method")) { + return hasId ? JSONRPCRequest.class : JSONRPCNotification.class; + } + if (hasId || message.containsKey("result") || message.containsKey("error")) { + return JSONRPCResponse.class; + } + return null; } // --------------------------- // Method Names (Agent Methods - client calls these) // --------------------------- + /** + * Client to agent: the first request on a connection, which agrees on the protocol version + * and capabilities ({@link InitializeRequest}, {@link InitializeResponse}). + */ public static final String METHOD_INITIALIZE = "initialize"; + /** + * Client to agent: logs in with one of the authentication methods the agent listed at + * {@code initialize} ({@link AuthenticateRequest}, {@link AuthenticateResponse}). + */ public static final String METHOD_AUTHENTICATE = "authenticate"; + /** + * Client to agent: logs out, clearing the agent's stored credentials + * ({@link LogoutRequest}, {@link LogoutResponse}). + */ public static final String METHOD_LOGOUT = "logout"; + /** + * Client to agent: starts an ACP session and returns its id ({@link NewSessionRequest}, + * {@link NewSessionResponse}). + */ public static final String METHOD_SESSION_NEW = "session/new"; + /** + * Client to agent: loads an existing ACP session ({@link LoadSessionRequest}, + * {@link LoadSessionResponse}). + */ public static final String METHOD_SESSION_LOAD = "session/load"; + /** + * Client to agent: sends a user message and runs a prompt turn, answered when the turn + * ends ({@link PromptRequest}, {@link PromptResponse}). + */ public static final String METHOD_SESSION_PROMPT = "session/prompt"; + /** + * Client to agent: changes the session's mode ({@link SetSessionModeRequest}, + * {@link SetSessionModeResponse}). + */ public static final String METHOD_SESSION_SET_MODE = "session/set_mode"; /** - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026, v0.13.5). Expose model selection through {@code session/set_config_option} - * with a config option whose {@code category} is {@code "model"} instead. Slated for - * removal in a future release. + * Client to agent, a notification: asks the agent to end the session's prompt turn + * ({@link CancelNotification}). The cancelled prompt answers {@link StopReason#CANCELLED}. */ - @Deprecated(forRemoval = true) - @UnstableAcpApi - public static final String METHOD_SESSION_SET_MODEL = "session/set_model"; - public static final String METHOD_SESSION_CANCEL = "session/cancel"; + /** + * Client to agent: lists existing ACP sessions ({@link ListSessionsRequest}, + * {@link ListSessionsResponse}). + */ public static final String METHOD_SESSION_LIST = "session/list"; + /** + * Client to agent: closes an active ACP session ({@link CloseSessionRequest}, + * {@link CloseSessionResponse}). + */ public static final String METHOD_SESSION_CLOSE = "session/close"; + /** + * Client to agent: deletes an ACP session that {@code session/list} returned + * ({@link DeleteSessionRequest}, {@link DeleteSessionResponse}). + */ public static final String METHOD_SESSION_DELETE = "session/delete"; + /** + * Client to agent: resumes an existing ACP session ({@link ResumeSessionRequest}, + * {@link ResumeSessionResponse}). + */ public static final String METHOD_SESSION_RESUME = "session/resume"; + /** + * Client to agent: starts a new ACP session from an existing one + * ({@link ForkSessionRequest}, {@link ForkSessionResponse}). + */ + @UnstableAcpApi public static final String METHOD_SESSION_FORK = "session/fork"; + /** + * Client to agent: sets a session config option, such as the model + * ({@link SetSessionConfigOptionRequest}, {@link SetSessionConfigOptionResponse}). + */ public static final String METHOD_SESSION_SET_CONFIG_OPTION = "session/set_config_option"; // --------------------------- // Method Names (Client Methods - agent calls these) // --------------------------- + /** + * Agent to client: asks the user for permission to run a tool call + * ({@link RequestPermissionRequest}, {@link RequestPermissionResponse}). + */ public static final String METHOD_SESSION_REQUEST_PERMISSION = "session/request_permission"; + /** + * Agent to client, a notification: streams one session update during a prompt turn + * ({@link SessionNotification}). + */ public static final String METHOD_SESSION_UPDATE = "session/update"; + /** + * Agent to client: reads a text file through the client ({@link ReadTextFileRequest}, + * {@link ReadTextFileResponse}). + */ public static final String METHOD_FS_READ_TEXT_FILE = "fs/read_text_file"; + /** + * Agent to client: writes a text file through the client ({@link WriteTextFileRequest}, + * {@link WriteTextFileResponse}). + */ public static final String METHOD_FS_WRITE_TEXT_FILE = "fs/write_text_file"; + /** + * Agent to client: creates a terminal and runs a command in it + * ({@link CreateTerminalRequest}, {@link CreateTerminalResponse}). + */ public static final String METHOD_TERMINAL_CREATE = "terminal/create"; + /** + * Agent to client: returns a terminal's output so far and its exit status, if it has + * exited ({@link TerminalOutputRequest}, {@link TerminalOutputResponse}). + */ public static final String METHOD_TERMINAL_OUTPUT = "terminal/output"; + /** + * Agent to client: releases a terminal and frees its resources + * ({@link ReleaseTerminalRequest}, {@link ReleaseTerminalResponse}). + */ public static final String METHOD_TERMINAL_RELEASE = "terminal/release"; + /** + * Agent to client: waits for a terminal's command to exit + * ({@link WaitForTerminalExitRequest}, {@link WaitForTerminalExitResponse}). + */ public static final String METHOD_TERMINAL_WAIT_FOR_EXIT = "terminal/wait_for_exit"; + /** + * Agent to client: kills a terminal's command but keeps the terminal + * ({@link KillTerminalCommandRequest}, {@link KillTerminalCommandResponse}). + */ public static final String METHOD_TERMINAL_KILL = "terminal/kill"; + /** + * Agent to client: asks the user for structured input, or to visit a URL + * ({@link CreateElicitationRequest}, {@link CreateElicitationResponse}). + */ public static final String METHOD_ELICITATION_CREATE = "elicitation/create"; + /** + * Agent to client, a notification: tells the client that a URL elicitation is complete + * ({@link CompleteElicitationNotification}). + */ public static final String METHOD_ELICITATION_COMPLETE = "elicitation/complete"; + // --------------------------- + // Method Names (protocol level, both sides) + // --------------------------- + + /** + * Either direction, a notification: cancels a request the sender sent earlier + * ({@link CancelRequestNotification}). The SDK sends and handles it itself. + */ + public static final String METHOD_CANCEL_REQUEST = "$/cancel_request"; + // Provider configuration (UNSTABLE) + /** + * Client to agent: lists the agent's providers ({@link ListProvidersRequest}, + * {@link ListProvidersResponse}). + */ + @UnstableAcpApi public static final String METHOD_PROVIDERS_LIST = "providers/list"; + /** + * Client to agent: sets a provider's configuration ({@link SetProviderRequest}, + * {@link SetProviderResponse}). + */ + @UnstableAcpApi public static final String METHOD_PROVIDERS_SET = "providers/set"; + /** + * Client to agent: disables a provider ({@link DisableProviderRequest}, + * {@link DisableProviderResponse}). + */ + @UnstableAcpApi public static final String METHOD_PROVIDERS_DISABLE = "providers/disable"; // --------------------------- @@ -154,60 +501,165 @@ else if (map.containsKey("result") || map.containsKey("error")) { // --------------------------- /** - * A JSON-RPC request that expects a response. + * A JSON-RPC 2.0 request: a call that expects one {@link JSONRPCResponse} with the same id. + * The protocol sessions build these from the facades' calls and number them; build one + * yourself only to work on the wire, for example in a transport test. + * + *

    + * When sent, the params are the method's ACP record, such as a {@link PromptRequest} for + * {@code session/prompt}. When read from JSON they are the parsed JSON (maps, lists and plain + * values), which the receiving session converts to that record. * - * @param jsonrpc The JSON-RPC version (must be "2.0") - * @param id A unique identifier for the request - * @param method The name of the method to be invoked - * @param params Parameters for the method call + * @param jsonrpc the JSON-RPC version, {@value AcpSchema#JSONRPC_VERSION} + * @param id the request id, a string or a number; requests this SDK sends have string ids + * @param method the method name: a {@code METHOD_*} constant, or an extension method whose + * name starts with {@code _} + * @param params the method's params, or {@code null} for none */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record JSONRPCRequest(@JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") Object id, - @JsonProperty("method") String method, @JsonProperty("params") Object params) implements JSONRPCMessage { - public JSONRPCRequest(String method, Object id, Object params) { + public record JSONRPCRequest(@JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") @Nullable Object id, + @JsonProperty("method") String method, @JsonProperty("params") @Nullable Object params) implements JSONRPCMessage { + /** + * Creates a request with the version {@value AcpSchema#JSONRPC_VERSION}. The method comes + * first here, unlike in the canonical constructor. + * @param method the method name + * @param id the request id + * @param params the params, or {@code null} + */ + public JSONRPCRequest(String method, @Nullable Object id, @Nullable Object params) { this(JSONRPC_VERSION, id, method, params); } } /** - * A JSON-RPC notification that does not expect a response. + * A JSON-RPC 2.0 notification: a message that gets no response, such as + * {@code session/update} or {@code session/cancel}. The protocol sessions build these when a + * facade sends a notification; build one yourself only to work on the wire. + * + *

    + * When sent, the params are the method's ACP record, such as a {@link SessionNotification}. + * When read from JSON they are the parsed JSON, which the receiving session converts to that + * record. * - * @param jsonrpc The JSON-RPC version (must be "2.0") - * @param method The name of the method to be invoked - * @param params Parameters for the method call + * @param jsonrpc the JSON-RPC version, {@value AcpSchema#JSONRPC_VERSION} + * @param method the method name: a {@code METHOD_*} constant, or an extension method whose + * name starts with {@code _} + * @param params the method's params, or {@code null} for none */ @JsonInclude(JsonInclude.Include.NON_NULL) public record JSONRPCNotification(@JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, - @JsonProperty("params") Object params) implements JSONRPCMessage { - public JSONRPCNotification(String method, Object params) { + @JsonProperty("params") @Nullable Object params) implements JSONRPCMessage { + /** + * Creates a notification with the version {@value AcpSchema#JSONRPC_VERSION}. + * @param method the method name + * @param params the params, or {@code null} + */ + public JSONRPCNotification(String method, @Nullable Object params) { this(JSONRPC_VERSION, method, params); } } /** - * A JSON-RPC response to a request. + * A JSON-RPC 2.0 response: the answer to the {@link JSONRPCRequest} with the same id, + * carrying either a result or an error. The protocol sessions match it to the waiting call + * and complete that call with the result, or fail it with an {@link AcpError}. + * + *

    + * A response this SDK sends has exactly one of {@code result} and {@code error}. A received + * response with neither reads as a {@code null} result, which completes the call only for a + * response type that implements {@link DefaultOnNull} or an extension method; any other call + * fails. * - * @param jsonrpc The JSON-RPC version (must be "2.0") - * @param id The request ID this response corresponds to - * @param result The result of the method call (null if error occurred) - * @param error The error information (null if successful) + * @param jsonrpc the JSON-RPC version, {@value AcpSchema#JSONRPC_VERSION} + * @param id the id of the request this answers; {@code null}, written as {@code "id": null} + * as JSON-RPC 2.0 requires, when the request could not be read + * @param result the result, or {@code null} when the request failed + * @param error the error, or {@code null} when the request succeeded */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record JSONRPCResponse(@JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") Object id, - @JsonProperty("result") Object result, - @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { + public record JSONRPCResponse(@JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("id") @JsonInclude(JsonInclude.Include.ALWAYS) @Nullable Object id, + @JsonProperty("result") @Nullable Object result, + @JsonProperty("error") @Nullable JSONRPCError error) implements JSONRPCMessage { } + /** + * The error of a failed JSON-RPC response: a code, a message and optional data. A handler + * fails a request by throwing an {@link AcpProtocolException}, which the SDK sends as this + * record ({@link #from}); a caller whose request failed gets an {@link AcpError} that carries + * it ({@link AcpError#getError()}). + * + *

    + * The codes are the constants of {@link AcpErrorCodes}: the JSON-RPC 2.0 codes, and the ACP + * ones such as {@code -32000} (authentication required) and {@code -32800} (request + * cancelled). + * + * @param code the error code + * @param message a short description of the error + * @param data more detail, or {@code null}; its shape depends on the error + */ @JsonInclude(JsonInclude.Include.NON_NULL) public record JSONRPCError(@JsonProperty("code") int code, @JsonProperty("message") String message, - @JsonProperty("data") Object data) { + @JsonProperty("data") @Nullable Object data) { + + /** + * Returns the wire form of a protocol exception: its code, its data, and its message + * without the code prefix that {@link AcpProtocolException#getMessage()} adds for logs. + * @param exception the exception to send + * @return an error with the exception's code, message and data + */ + public static JSONRPCError from(AcpProtocolException exception) { + return new JSONRPCError(exception.getCode(), exception.getErrorMessage(), exception.getData()); + } + + /** + * Returns this error as an {@link AcpProtocolException} with the same code, message and + * data. The SDK does not use it for the errors it receives: the caller of a failed request + * gets an {@link AcpError}, which a handler can let escape to pass the error on as it is. + * @return a protocol exception carrying this error's code, message and data + */ + public AcpProtocolException toException() { + return new AcpProtocolException(code, message, data); + } + } + + /** + * Marks a response whose every component is optional, so that a peer may answer the request + * without a result. A received response with {@code "result": null}, which JSON-RPC 2.0 allows, + * or without a {@code result} member reads as if the peer had sent {@code {}}: the call + * completes with a record whose components are all {@code null}. Some peers answer this way + * when their handler returns nothing. + * + *

    + * For a response type that does not implement it, a missing result fails the call with an + * {@link AcpError} of code {@code -32603} (Internal error); only the call of an extension + * method completes empty instead. In this class exactly the response types whose every + * component is optional implement it, such as {@link WriteTextFileResponse} and + * {@link AuthenticateResponse}. It has no methods, and it changes only how a received response + * is read, not what the SDK sends. + */ + public interface DefaultOnNull { + } /** - * Base type for all JSON-RPC messages. + * One JSON-RPC 2.0 message: a {@link JSONRPCRequest}, a {@link JSONRPCNotification} or a + * {@link JSONRPCResponse}. Transports move these between client and agent, and the protocol + * sessions turn them into calls of the right handler. Application code meets them only when + * it writes a transport or a test that works on the wire; everywhere else the ACP records + * travel inside them as params and results. + * + *

    + * The interface is sealed, so these three types are all there are. + * {@link AcpSchema#deserializeJsonRpcMessage} reads one from JSON text. */ public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotification, JSONRPCResponse { + /** + * Returns the JSON-RPC version, {@value AcpSchema#JSONRPC_VERSION} in every message this + * SDK sends. + * @return the {@code jsonrpc} member + */ String jsonrpc(); } @@ -217,45 +669,93 @@ public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotificati // --------------------------- /** - * Initialize request - establishes connection and negotiates capabilities + * The params of {@code initialize}, the first request on a connection: the protocol version + * the client speaks, what it can do and who it is. {@code AcpAsyncClient} and + * {@code AcpSyncClient} build it in {@code initialize()} from the capabilities and client + * info set on their builder. The agent's + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.InitializeHandler} receives it and + * answers with an {@link InitializeResponse}. + * + *

    + * The agent records {@code clientCapabilities} as + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities} before its + * initialize handler runs, and checks them before it calls the client's file system, + * terminal and elicitation methods. A {@code null} {@code clientCapabilities} counts as a + * client that offers none of them. + * + * @param protocolVersion the latest protocol version the client supports; this SDK sends + * {@value AcpSchema#LATEST_PROTOCOL_VERSION} unless told otherwise + * @param clientCapabilities what the client offers the agent, or {@code null} + * @param clientInfo the client's name and version, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record InitializeRequest(@JsonProperty("protocolVersion") Integer protocolVersion, - @JsonProperty("clientCapabilities") ClientCapabilities clientCapabilities, - @JsonProperty("clientInfo") Implementation clientInfo, - @JsonProperty("_meta") Map meta) { - public InitializeRequest(Integer protocolVersion, ClientCapabilities clientCapabilities) { + @JsonProperty("clientCapabilities") @Nullable ClientCapabilities clientCapabilities, + @JsonProperty("clientInfo") @Nullable Implementation clientInfo, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without client info or {@code _meta}. + * @param protocolVersion the latest protocol version the client supports + * @param clientCapabilities what the client offers, or {@code null} + */ + public InitializeRequest(Integer protocolVersion, @Nullable ClientCapabilities clientCapabilities) { this(protocolVersion, clientCapabilities, null, null); } } /** - * Initialize response - returns agent capabilities and auth methods + * The result of {@code initialize}: the protocol version the agent agrees to, what it can do + * and how a client can log in. The agent's initialize handler builds it, most simply with + * {@link #ok()} or {@link #ok(AgentCapabilities)}. The client's {@code initialize()} + * completes with it and records {@code agentCapabilities} as the connection's + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities}. + * + *

    + * By the protocol, the agent answers with the client's protocol version if it supports it, + * and otherwise with the latest version it supports; a client that does not support the + * answer should disconnect. A builder agent without an initialize handler, and an annotated + * agent whose {@code @Initialize} method returns no version, answer this way: the client's + * version when the SDK speaks it, otherwise the latest the SDK speaks. An initialize handler + * that sets a version chooses it, unchecked, and the client does not check the answer. + * + * @param protocolVersion the protocol version for this connection + * @param agentCapabilities what the agent offers, or {@code null} + * @param authMethods the ways a client can authenticate, or {@code null} for none + * @param agentInfo the agent's name and version, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record InitializeResponse(@JsonProperty("protocolVersion") Integer protocolVersion, - @JsonProperty("agentCapabilities") AgentCapabilities agentCapabilities, - @JsonProperty("authMethods") List authMethods, - @JsonProperty("agentInfo") Implementation agentInfo, - @JsonProperty("_meta") Map meta) { - public InitializeResponse(Integer protocolVersion, AgentCapabilities agentCapabilities, - List authMethods) { + @JsonProperty("agentCapabilities") @Nullable AgentCapabilities agentCapabilities, + @JsonProperty("authMethods") @Nullable List authMethods, + @JsonProperty("agentInfo") @Nullable Implementation agentInfo, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without agent info or {@code _meta}. + * @param protocolVersion the protocol version for this connection + * @param agentCapabilities what the agent offers, or {@code null} + * @param authMethods the ways a client can authenticate, or {@code null} + */ + public InitializeResponse(Integer protocolVersion, @Nullable AgentCapabilities agentCapabilities, + @Nullable List authMethods) { this(protocolVersion, agentCapabilities, authMethods, null, null); } /** - * Creates a default successful initialization response. - * Uses protocol version 1 and default agent capabilities. - * @return A default InitializeResponse + * Returns a response with protocol version 1 and {@code new AgentCapabilities()}: no + * session loading, no MCP servers over HTTP or SSE, and only text and resource links in + * prompts. Enough for a minimal agent. + * @return a response for protocol version 1 with the default capabilities */ public static InitializeResponse ok() { return new InitializeResponse(1, new AgentCapabilities(), null); } /** - * Creates a successful initialization response with the given capabilities. - * @param capabilities The agent capabilities to advertise - * @return An InitializeResponse with the specified capabilities + * Returns a response with protocol version 1 and the given capabilities. + * @param capabilities what the agent offers + * @return a response for protocol version 1 with those capabilities */ public static InitializeResponse ok(AgentCapabilities capabilities) { return new InitializeResponse(1, capabilities, null); @@ -263,117 +763,384 @@ public static InitializeResponse ok(AgentCapabilities capabilities) { } /** - * Authenticate request - authenticates using specified method + * The params of {@code authenticate}: the client logs in with one of the + * {@link AuthMethodAgent} methods the agent listed in {@link InitializeResponse#authMethods()}, + * named by its id. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#authenticate + * AcpSyncClient.authenticate} or the {@code AcpAsyncClient} method of the same name, when the + * agent requires a login: such an agent answers other requests with {@code -32000} + * (authentication required) until then. The agent's authenticate handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.AuthenticateHandler} or an + * {@link com.agentclientprotocol.sdk.annotation.Authenticate @Authenticate} method) receives it + * and answers with an {@link AuthenticateResponse}. + * + *

    + * Pass only the id of an {@link AuthMethodAgent}. The protocol forbids passing an + * {@link AuthMethodTerminal}: the client runs that one itself, outside the connection. The SDK + * checks the id on neither side: the client sends any id, and the agent's handler receives any + * id, so a handler should reject an id it did not advertise. To refuse a login, the handler + * fails with an {@link AcpProtocolException} carrying + * {@link AcpErrorCodes#AUTHENTICATION_REQUIRED}. An agent without an authenticate handler + * answers {@code -32601} (method not found). + * + * @param methodId the id of the chosen {@link AuthMethodAgent}, one the agent advertised + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AuthenticateRequest(@JsonProperty("methodId") String methodId) { + public record AuthenticateRequest(@JsonProperty("methodId") String methodId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param methodId the id of the chosen agent auth method + */ + public AuthenticateRequest(String methodId) { + this(methodId, null); + } } /** - * Authenticate response + * The result of {@code authenticate}: an empty answer that confirms the login succeeded. The + * agent's authenticate handler returns it once it has checked the login; the client's + * {@code authenticate(...)} completes with it. A failed login is an error answer, not this + * record. + * + *

    + * The SDK does not record that a client has logged in. An agent's handlers that need a login + * check for one themselves, and fail with {@link AcpErrorCodes#AUTHENTICATION_REQUIRED} when + * there is none. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AuthenticateResponse() { + public record AuthenticateResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ + public AuthenticateResponse() { + this(null); + } } /** - * Logout request - clears stored credentials, terminating the current - * authenticated session. + * The params of {@code logout}: asks the agent to end the client's authenticated state, so that + * new sessions need a login again. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#logout AcpSyncClient.logout} or the + * {@code AcpAsyncClient} method of the same name. The agent's logout handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.LogoutHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.Logout @Logout} method) receives it and answers + * with a {@link LogoutResponse}. + * + *

    + * Only an agent that advertises {@code auth.logout} + * ({@link AgentAuthCapabilities#withLogout()}) supports it: for any other agent the client + * fails the call with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} + * without sending it. A builder agent without an initialize handler and an annotated agent + * advertise {@code auth.logout} when they have a logout handler. The protocol does not say what + * happens to sessions already running: the agent may end them, keep them, or fail their later + * requests with {@code -32000} (authentication required). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record LogoutRequest(@JsonProperty("_meta") Map meta) { + public record LogoutRequest(@JsonProperty("_meta") @Nullable Map meta) { + /** Creates a request without {@code _meta}. */ public LogoutRequest() { this(null); } } /** - * Logout response + * The result of {@code logout}: an empty answer that confirms the client is logged out. The + * agent's logout handler returns it; the client's {@code logout(...)} completes with it. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record LogoutResponse(@JsonProperty("_meta") Map meta) { + public record LogoutResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ public LogoutResponse() { this(null); } } /** - * Create new session request + * The params of {@code session/new}: asks the agent to start an ACP session in a working + * directory, with the MCP servers it should connect to. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#newSession AcpSyncClient.newSession} + * or the {@code AcpAsyncClient} method of the same name. The agent's + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.NewSessionHandler} receives it and + * answers with a {@link NewSessionResponse} that carries the new session's id. + * + *

    + * The protocol requires absolute paths for {@code cwd} and {@code additionalDirectories}; the + * SDK does not check them. Pass an empty list, not {@code null}, when there are no MCP + * servers. A non-empty {@code additionalDirectories} needs an agent that advertises + * {@code sessionCapabilities.additionalDirectories}: for any other, the client fails the call + * with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. + * + * @param cwd the session's working directory, an absolute path; relative paths in the + * session resolve against it + * @param mcpServers the MCP servers the agent should connect to, possibly empty + * @param additionalDirectories more workspace roots as absolute paths, or {@code null} for + * none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record NewSessionRequest(@JsonProperty("cwd") String cwd, @JsonProperty("mcpServers") List mcpServers, - @JsonProperty("additionalDirectories") List additionalDirectories, - @JsonProperty("_meta") Map meta) { + @JsonProperty("additionalDirectories") @Nullable List additionalDirectories, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request for a working directory, with no MCP servers, additional directories + * or {@code _meta}. + * @param cwd the working directory, an absolute path + */ + public NewSessionRequest(String cwd) { + this(cwd, List.of(), null, null); + } + + /** + * Creates a request without additional directories or {@code _meta}. + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, possibly empty + */ public NewSessionRequest(String cwd, List mcpServers) { this(cwd, mcpServers, null, null); } - public NewSessionRequest(String cwd, List mcpServers, List additionalDirectories) { + /** + * Creates a request without {@code _meta}. + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, possibly empty + * @param additionalDirectories more workspace roots, or {@code null} + */ + public NewSessionRequest(String cwd, List mcpServers, @Nullable List additionalDirectories) { this(cwd, mcpServers, additionalDirectories, null); } } /** - * Create new session response + * The result of {@code session/new}: the id of the new ACP session, with its modes and config + * options when the agent has them. The agent's new-session handler builds it and chooses the + * id. The client gets it from {@code newSession(...)} and passes {@link #sessionId()} in every + * later message about that session, starting with {@link PromptRequest}. + * + *

    + * A minimal agent answers {@code new NewSessionResponse(id)}. An agent without a + * new-session handler (a builder agent without {@code newSessionHandler}, an annotated agent + * without a {@link com.agentclientprotocol.sdk.annotation.NewSession @NewSession} method) + * answers {@link #withGeneratedId()}: a random UUID as the id, and no modes or options. + * + * @param sessionId the id of the new ACP session + * @param modes the session's modes and the current one, or {@code null} if the agent has no + * modes + * @param configOptions the session's config options with their current values, or + * {@code null} if the agent has none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - @SuppressWarnings("removal") // 'models' references the deprecated-for-removal SessionModelState public record NewSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("modes") SessionModeState modes, @JsonProperty("models") SessionModelState models, - @JsonProperty("_meta") Map meta) { - public NewSessionResponse(String sessionId, SessionModeState modes, SessionModelState models) { - this(sessionId, modes, models, null); + @JsonProperty("modes") @Nullable SessionModeState modes, + @JsonProperty("configOptions") @Nullable List configOptions, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response with only the session id: no modes, config options or + * {@code _meta}. + * @param sessionId the id of the new ACP session + */ + public NewSessionResponse(String sessionId) { + this(sessionId, null, null, null); + } + + /** + * Creates a response without config options or {@code _meta}. + * @param sessionId the id of the new ACP session + * @param modes the session's modes, or {@code null} + */ + public NewSessionResponse(String sessionId, @Nullable SessionModeState modes) { + this(sessionId, modes, null, null); + } + + /** + * Creates a response without {@code _meta}. + * @param sessionId the id of the new ACP session + * @param modes the session's modes, or {@code null} + * @param configOptions the session's config options, or {@code null} + */ + public NewSessionResponse(String sessionId, @Nullable SessionModeState modes, + @Nullable List configOptions) { + this(sessionId, modes, configOptions, null); + } + + /** + * Returns a response for a new session with a random UUID as its id, and no modes, + * config options or {@code _meta}: what an agent without a new-session handler answers. + * @return a response with a fresh session id + */ + public static NewSessionResponse withGeneratedId() { + return new NewSessionResponse(UUID.randomUUID().toString()); } } /** - * Load existing session request + * The params of {@code session/load}: asks the agent to reopen an ACP session it kept, in a + * working directory and with the MCP servers it should connect to. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#loadSession + * AcpSyncClient.loadSession} or the {@code AcpAsyncClient} method of the same name. The agent's + * load-session handler ({@link com.agentclientprotocol.sdk.agent.AcpAgent.LoadSessionHandler} + * or a {@link com.agentclientprotocol.sdk.annotation.LoadSession @LoadSession} method) receives + * it, replays the session's conversation to the client as session updates, and answers with a + * {@link LoadSessionResponse}. + * + *

    + * Only an agent that advertises {@code loadSession} supports it: for any other agent the client + * fails the call with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} + * without sending it. The replayed session updates reach the client's session update handlers + * before its call completes. To reopen a session without the replay, use + * {@link ResumeSessionRequest}. The protocol requires absolute paths for {@code cwd} and + * {@code additionalDirectories}; the SDK does not check them. A non-empty + * {@code additionalDirectories} is the complete list of additional workspace roots for the + * session. Only an agent that advertises {@code sessionCapabilities.additionalDirectories} + * accepts it: for any other, the client fails a call with a non-empty list with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. + * + * @param sessionId the ACP session to reopen, as {@link NewSessionResponse#sessionId()} or + * {@link SessionInfo#sessionId()} gave it + * @param cwd the session's working directory, an absolute path + * @param mcpServers the MCP servers the agent should connect to, possibly empty + * @param additionalDirectories more workspace roots as absolute paths, or {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record LoadSessionRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("cwd") String cwd, @JsonProperty("mcpServers") List mcpServers, - @JsonProperty("additionalDirectories") List additionalDirectories, - @JsonProperty("_meta") Map meta) { + @JsonProperty("additionalDirectories") @Nullable List additionalDirectories, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without additional directories or {@code _meta}. + * @param sessionId the ACP session to reopen + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, possibly empty + */ public LoadSessionRequest(String sessionId, String cwd, List mcpServers) { this(sessionId, cwd, mcpServers, null, null); } + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to reopen + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, possibly empty + * @param additionalDirectories more workspace roots, or {@code null} + */ public LoadSessionRequest(String sessionId, String cwd, List mcpServers, - List additionalDirectories) { + @Nullable List additionalDirectories) { this(sessionId, cwd, mcpServers, additionalDirectories, null); } } /** - * Load session response + * The result of {@code session/load}: the reopened ACP session's modes and config options, when + * the agent has them. The agent's load-session handler returns it after it has replayed the + * conversation. The client's {@code loadSession(...)} completes with it, and only after the + * client has handled every replayed session update. + * + *

    + * Every component is optional: {@code new LoadSessionResponse(null)} answers for an agent + * without modes or config options, and a peer that answers with an empty or {@code null} result + * gives such a record (see {@link DefaultOnNull}). + * + * @param modes the session's modes and the current one, or {@code null} if the agent has no + * modes + * @param configOptions the session's config options with their current values, or {@code null} + * if the agent has none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - @SuppressWarnings("removal") // 'models' references the deprecated-for-removal SessionModelState - public record LoadSessionResponse(@JsonProperty("modes") SessionModeState modes, - @JsonProperty("models") SessionModelState models, - @JsonProperty("_meta") Map meta) { - public LoadSessionResponse(SessionModeState modes, SessionModelState models) { - this(modes, models, null); + public record LoadSessionResponse(@JsonProperty("modes") @Nullable SessionModeState modes, + @JsonProperty("configOptions") @Nullable List configOptions, + @JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** + * Creates a response without config options or {@code _meta}. + * @param modes the session's modes, or {@code null} + */ + public LoadSessionResponse(@Nullable SessionModeState modes) { + this(modes, null, null); + } + + /** + * Creates a response without {@code _meta}. + * @param modes the session's modes, or {@code null} + * @param configOptions the session's config options, or {@code null} + */ + public LoadSessionResponse(@Nullable SessionModeState modes, + @Nullable List configOptions) { + this(modes, configOptions, null); } } /** - * Prompt request - sends user message to agent + * The params of {@code session/prompt}: one user message for an ACP session, which starts a + * prompt turn. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#prompt AcpSyncClient.prompt} or the + * {@code AcpAsyncClient} method of the same name. The agent's prompt handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.PromptHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} method) receives it with a + * prompt context, streams session updates, and answers with a {@link PromptResponse}. + * + *

    + * An ACP session has at most one prompt turn at a time. A second prompt on the same session + * is refused with {@code -32600} (Invalid Request) until the first one has been answered, + * even after a {@code session/cancel}. The protocol requires every agent to accept + * {@link TextContent} and {@link ResourceLink} blocks; other block types only when the + * agent's {@link PromptCapabilities} allow them. + * + *

    {@code
    +	 * AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(sessionId,
    +	 *         List.of(new AcpSchema.TextContent("Explain this stack trace"))));
    +	 * }
    + * + * @param sessionId the ACP session to prompt, from {@link NewSessionResponse#sessionId()} + * @param prompt the content blocks of the user's message + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PromptRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("prompt") List prompt, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to prompt + * @param prompt the content blocks of the user's message + */ public PromptRequest(String sessionId, List prompt) { this(sessionId, prompt, null); } /** - * Returns the text of the first {@link TextContent} block in the prompt, or an empty - * string if no text content is present. + * Creates a prompt of one text block, the common case: + * {@code client.prompt(PromptRequest.text(sessionId, "Fix the failing test"))}. + * @param sessionId the ACP session to prompt + * @param text the user's message + * @return a request whose prompt is one {@link TextContent} block + */ + public static PromptRequest text(String sessionId, String text) { + return new PromptRequest(sessionId, List.of(new TextContent(text))); + } + + /** + * Returns the text of the first {@link TextContent} block, a shortcut for prompt handlers + * that only read plain text. Other blocks, and any later text blocks, are ignored. + * @return the first text block's text, or an empty string when the prompt has no text block */ public String text() { + // Required by the schema, but Jackson does not enforce it: a peer can omit it. if (prompt == null) { return ""; } @@ -386,209 +1153,459 @@ public String text() { } /** - * Prompt response - indicates why agent stopped + * The result of {@code session/prompt}: why the agent ended the prompt turn. The agent's + * prompt handler returns it after the turn's last session update. The client's + * {@code prompt(...)} completes with it, and only after the client has handled every session + * update the agent sent before it. + * + *

    + * The stop reason is an open value, so compare it with {@code equals}. A prompt can also end + * with an error instead, which fails the client's call with an {@link AcpError}: for example + * {@code -32800} when the agent's {@code maxPromptDuration} passes. After a + * {@code session/cancel} the agent must answer {@link StopReason#CANCELLED}; if the prompt + * handler of an agent built with this SDK has not answered when the cancel grace period + * passes, the SDK answers that itself (see {@link PromptTimeouts}). + * + * @param stopReason why the turn ended + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PromptResponse(@JsonProperty("stopReason") StopReason stopReason, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param stopReason why the turn ended + */ public PromptResponse(StopReason stopReason) { this(stopReason, null); } /** - * Creates a response indicating the agent has finished its turn. - * @return A PromptResponse with END_TURN stop reason + * Returns a response with stop reason {@link StopReason#END_TURN}: the turn ended normally. + * @return a response that ends the turn */ public static PromptResponse endTurn() { return new PromptResponse(StopReason.END_TURN); } /** - * Creates a response indicating the agent has finished its turn with a text result. - * Note: The text content should be sent via the context before returning this response. - * @param text The text (for documentation purposes; actual content sent via context) - * @return A PromptResponse with END_TURN stop reason + * Returns a response with stop reason {@link StopReason#REFUSAL}: the agent refused to + * continue. + * @return a response that ends the turn as refused */ - public static PromptResponse text(String text) { - // Text content should be sent via context.sendMessage() before returning - return new PromptResponse(StopReason.END_TURN); + public static PromptResponse refusal() { + return new PromptResponse(StopReason.REFUSAL); } /** - * Creates a response indicating the agent refused the request. - * @return A PromptResponse with REFUSAL stop reason + * Creates the response to a cancelled prompt, which ACP requires once the client sent + * {@code session/cancel} (ACP v1, prompt turn, Cancellation). + * @return A PromptResponse with CANCELLED stop reason */ - public static PromptResponse refusal() { - return new PromptResponse(StopReason.REFUSAL); + public static PromptResponse cancelled() { + return new PromptResponse(StopReason.CANCELLED); } } /** - * Set session mode request + * The params of {@code session/set_mode}: asks the agent to switch an ACP session to another of + * the modes it offered for it, such as "ask" or "code". A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#setSessionMode + * AcpSyncClient.setSessionMode} or the {@code AcpAsyncClient} method of the same name. The + * agent's set-mode handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.SetSessionModeHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.SetSessionMode @SetSessionMode} method) + * receives it and answers with a {@link SetSessionModeResponse}. + * + *

    + * The agent offers modes in the {@link SessionModeState} of its answers to {@code session/new}, + * {@code session/load} and {@code session/resume}. No capability advertises them, so the client + * sends this request without a capability check. The protocol requires {@code modeId} to be one + * of the session's {@link SessionModeState#availableModes()}; the SDK does not check it. + * + * @param sessionId the ACP session to switch + * @param modeId the {@link SessionMode#id()} of the mode to switch to + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record SetSessionModeRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("modeId") String modeId) { + @JsonProperty("modeId") String modeId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to switch + * @param modeId the id of the mode to switch to + */ + public SetSessionModeRequest(String sessionId, String modeId) { + this(sessionId, modeId, null); + } } /** - * Set session mode response + * The result of {@code session/set_mode}: an empty answer that confirms the switch. The agent's + * set-mode handler returns it after switching the session; the client's + * {@code setSessionMode(...)} completes with it. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). When the agent changes a + * session's mode on its own, it tells the client with a {@link CurrentModeUpdate} session + * update instead. + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record SetSessionModeResponse() { - } - - /** - * Set session model request. - * - * @deprecated The {@code session/set_model} method was removed from the ACP spec - * (June 2026, v0.13.5). Use {@code session/set_config_option} with a {@code "model"} - * category config option instead. Slated for removal in a future release. - */ - @Deprecated(forRemoval = true) - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record SetSessionModelRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("modelId") String modelId) { + public record SetSessionModeResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ + public SetSessionModeResponse() { + this(null); + } } /** - * Set session model response. + * The params of {@code session/cancel}: asks the agent to end the session's current prompt + * turn. A client sends it with {@code cancel(...)} on {@code AcpAsyncClient} or + * {@code AcpSyncClient}. The agent's cancel handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.CancelHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.Cancel @Cancel} method) receives it, and the + * running prompt's context reports it ({@code PromptContext.isCancelled()}). It is a + * notification, so it gets no answer of its own. * - * @deprecated See {@link SetSessionModelRequest}. Slated for removal. + *

    + * The cancel does not end the turn; the cancelled prompt's answer does. The agent may still + * send session updates, and then answers the prompt with {@link StopReason#CANCELLED}. Until + * that answer, a new prompt on the session is refused. If the prompt handler has not answered + * when the cancel grace period passes (60 seconds by default), the SDK cancels the handler + * and answers {@code cancelled} itself (see {@link PromptTimeouts}). To cancel any other + * request, the SDK uses {@link CancelRequestNotification}. + * + * @param sessionId the ACP session whose prompt turn to cancel + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @Deprecated(forRemoval = true) - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record SetSessionModelResponse() { + public record CancelNotification(@JsonProperty("sessionId") String sessionId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a notification without {@code _meta}. + * @param sessionId the ACP session whose prompt turn to cancel + */ + public CancelNotification(String sessionId) { + this(sessionId, null); + } } /** - * Cancel notification - cancels ongoing operations + * The params of {@code $/cancel_request}: cancels one request its sender sent earlier, in + * either direction. The SDK sends it when a caller gives up on a request: the request's + * {@code Mono} is cancelled, its request timeout passes, or a + * {@link RequestCancellation#cancelWhen} trigger fires. The receiving SDK handles it itself + * and cancels the request's handler; no application handler sees it. + * + *

    + * The receiver still answers the cancelled request: with its result, if the handler finished + * first, and otherwise with the error {@code -32800} (Request cancelled). A + * {@code session/prompt} already cancelled with a {@link CancelNotification} answers + * {@link StopReason#CANCELLED} instead. A {@code $/cancel_request} whose id is missing, or is + * not a string or an integer, is logged and ignored. + * + * @param requestId the id of the request to cancel: a string or an integer + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record CancelNotification(@JsonProperty("sessionId") String sessionId, - @JsonProperty("_meta") Map meta) { - public CancelNotification(String sessionId) { - this(sessionId, null); + public record CancelRequestNotification(@JsonProperty("requestId") Object requestId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a notification without {@code _meta}. + * @param requestId the id of the request to cancel + */ + public CancelRequestNotification(Object requestId) { + this(requestId, null); } } /** - * List sessions request - lists all sessions, optionally filtered by working directory + * The params of {@code session/list}: asks the agent which ACP sessions it knows, optionally + * only those of one working directory, one page at a time. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#listSessions + * AcpSyncClient.listSessions} or the {@code AcpAsyncClient} method of the same name. The + * agent's list-sessions handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.ListSessionsHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.ListSessions @ListSessions} method) receives it + * and answers with a {@link ListSessionsResponse}. + * + *

    + * Only an agent that advertises {@code sessionCapabilities.list} supports it: for any other + * agent the client fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. Listing + * reopens nothing; {@link LoadSessionRequest} and {@link ResumeSessionRequest} do. For the + * first page leave {@code cursor} {@code null}; for each next page send the + * {@link ListSessionsResponse#nextCursor()} of the previous answer. + * + * @param cwd the working directory whose sessions to list, an absolute path, or {@code null} + * for all sessions + * @param cursor the {@code nextCursor} of the previous page, or {@code null} for the first page + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ListSessionsRequest(@JsonProperty("cwd") String cwd, @JsonProperty("cursor") String cursor, - @JsonProperty("_meta") Map meta) { - public ListSessionsRequest(String cwd) { + public record ListSessionsRequest(@JsonProperty("cwd") @Nullable String cwd, @JsonProperty("cursor") @Nullable String cursor, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request for the first page, without {@code _meta}. + * @param cwd the working directory to filter by, or {@code null} for all sessions + */ + public ListSessionsRequest(@Nullable String cwd) { this(cwd, null, null); } } /** - * List sessions response + * The result of {@code session/list}: one page of the ACP sessions the agent knows, as + * {@link SessionInfo} records, and a cursor when more pages remain. The agent's list-sessions + * handler builds it; the client's {@code listSessions(...)} completes with it. + * + *

    + * When {@code nextCursor} is present, send it as the {@code cursor} of the next + * {@link ListSessionsRequest} to get the next page; when it is {@code null}, this is the last + * page. The cursor is opaque: the agent chooses it and the client only sends it back. + * {@code sessions} is required, so an agent with no sessions answers an empty list. + * + * @param sessions the sessions on this page, possibly empty + * @param nextCursor the cursor for the next page, or {@code null} on the last page + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ListSessionsResponse(@JsonProperty("sessions") List sessions, - @JsonProperty("nextCursor") String nextCursor, - @JsonProperty("_meta") Map meta) { + @JsonProperty("nextCursor") @Nullable String nextCursor, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a last-page response: no next cursor and no {@code _meta}. + * @param sessions the sessions, possibly empty + */ public ListSessionsResponse(List sessions) { this(sessions, null, null); } } /** - * Close session request - closes a session and cancels in-flight work + * The params of {@code session/close}: tells the agent the client is done with an active ACP + * session, so that the agent stops the session's work and frees what it holds. A client sends + * it with {@link com.agentclientprotocol.sdk.client.AcpSyncClient#closeSession + * AcpSyncClient.closeSession} or the {@code AcpAsyncClient} method of the same name. The + * agent's close-session handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.CloseSessionHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.CloseSession @CloseSession} method) receives it + * and answers with a {@link CloseSessionResponse}. + * + *

    + * Only an agent that advertises {@code sessionCapabilities.close} supports it: for any other + * agent the client fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. The + * protocol requires the agent to cancel the session's work as if a {@link CancelNotification} + * had arrived. An agent built with this SDK does that before its close handler runs: it calls + * its cancel handler, cancels a running prompt turn, and waits until that prompt has answered + * {@link StopReason#CANCELLED}. Closing does not delete the session; + * {@link DeleteSessionRequest} does. + * + * @param sessionId the ACP session to close + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record CloseSessionRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to close + */ public CloseSessionRequest(String sessionId) { this(sessionId, null); } } /** - * Close session response + * The result of {@code session/close}: an empty answer that confirms the session is closed. The + * agent's close-session handler returns it after freeing the session; the client's + * {@code closeSession(...)} completes with it. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record CloseSessionResponse(@JsonProperty("_meta") Map meta) { + public record CloseSessionResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ public CloseSessionResponse() { this(null); } } /** - * Delete session request - permanently deletes a stored session. + * The params of {@code session/delete}: asks the agent to remove a stored ACP session for good, + * so that {@code session/list} no longer returns it. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#deleteSession + * AcpSyncClient.deleteSession} or the {@code AcpAsyncClient} method of the same name. The + * agent's delete-session handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.DeleteSessionHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.DeleteSession @DeleteSession} method) receives + * it and answers with a {@link DeleteSessionResponse}. + * + *

    + * Only an agent that advertises {@code sessionCapabilities.delete} supports it: for any other + * agent the client fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. Deleting + * is not closing: unlike for {@link CloseSessionRequest}, the SDK does not cancel the session's + * work first. * - *

    Only available if the agent advertises the {@code sessionCapabilities.delete} - * capability. + * @param sessionId the ACP session to delete + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record DeleteSessionRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to delete + */ public DeleteSessionRequest(String sessionId) { this(sessionId, null); } } /** - * Delete session response + * The result of {@code session/delete}: an empty answer that confirms the session is deleted. + * The agent's delete-session handler returns it; the client's {@code deleteSession(...)} + * completes with it. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record DeleteSessionResponse(@JsonProperty("_meta") Map meta) { + public record DeleteSessionResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ public DeleteSessionResponse() { this(null); } } /** - * Resume session request - reconnects to existing session without replaying history + * The params of {@code session/resume}: asks the agent to reopen an ACP session it kept, + * without replaying its conversation. A client sends it with + * {@link com.agentclientprotocol.sdk.client.AcpSyncClient#resumeSession + * AcpSyncClient.resumeSession} or the {@code AcpAsyncClient} method of the same name. The + * agent's resume-session handler + * ({@link com.agentclientprotocol.sdk.agent.AcpAgent.ResumeSessionHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.ResumeSession @ResumeSession} method) receives + * it and answers with a {@link ResumeSessionResponse}. + * + *

    + * Only an agent that advertises {@code sessionCapabilities.resume} supports it: for any other + * agent the client fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. Use it + * instead of {@link LoadSessionRequest} when the client keeps the conversation itself, or to + * reconnect to a session the agent still runs; an agent that can continue a session but cannot + * send its history offers only this. Unlike for {@code session/load}, {@code mcpServers} may be + * {@code null}. The protocol requires absolute paths for {@code cwd} and + * {@code additionalDirectories}; the SDK does not check them. A non-empty + * {@code additionalDirectories} is the complete list of additional workspace roots for the + * session. Only an agent that advertises {@code sessionCapabilities.additionalDirectories} + * accepts it: for any other, the client fails a call with a non-empty list with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it. + * + * @param sessionId the ACP session to reopen + * @param cwd the session's working directory, an absolute path + * @param mcpServers the MCP servers the agent should connect to, or {@code null} + * @param additionalDirectories more workspace roots as absolute paths, or {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ResumeSessionRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("cwd") String cwd, - @JsonProperty("mcpServers") List mcpServers, - @JsonProperty("additionalDirectories") List additionalDirectories, - @JsonProperty("_meta") Map meta) { - public ResumeSessionRequest(String sessionId, String cwd, List mcpServers) { + @JsonProperty("mcpServers") @Nullable List mcpServers, + @JsonProperty("additionalDirectories") @Nullable List additionalDirectories, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without additional directories or {@code _meta}. + * @param sessionId the ACP session to reopen + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, or {@code null} + */ + public ResumeSessionRequest(String sessionId, String cwd, @Nullable List mcpServers) { this(sessionId, cwd, mcpServers, null, null); } - public ResumeSessionRequest(String sessionId, String cwd, List mcpServers, - List additionalDirectories) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session to reopen + * @param cwd the working directory, an absolute path + * @param mcpServers the MCP servers, or {@code null} + * @param additionalDirectories more workspace roots, or {@code null} + */ + public ResumeSessionRequest(String sessionId, String cwd, @Nullable List mcpServers, + @Nullable List additionalDirectories) { this(sessionId, cwd, mcpServers, additionalDirectories, null); } } /** - * Resume session response + * The result of {@code session/resume}: the resumed ACP session's modes and config options, + * when the agent has them. The agent's resume-session handler returns it without sending the + * earlier conversation; the client's {@code resumeSession(...)} completes with it. + * + *

    + * Every component is optional: {@code new ResumeSessionResponse(null)} answers for an agent + * without modes or config options, and a peer that answers with an empty or {@code null} result + * gives such a record (see {@link DefaultOnNull}). + * + * @param modes the session's modes and the current one, or {@code null} if the agent has no + * modes + * @param configOptions the session's config options with their current values, or {@code null} + * if the agent has none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - @SuppressWarnings("removal") // 'models' references the deprecated-for-removal SessionModelState - public record ResumeSessionResponse(@JsonProperty("modes") SessionModeState modes, - @JsonProperty("models") SessionModelState models, - @JsonProperty("_meta") Map meta) { - public ResumeSessionResponse(SessionModeState modes, SessionModelState models) { - this(modes, models, null); + public record ResumeSessionResponse(@JsonProperty("modes") @Nullable SessionModeState modes, + @JsonProperty("configOptions") @Nullable List configOptions, + @JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** + * Creates a response without config options or {@code _meta}. + * @param modes the session's modes, or {@code null} + */ + public ResumeSessionResponse(@Nullable SessionModeState modes) { + this(modes, null, null); + } + + /** + * Creates a response without {@code _meta}. + * @param modes the session's modes, or {@code null} + * @param configOptions the session's config options, or {@code null} + */ + public ResumeSessionResponse(@Nullable SessionModeState modes, + @Nullable List configOptions) { + this(modes, configOptions, null); } } /** - * Fork session request - creates a new session branched from an existing one + * Fork session request - creates a new session branched from an existing one. A non-empty + * {@code additionalDirectories} needs an agent that advertises + * {@code sessionCapabilities.additionalDirectories}, as for {@link NewSessionRequest}. */ @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) public record ForkSessionRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("cwd") String cwd, - @JsonProperty("mcpServers") List mcpServers, - @JsonProperty("additionalDirectories") List additionalDirectories, - @JsonProperty("_meta") Map meta) { - public ForkSessionRequest(String sessionId, String cwd, List mcpServers) { + @JsonProperty("mcpServers") @Nullable List mcpServers, + @JsonProperty("additionalDirectories") @Nullable List additionalDirectories, + @JsonProperty("_meta") @Nullable Map meta) { + public ForkSessionRequest(String sessionId, String cwd, @Nullable List mcpServers) { this(sessionId, cwd, mcpServers, null, null); } - public ForkSessionRequest(String sessionId, String cwd, List mcpServers, - List additionalDirectories) { + public ForkSessionRequest(String sessionId, String cwd, @Nullable List mcpServers, + @Nullable List additionalDirectories) { this(sessionId, cwd, mcpServers, additionalDirectories, null); } } @@ -598,34 +1615,83 @@ public ForkSessionRequest(String sessionId, String cwd, List mcpServe */ @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - @SuppressWarnings("removal") // 'models' references the deprecated-for-removal SessionModelState public record ForkSessionResponse(@JsonProperty("sessionId") String sessionId, - @JsonProperty("modes") SessionModeState modes, @JsonProperty("models") SessionModelState models, - @JsonProperty("configOptions") List configOptions, - @JsonProperty("_meta") Map meta) { - public ForkSessionResponse(String sessionId, SessionModeState modes, SessionModelState models) { - this(sessionId, modes, models, null, null); + @JsonProperty("modes") @Nullable SessionModeState modes, + @JsonProperty("configOptions") @Nullable List configOptions, + @JsonProperty("_meta") @Nullable Map meta) { + public ForkSessionResponse(String sessionId, @Nullable SessionModeState modes) { + this(sessionId, modes, null, null); } } /** - * Set session config option request - changes a configuration value + * The params of {@code session/set_config_option}: the client asks the agent to change one + * config option of an ACP session, such as switching the model. Build it with + * {@link #select(String, String, String) select} for a {@link SessionConfigSelect} or + * {@link #bool(String, String, boolean) bool} for a {@link SessionConfigBoolean}. A client + * sends it with {@link com.agentclientprotocol.sdk.client.AcpSyncClient#setSessionConfigOption + * AcpSyncClient.setSessionConfigOption} or the {@code AcpAsyncClient} method of the same name, + * once {@code initialize} is answered. The agent's handler, an + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.SetSessionConfigOptionHandler} or a + * {@link com.agentclientprotocol.sdk.annotation.SetSessionConfigOption @SetSessionConfigOption} + * method, receives it and answers with a {@link SetSessionConfigOptionResponse} that lists + * every option of the session. An agent without a handler answers {@code -32601} + * (method not found). + * + *

    + * {@link #configId()} is the {@code id} of an option the agent offered, and {@link #value()} is + * the new value: for a select, the {@link SessionConfigSelectOption#value()} of one of its + * options, a {@code String}, without a {@code type}; for a boolean option, a {@code Boolean}, + * with the type {@code "boolean"}. No capability guards the request: the client may change an + * option at any time in the session, during a prompt turn too. + * + *

    + * The SDK checks neither the id nor the value against the options the agent offered, nor that + * the value fits the type: read from the wire, the value is whatever JSON value arrived, such + * as a {@code String}, a {@code Boolean} or a number. The agent answers an option or a value it + * did not offer with an {@link com.agentclientprotocol.sdk.error.AcpProtocolException} of code + * {@code -32602} ({@link com.agentclientprotocol.sdk.error.AcpErrorCodes#INVALID_PARAMS}). A + * {@link com.agentclientprotocol.sdk.annotation.ConfigId @ConfigId} parameter of a + * {@code @SetSessionConfigOption} method receives the id, and a + * {@link com.agentclientprotocol.sdk.annotation.ConfigValue @ConfigValue} parameter the value, + * typed by the parameter: {@code String} for a select, {@code boolean} for a boolean option, + * {@code Object} for either; a value of the other kind is answered {@code -32602} without + * calling the method. A request without {@code sessionId}, {@code configId} or {@code value} is + * answered {@code -32602} before any handler is called. + * + * @param sessionId the ACP session whose option changes + * @param configId the {@code id} of the option to change + * @param value the new value: a {@code String} for a select, a {@code Boolean} for a boolean + * option + * @param type {@code "boolean"} for a boolean option, or {@code null} for a select + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record SetSessionConfigOptionRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("configId") String configId, @JsonProperty("value") Object value, - @JsonProperty("type") String type, - @JsonProperty("_meta") Map meta) { + @JsonProperty("type") @Nullable String type, + @JsonProperty("_meta") @Nullable Map meta) { /** - * Creates a request to set a select-type config option. + * Returns a request that sets a select option to one of its values, without a type or + * {@code _meta}. + * @param sessionId the ACP session + * @param configId the select's {@code id} + * @param value the {@link SessionConfigSelectOption#value()} of the option to choose + * @return the request */ public static SetSessionConfigOptionRequest select(String sessionId, String configId, String value) { return new SetSessionConfigOptionRequest(sessionId, configId, value, null, null); } /** - * Creates a request to set a boolean-type config option. + * Returns a request that turns a boolean option on or off, with the type {@code "boolean"} + * and without {@code _meta}. Send it only for an option the agent offered as a + * {@link SessionConfigBoolean}. + * @param sessionId the ACP session + * @param configId the boolean option's {@code id} + * @param value {@code true} to turn the option on, {@code false} to turn it off + * @return the request */ public static SetSessionConfigOptionRequest bool(String sessionId, String configId, boolean value) { return new SetSessionConfigOptionRequest(sessionId, configId, value, "boolean", null); @@ -633,12 +1699,30 @@ public static SetSessionConfigOptionRequest bool(String sessionId, String config } /** - * Set session config option response - returns full config state + * The result of {@code session/set_config_option}: every config option of the session with its + * current value, not only the one that changed, since one change can change other options too, + * such as the reasoning levels a model offers. The protocol requires the full list. The agent's + * handler returns it; the client's {@code setSessionConfigOption(...)} completes with it, and + * its list replaces the client's copy. + * + *

    + * {@code configOptions} is required: an answer without it fails the client's call with an + * {@link AcpError} of code {@code -32603}. An option of a type this SDK does not know reads as + * an {@link UnknownSessionConfigOption}; an option this SDK cannot read, such as a select whose + * list mixes options and groups, fails the call the same way. The SDK does not check that the + * list is complete. + * + * @param configOptions every config option of the session, with its current value + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record SetSessionConfigOptionResponse( @JsonProperty("configOptions") List configOptions, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param configOptions every config option of the session + */ public SetSessionConfigOptionResponse(List configOptions) { this(configOptions, null); } @@ -649,1139 +1733,5345 @@ public SetSessionConfigOptionResponse(List configOptions) { // --------------------------- /** - * Request permission from user + * The params of {@code session/request_permission}: the agent asks the client to let the user + * approve a tool call, and offers the options to choose from, such as "allow once" and + * "reject". An agent in a prompt turn sends it with + * {@link com.agentclientprotocol.sdk.agent.SessionClient#requestPermission + * requestPermission(...)} on the prompt context's {@code client()}, or the method of the same name on + * {@code AcpAsyncAgent} or {@code AcpSyncAgent}; + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askPermission(String, ToolKind) + * askPermission} and + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askChoice(String, String...) + * askChoice} on the prompt context build one for you. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#requestPermissionHandler + * requestPermissionHandler} on the client builder, receives it and answers with a + * {@link RequestPermissionResponse}. + * + *

    + * Every client is expected to handle it, so it has no capability and the agent checks none; a + * client without the handler answers {@code -32601} (method not found). The request names a + * tool call the agent announced with a {@link ToolCall} session update, so the client can show + * the user what it is asked to allow. {@code askPermission} and {@code askChoice} announce a + * pending tool call with a new random ID, send this request about it, then settle the tool call + * with a {@code tool_call_update}: {@code completed} once the user chose an option, either way, + * and {@code failed} for any other outcome. An agent that sends this request itself announces + * the tool call itself. + * + *

    + * The user's answer counts against the agent's request timeout, which its builder's + * {@code requestTimeout} sets: a user who takes longer fails the call with a + * {@link java.util.concurrent.TimeoutException}, and the agent sends {@code $/cancel_request} + * for the request. When the client cancels the prompt turn, the protocol requires it to answer + * every pending permission request with {@link PermissionCancelled}; the SDK leaves that to the + * client's handler. + * + *

    + * Each option's ID should be unique within the request. The SDK checks neither the IDs nor the + * tool call on either side. A client answers {@code -32602} (invalid params) when a required + * field is missing, including one of an option. + * + * @param sessionId the ACP session the tool call belongs to + * @param toolCall the tool call to approve: its ID, and the title, kind and status the client + * shows + * @param options the options the user chooses from + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record RequestPermissionRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("toolCall") ToolCallUpdate toolCall, - @JsonProperty("options") List options) { + @JsonProperty("options") List options, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session + * @param toolCall the tool call to approve + * @param options the options the user chooses from + */ + public RequestPermissionRequest(String sessionId, ToolCallUpdate toolCall, List options) { + this(sessionId, toolCall, options, null); + } } /** - * Permission response from user + * The result of {@code session/request_permission}: the user's decision, a + * {@link RequestPermissionOutcome}. The client's permission handler returns it, with a + * {@link PermissionSelected} for the option the user chose or a {@link PermissionCancelled} + * once the prompt turn was cancelled; the agent's {@code requestPermission(...)} completes with + * it. On the wire the outcome is an object with its own {@code outcome} member, for example + * {@code {"outcome":{"outcome":"selected","optionId":"allow"}}}. + * + *

    + * The outcome is required: an answer without it, or with an empty or {@code null} result, fails + * the agent's call with an {@link AcpError} of code {@code -32603}. An outcome of a kind this + * SDK does not know reads as an {@link UnknownPermissionOutcome}. + * + * @param outcome the user's decision + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record RequestPermissionResponse(@JsonProperty("outcome") RequestPermissionOutcome outcome) { + public record RequestPermissionResponse(@JsonProperty("outcome") RequestPermissionOutcome outcome, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param outcome the user's decision + */ + public RequestPermissionResponse(RequestPermissionOutcome outcome) { + this(outcome, null); + } } /** - * Session update notification - real-time progress + * The params of {@code session/update}: one {@link SessionUpdate} for an ACP session. The agent + * sends it to show the client its work as it happens, such as its reply, its tool calls and its + * plan. During a prompt turn the agent sends it with + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendSessionUpdate PromptContext.sendSessionUpdate} + * or a shortcut such as {@code sendMessage}; at other times with + * {@link com.agentclientprotocol.sdk.agent.AcpSyncAgent#sendSessionUpdate + * AcpSyncAgent.sendSessionUpdate} or the {@code AcpAsyncAgent} method of the same name. Both + * wrap the update in this record. The client's session-update handlers + * ({@link com.agentclientprotocol.sdk.client.AcpClient.SyncSpec#sessionUpdateHandler + * sessionUpdateHandler} on the client builder) receive it. It is a notification, so it gets no + * answer of its own. + * + *

    + * The Java client hands notifications to its update handlers one at a time, in the order the agent + * sent them, and a response from the agent completes its caller only after the update handlers have + * handled every notification sent before it: when {@code prompt(...)} returns, that turn's + * updates have all been handled. Updates are not limited to prompt turns. An agent replays a + * loaded session's conversation as updates before it answers {@code session/load}, and can send + * others, such as its available commands, at any time. + * + *

    + * The protocol requires an agent to send a turn's updates before it answers the prompt, also + * after a {@code session/cancel}. The SDK's prompt contexts drop the updates a handler sends + * once its prompt has been answered, by the handler or by the SDK when the cancel grace period + * or the maximum prompt duration passed (see {@link PromptTimeouts}). + * + *

    + * The Java client skips, with a warning in the log, a received notification it cannot read or + * that lacks a required member at any depth: an update without its content, a plan entry + * without its text. The whole notification is skipped, not only the bad part. An update of a + * kind this SDK does not know is not skipped: it reads as an {@link UnknownSessionUpdate}. A + * client without a session-update handler ignores {@code session/update}, also with a warning. + * + * @param sessionId the ACP session the update belongs to + * @param update the update + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record SessionNotification(@JsonProperty("sessionId") String sessionId, @JsonProperty("update") SessionUpdate update, - @JsonProperty("_meta") Map meta) { + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a notification without {@code _meta}. + * @param sessionId the ACP session the update belongs to + * @param update the update + */ public SessionNotification(String sessionId, SessionUpdate update) { this(sessionId, update, null); } } /** - * Read text file request + * The params of {@code fs/read_text_file}: the agent asks the client for the content of a text + * file, including changes the user has not yet saved in the editor. An agent in a prompt turn + * usually sends it with + * {@link com.agentclientprotocol.sdk.agent.PromptContext#readFile(String, Integer, Integer) + * readFile}, which fills in the session; + * {@link com.agentclientprotocol.sdk.agent.SessionClient#readTextFile readTextFile(...)} on the + * prompt context's {@code client()}, and the methods of the same name on {@code AcpAsyncAgent} and + * {@code AcpSyncAgent}, take the whole request. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#readTextFileHandler + * readTextFileHandler} on the client builder, receives it and answers with a + * {@link ReadTextFileResponse}. + * + *

    + * Only a client that advertises {@code fs.readTextFile} ({@link FileSystemCapability}) takes + * it. The agent fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it when the + * client did not advertise it, and a client builder that advertises it without registering the + * handler fails at {@code build()}. + * + *

    + * {@code line} counts from 1, so the first line of the file is line 1, and {@code limit} is a + * number of lines; leave both {@code null} to read the whole file. The path must be absolute. + * The SDK checks neither the path nor the numbers on either side: they reach the client's + * handler as the agent sent them, so a client handler that reads any path it is given lets the + * agent read every file the client process can read. + * + * @param sessionId the ACP session the read is for + * @param path the absolute path of the file + * @param line the first line to read, counting from 1, or {@code null} to start at the first + * line + * @param limit the most lines to read, or {@code null} for the rest of the file + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ReadTextFileRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("path") String path, - @JsonProperty("line") Integer line, @JsonProperty("limit") Integer limit) { + @JsonProperty("line") @Nullable Integer line, @JsonProperty("limit") @Nullable Integer limit, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the read is for + * @param path the absolute path of the file + * @param line the first line to read, counting from 1, or {@code null} + * @param limit the most lines to read, or {@code null} + */ + public ReadTextFileRequest(String sessionId, String path, @Nullable Integer line, @Nullable Integer limit) { + this(sessionId, path, line, limit, null); + } } /** - * Read text file response + * The result of {@code fs/read_text_file}: the text the client read. The client's read handler + * returns it; the agent's {@code readTextFile(...)} completes with it, and + * {@link com.agentclientprotocol.sdk.agent.PromptContext#readFile(String) + * PromptContext.readFile} returns its {@link #content()}. + * + *

    + * Whether the content is the whole file or only the lines the request asked for is up to the + * client's handler; the SDK does not check it. + * + * @param content the text read: the whole file, or the requested lines + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ReadTextFileResponse(@JsonProperty("content") String content) { + public record ReadTextFileResponse(@JsonProperty("content") String content, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param content the text read + */ + public ReadTextFileResponse(String content) { + this(content, null); + } } /** - * Write text file request + * The params of {@code fs/write_text_file}: the agent asks the client to write text to a file. + * An agent in a prompt turn usually sends it with + * {@link com.agentclientprotocol.sdk.agent.PromptContext#writeFile(String, String) writeFile}, + * which fills in the session; + * {@link com.agentclientprotocol.sdk.agent.SessionClient#writeTextFile writeTextFile(...)} on + * the prompt context's {@code client()}, and the methods of the same name on {@code AcpAsyncAgent} and + * {@code AcpSyncAgent}, take the whole request. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#writeTextFileHandler + * writeTextFileHandler} on the client builder, receives it and answers with a + * {@link WriteTextFileResponse} once the file is written. + * + *

    + * Only a client that advertises {@code fs.writeTextFile} ({@link FileSystemCapability}) takes + * it, with the same checks as {@link ReadTextFileRequest}: the agent does not send it to a + * client that did not advertise it, and a client builder fails at {@code build()} if it + * advertises it without the handler. The protocol requires the client to create the file if it + * does not exist. + * + *

    + * The path must be absolute. The SDK checks neither the path nor the content on either side, so + * a client handler that writes any path it is given lets the agent change every file the client + * process can write. + * + * @param sessionId the ACP session the write is for + * @param path the absolute path of the file + * @param content the text to write + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record WriteTextFileRequest(@JsonProperty("sessionId") String sessionId, @JsonProperty("path") String path, - @JsonProperty("content") String content) { + @JsonProperty("content") String content, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the write is for + * @param path the absolute path of the file + * @param content the text to write + */ + public WriteTextFileRequest(String sessionId, String path, String content) { + this(sessionId, path, content, null); + } } /** - * Write text file response + * The result of {@code fs/write_text_file}: an empty answer that confirms the file was written. + * The client's write handler returns it; the agent's {@code writeTextFile(...)} completes with + * it, and {@link com.agentclientprotocol.sdk.agent.PromptContext#writeFile(String, String) + * PromptContext.writeFile} completes. A failed write is an error answer, not this record. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record WriteTextFileResponse() { + public record WriteTextFileResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ + public WriteTextFileResponse() { + this(null); + } } /** - * Create terminal request + * The params of {@code terminal/create}: the agent asks the client to start a command in a new + * terminal. The client answers at once with the terminal's ID ({@link CreateTerminalResponse}), + * without waiting for the command to end; the agent passes that ID to {@code terminal/output}, + * {@code terminal/wait_for_exit}, {@code terminal/kill} and {@code terminal/release}. Most + * agents do not build this request: + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} on the + * prompt context builds it from a {@link com.agentclientprotocol.sdk.agent.Command} and runs + * the whole sequence. To send it yourself, use + * {@link com.agentclientprotocol.sdk.agent.SessionClient#createTerminal createTerminal(...)} on + * the prompt context's {@code client()}, or the method of the same name on {@code AcpAsyncAgent} or + * {@code AcpSyncAgent}. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#createTerminalHandler + * createTerminalHandler} on the client builder, receives it. + * + *

    + * Only a client that advertises {@code terminal} ({@link ClientCapabilities#terminal()}) takes + * it. The agent fails the call with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without sending it when the + * client did not advertise it. A client builder that advertises {@code terminal} must register + * all five terminal handlers, or {@code build()} fails. + * + *

    + * The protocol requires the agent to release every terminal it creates + * ({@link ReleaseTerminalRequest}). {@code execute} does so in every case: after reading the + * output, when a step fails, and when its {@code Mono} is cancelled, for example because the + * prompt was cancelled. An agent that sends this request itself must release the terminal + * itself. To show the output live in a tool call, add a {@link ToolCallTerminal} with the ID + * before releasing the terminal. + * + *

    + * {@code cwd} must be an absolute path. With {@code outputByteLimit}, the client keeps at most + * that many bytes of output, dropping output from the start, and the protocol requires it to + * cut at a character boundary; {@link TerminalOutputResponse#truncated()} then says it did. The + * SDK checks none of the values on either side, and runs nothing itself: a client handler that + * runs every command it is given lets the agent run any program the client process can run. + * + * @param sessionId the ACP session the command is for + * @param command the program to run + * @param args the command's arguments, or {@code null} for none + * @param cwd the absolute working directory, or {@code null} to leave it to the client + * @param env environment variables to set for the command, or {@code null} for none + * @param outputByteLimit the most bytes of output the client keeps, or {@code null} to leave it + * to the client + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record CreateTerminalRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("command") String command, @JsonProperty("args") List args, - @JsonProperty("cwd") String cwd, @JsonProperty("env") List env, - @JsonProperty("outputByteLimit") Long outputByteLimit) { + @JsonProperty("command") String command, @JsonProperty("args") @Nullable List args, + @JsonProperty("cwd") @Nullable String cwd, @JsonProperty("env") @Nullable List env, + @JsonProperty("outputByteLimit") @Nullable Long outputByteLimit, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the command is for + * @param command the program to run + * @param args the command's arguments, or {@code null} + * @param cwd the absolute working directory, or {@code null} + * @param env environment variables to set, or {@code null} + * @param outputByteLimit the most bytes of output the client keeps, or {@code null} + */ + public CreateTerminalRequest(String sessionId, String command, @Nullable List args, + @Nullable String cwd, @Nullable List env, @Nullable Long outputByteLimit) { + this(sessionId, command, args, cwd, env, outputByteLimit, null); + } } /** - * Create terminal response + * The result of {@code terminal/create}: the ID of the new terminal, which the client returns + * without waiting for the command to end. The client's create handler returns it; the agent's + * {@code createTerminal(...)} completes with it. Pass {@link #terminalId()} to the other + * terminal requests and, to show the output in a tool call, to a {@link ToolCallTerminal}. + * + *

    + * The ID is valid until the agent releases the terminal, and the agent must release it + * ({@link ReleaseTerminalRequest}). + * + * @param terminalId the new terminal's ID + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record CreateTerminalResponse(@JsonProperty("terminalId") String terminalId) { + public record CreateTerminalResponse(@JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param terminalId the new terminal's ID + */ + public CreateTerminalResponse(String terminalId) { + this(terminalId, null); + } } /** - * Terminal output request + * The params of {@code terminal/output}: the agent asks for a terminal's output so far, without + * waiting for the command to end. The answer, a {@link TerminalOutputResponse}, also says + * whether the output was truncated and, once the command has ended, how it ended. The agent + * sends it with {@link com.agentclientprotocol.sdk.agent.SessionClient#getTerminalOutput + * getTerminalOutput(...)} on the prompt context's {@code client()}, or the method of the same name on + * {@code AcpAsyncAgent} or {@code AcpSyncAgent}; + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} sends it + * once the command has ended. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#terminalOutputHandler + * terminalOutputHandler} on the client builder, receives it. + * + *

    + * The terminal must be one the agent created with {@code terminal/create} and has not released. + * The SDK checks the ID on neither side. Like every terminal method, the agent sends it only to a + * client that advertised the terminal capability. + * After a {@code terminal/kill} the terminal can still be read. + * + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID, from {@link CreateTerminalResponse#terminalId()} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record TerminalOutputRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("terminalId") String terminalId) { + @JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID + */ + public TerminalOutputRequest(String sessionId, String terminalId) { + this(sessionId, terminalId, null); + } } /** - * Terminal output response + * The result of {@code terminal/output}: a terminal's output so far, whether it was truncated, + * and how the command ended once it has. The client's output handler returns it; the agent's + * {@code getTerminalOutput(...)} completes with it, and + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} copies its + * output and {@code truncated} into the + * {@link com.agentclientprotocol.sdk.agent.CommandResult}. + * + *

    + * {@code truncated} is {@code true} when the client dropped output from the start to stay + * within the {@code outputByteLimit} of the {@link CreateTerminalRequest}. {@code exitStatus} + * is {@code null} while the command runs. Whether standard error is part of the output is up to + * the client. An answer without {@code truncated}, or with {@code null}, reads as + * {@code false}. + * + * @param output the output captured so far + * @param truncated whether the client dropped output from the start to stay within the output + * limit + * @param exitStatus how the command ended, or {@code null} while it runs + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record TerminalOutputResponse(@JsonProperty("output") String output, - @JsonProperty("truncated") boolean truncated, @JsonProperty("exitStatus") TerminalExitStatus exitStatus) { + @JsonProperty("truncated") boolean truncated, @JsonProperty("exitStatus") @Nullable TerminalExitStatus exitStatus, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a response without {@code _meta}. + * @param output the output captured so far + * @param truncated whether the client dropped output from the start + * @param exitStatus how the command ended, or {@code null} while it runs + */ + public TerminalOutputResponse(String output, boolean truncated, @Nullable TerminalExitStatus exitStatus) { + this(output, truncated, exitStatus, null); + } } /** - * Release terminal request + * The params of {@code terminal/release}: the agent is done with a terminal. The client kills + * the command if it is still running and frees the terminal; the ID is invalid afterwards for + * every other terminal request. A tool call that shows the terminal ({@link ToolCallTerminal}) + * should keep showing its output. The agent sends it with + * {@link com.agentclientprotocol.sdk.agent.SessionClient#releaseTerminal releaseTerminal(...)} + * on the prompt context's {@code client()}, or the method of the same name on {@code AcpAsyncAgent} or + * {@code AcpSyncAgent}. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#releaseTerminalHandler + * releaseTerminalHandler} on the client builder, receives it and answers with a + * {@link ReleaseTerminalResponse}. + * + *

    + * The protocol requires the agent to release every terminal it creates. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} sends this + * request exactly once for each terminal it creates: after reading the output, after a step + * fails, and when its {@code Mono} is cancelled, for example because the prompt was cancelled; + * in that last case it sends the release on its own and logs a failure at WARN. An agent that + * sends {@code terminal/create} itself must send this itself, also on errors and cancellation. + * The SDK releases no terminal in any other case, not even when a session or the connection + * ends. + * + *

    + * The terminal must be one the agent created with {@code terminal/create} and has not released. + * The SDK checks the ID on neither side. Like every terminal method, the agent sends it only to a + * client that advertised the terminal capability. + * + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID, from {@link CreateTerminalResponse#terminalId()} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record ReleaseTerminalRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("terminalId") String terminalId) { + @JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID + */ + public ReleaseTerminalRequest(String sessionId, String terminalId) { + this(sessionId, terminalId, null); + } } /** - * Release terminal response + * The result of {@code terminal/release}: an empty answer that confirms the terminal was + * released. The client's release handler returns it; the agent's {@code releaseTerminal(...)} + * completes with it. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ReleaseTerminalResponse() { + public record ReleaseTerminalResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ + public ReleaseTerminalResponse() { + this(null); + } } /** - * Wait for terminal exit request + * The params of {@code terminal/wait_for_exit}: the agent asks the client to answer once a + * terminal's command has ended. The answer, a {@link WaitForTerminalExitResponse}, carries the + * exit code or the signal that ended it. The agent sends it with + * {@link com.agentclientprotocol.sdk.agent.SessionClient#waitForTerminalExit + * waitForTerminalExit(...)} on the prompt context's {@code client()}, or the method of the same name on + * {@code AcpAsyncAgent} or {@code AcpSyncAgent}; + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} sends it + * right after creating the terminal. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#waitForTerminalExitHandler + * waitForTerminalExitHandler} on the client builder, receives it. + * + *

    + * The wait counts against the agent's request timeout (its builder's {@code requestTimeout}): a + * command that runs longer fails the call with a {@link java.util.concurrent.TimeoutException}, + * which the blocking API wraps in an + * {@link com.agentclientprotocol.sdk.error.AcpTimeoutException}. The command keeps running + * until the agent kills or releases the terminal. To give a command its own time limit, race + * this request against a timer, then send {@code terminal/kill}, {@code terminal/output} and + * {@code terminal/release}. + * + *

    + * The terminal must be one the agent created with {@code terminal/create} and has not released. + * The SDK checks the ID on neither side. Like every terminal method, the agent sends it only to a + * client that advertised the terminal capability. + * + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID, from {@link CreateTerminalResponse#terminalId()} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record WaitForTerminalExitRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("terminalId") String terminalId) { + @JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID + */ + public WaitForTerminalExitRequest(String sessionId, String terminalId) { + this(sessionId, terminalId, null); + } } /** - * Wait for terminal exit response + * The result of {@code terminal/wait_for_exit}: how a terminal's command ended, with an exit + * code or with the signal that ended it. The client's handler returns it once the command has + * ended; the agent's {@code waitForTerminalExit(...)} completes with it, and + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} copies both + * into the {@link com.agentclientprotocol.sdk.agent.CommandResult}. It carries the same two + * values as a {@link TerminalExitStatus}. + * + *

    + * A process ends with an exit code or with a signal, so one of the two is normally + * {@code null}. Both components are optional, so a peer that answers with an empty or + * {@code null} result gives this record with both {@code null} (see {@link DefaultOnNull}). The + * schema allows an exit code up to 4294967295 (an unsigned 32-bit number); one above + * {@link Integer#MAX_VALUE} cannot be read into this record, and the answer that carries it + * fails the agent's call with an {@link AcpError} of code {@code -32603}. + * + * @param exitCode the exit code, or {@code null} if a signal ended the process + * @param signal the signal that ended the process, or {@code null} if it exited + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record WaitForTerminalExitResponse(@JsonProperty("exitCode") Integer exitCode, - @JsonProperty("signal") String signal) { + public record WaitForTerminalExitResponse(@JsonProperty("exitCode") @Nullable Integer exitCode, + @JsonProperty("signal") @Nullable String signal, + @JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** + * Creates a response without {@code _meta}. + * @param exitCode the exit code, or {@code null} + * @param signal the signal that ended the process, or {@code null} + */ + public WaitForTerminalExitResponse(@Nullable Integer exitCode, @Nullable String signal) { + this(exitCode, signal, null); + } } /** - * Kill terminal request + * The params of {@code terminal/kill}: the agent asks the client to kill a terminal's command + * but keep the terminal. Afterwards {@code terminal/output} still gives the final output and + * {@code terminal/wait_for_exit} the exit status, and the agent must still release the terminal + * ({@link ReleaseTerminalRequest}). The agent sends it with + * {@link com.agentclientprotocol.sdk.agent.SessionClient#killTerminal killTerminal(...)} on the + * prompt context's {@code client()}, or the method of the same name on {@code AcpAsyncAgent} or + * {@code AcpSyncAgent}. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#killTerminalHandler + * killTerminalHandler} on the client builder, receives it and answers with a + * {@link KillTerminalCommandResponse}. + * + *

    + * Use it to stop a command that runs too long: race {@code terminal/wait_for_exit} against a + * timer, kill the command when the timer fires, read the output, then release the terminal. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#execute(Command) execute} does not + * send it: when its wait fails or is cancelled, it releases the terminal, which kills the + * command as well. The schema calls this type {@code KillTerminalRequest}. + * + *

    + * The terminal must be one the agent created with {@code terminal/create} and has not released. + * The SDK checks the ID on neither side. Like every terminal method, the agent sends it only to a + * client that advertised the terminal capability. + * + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID, from {@link CreateTerminalResponse#terminalId()} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record KillTerminalCommandRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("terminalId") String terminalId) { + @JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a request without {@code _meta}. + * @param sessionId the ACP session the terminal belongs to + * @param terminalId the terminal's ID + */ + public KillTerminalCommandRequest(String sessionId, String terminalId) { + this(sessionId, terminalId, null); + } } /** - * Kill terminal response + * The result of {@code terminal/kill}: an empty answer that confirms the command was killed. + * The client's kill handler returns it; the agent's {@code killTerminal(...)} completes with + * it. The terminal stays valid until the agent releases it. The schema calls this type + * {@code KillTerminalResponse}. + * + *

    + * It has only {@code _meta}, so a peer that answers with an empty or {@code null} result gives + * this record, with no {@code _meta} (see {@link DefaultOnNull}). + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record KillTerminalCommandResponse() { + public record KillTerminalCommandResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + /** Creates the empty response, without {@code _meta}. */ + public KillTerminalCommandResponse() { + this(null); + } } // --------------------------- - // Elicitation (UNSTABLE) + // Elicitation // --------------------------- /** - * Create elicitation request - agent asks client for structured user input. - * Supports form mode (JSON Schema) and URL mode (out-of-band). - * Scope is either session (sessionId) or request (requestId). + * The params of {@code elicitation/create}: the agent asks the client to collect structured + * input from the user, with a form or by sending the user to a URL. In form mode the client + * renders {@link #requestedSchema()}, an {@link ElicitationSchema}, as a form; in URL mode it + * opens {@link #url()} outside the client, once the user agrees, for an interaction such as a + * sign-in that the agent's own server handles. Build it with + * {@link #form(String, String, ElicitationSchema) form} or + * {@link #url(String, String, String, String) url}, which scope it to a session. The agent + * sends it with {@link com.agentclientprotocol.sdk.agent.SessionClient#createElicitation + * createElicitation(...)} on the prompt context's {@code client()}, or the method of the same name on + * {@code AcpAsyncAgent} or {@code AcpSyncAgent}. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#createElicitationHandler + * createElicitationHandler} on the client builder, receives it and answers with a + * {@link CreateElicitationResponse}. + * + *

    + * The client advertises each mode it supports ({@link ElicitationCapabilities}), and the + * protocol forbids an agent to request a mode the client did not advertise. The agent fails + * such a call with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} + * ({@code elicitation.form} or {@code elicitation.url}) without sending it, and the client + * answers such a request {@code -32602} (invalid params) without calling its handler. Before + * {@code initialize} neither side knows the modes: the request is sent and the handler is + * called. The mode is an open string, compared with {@code equals}, so {@code "FORM"} is not + * form mode. For a mode this SDK does not know, the agent requires only that the client sent an + * elicitation capability, even one with no mode, and the client passes the request to its + * handler, which must not treat it as a known mode. + * + *

    + * Scope the request with {@link #sessionId()}, optionally with {@link #toolCallId()}, or, + * outside a session, with {@link #requestId()}, the ID of the JSON-RPC request it belongs to. + * Form mode needs {@code requestedSchema}; URL mode needs {@code elicitationId}, unique among + * the agent's open URL elicitations on the connection, and {@code url}. The SDK checks none of + * these on either side; {@code message} and {@code mode} must not be {@code null}. The user's + * answer counts against the agent's request timeout, as for a {@link RequestPermissionRequest}. + * + *

    + * Form mode must not ask for secrets such as passwords, API keys or tokens: the protocol + * requires URL mode for them. The URL must not carry credentials or personal data, and the + * protocol requires the client to show it and ask the user before opening it. + * + * @param sessionId the ACP session the elicitation belongs to, or {@code null} for a request + * scope + * @param toolCallId the tool call within the session, or {@code null} + * @param requestId the ID of the JSON-RPC request the elicitation belongs to, a string or a + * number, or {@code null} for a session scope + * @param message what the agent asks for and why, shown to the user + * @param mode the mode, {@link #MODE_FORM} or {@link #MODE_URL} + * @param requestedSchema the form's fields, for form mode, or {@code null} + * @param elicitationId the elicitation's ID, for URL mode, or {@code null} + * @param url the URL to open, for URL mode, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record CreateElicitationRequest(@JsonProperty("sessionId") String sessionId, - @JsonProperty("toolCallId") String toolCallId, @JsonProperty("requestId") Object requestId, + public record CreateElicitationRequest(@JsonProperty("sessionId") @Nullable String sessionId, + @JsonProperty("toolCallId") @Nullable String toolCallId, @JsonProperty("requestId") @Nullable Object requestId, @JsonProperty("message") String message, @JsonProperty("mode") String mode, - @JsonProperty("requestedSchema") ElicitationSchema requestedSchema, - @JsonProperty("elicitationId") String elicitationId, @JsonProperty("url") String url, - @JsonProperty("_meta") Map meta) { + @JsonProperty("requestedSchema") @Nullable ElicitationSchema requestedSchema, + @JsonProperty("elicitationId") @Nullable String elicitationId, @JsonProperty("url") @Nullable String url, + @JsonProperty("_meta") @Nullable Map meta) { + + /** The form mode, {@value}: the client renders {@code requestedSchema} as a form. */ + public static final String MODE_FORM = "form"; + + /** + * The URL mode, {@value}: the client opens {@code url} outside the client, once the user + * agrees. + */ + public static final String MODE_URL = "url"; /** - * Creates a form-mode elicitation request scoped to a session. + * Creates a form-mode request scoped to a session, without a tool call or {@code _meta}. + * @param sessionId the ACP session + * @param message what the agent asks for and why + * @param schema the form's fields + * @return the request */ public static CreateElicitationRequest form(String sessionId, String message, ElicitationSchema schema) { - return new CreateElicitationRequest(sessionId, null, null, message, "form", schema, null, + return new CreateElicitationRequest(sessionId, null, null, message, MODE_FORM, schema, null, null, null); } /** - * Creates a URL-mode elicitation request scoped to a session. + * Creates a URL-mode request scoped to a session, without a tool call or {@code _meta}. + * @param sessionId the ACP session + * @param message what the agent asks for and why + * @param elicitationId the elicitation's ID, unique among the agent's open URL elicitations + * on the connection + * @param url the URL to open + * @return the request */ public static CreateElicitationRequest url(String sessionId, String message, String elicitationId, String url) { - return new CreateElicitationRequest(sessionId, null, null, message, "url", null, + return new CreateElicitationRequest(sessionId, null, null, message, MODE_URL, null, elicitationId, url, null); } } /** - * Create elicitation response - client returns user's input. - * Action is "accept" (with content), "decline", or "cancel". + * The result of {@code elicitation/create}: what the user did, an {@link ElicitationAction}, + * and the form content when the user accepted a form. The client's elicitation handler returns + * it, built with {@link #accept(Map)}, {@link #accept()}, {@link #decline()} or + * {@link #cancel()}; the agent's {@code createElicitation(...)} completes with it. Compare the + * action with {@code equals}, for example + * {@code ElicitationAction.ACCEPT.equals(response.action())}, not with {@code ==}. + * + *

    + * Accept means the user submitted the form, or agreed to open the URL; for URL mode it does not + * mean the interaction there has finished. Decline means the user said no, and cancel that the + * user dismissed the request without choosing. The protocol requires the agent to handle + * decline, cancel and failure, for example by falling back or by failing the operation. + * + *

    + * {@code content} maps the form's property names to the values the user entered: strings, + * integers, numbers, booleans or lists of strings. It is only meaningful on accept, and + * normally {@code null} for URL mode. The SDK checks neither the values against the form nor + * their types, on either side, so the agent should check them again. An answer without an + * action fails the agent's call with an {@link AcpError} of code {@code -32603}; an action this + * SDK does not know is kept. + * + * @param action what the user did + * @param content the form content on accept, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) public record CreateElicitationResponse(@JsonProperty("action") ElicitationAction action, - @JsonProperty("content") Map content, - @JsonProperty("_meta") Map meta) { + @JsonProperty("content") @Nullable Map content, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Returns an answer that the user accepted the form, with its content. + * @param content the property names and the values the user entered + * @return the answer + */ public static CreateElicitationResponse accept(Map content) { return new CreateElicitationResponse(ElicitationAction.ACCEPT, content, null); } + /** + * Returns an answer that the user accepted, without content, as for a URL elicitation the + * user agreed to open. + * @return the answer + */ + public static CreateElicitationResponse accept() { + return new CreateElicitationResponse(ElicitationAction.ACCEPT, null, null); + } + + /** + * Returns an answer that the user declined. + * @return the answer + */ public static CreateElicitationResponse decline() { return new CreateElicitationResponse(ElicitationAction.DECLINE, null, null); } + /** + * Returns an answer that the user dismissed the request without choosing. + * @return the answer + */ public static CreateElicitationResponse cancel() { return new CreateElicitationResponse(ElicitationAction.CANCEL, null, null); } } /** - * Elicitation action - user's response to an elicitation. + * What the user did with an elicitation: the {@link CreateElicitationResponse#action()} a + * client answers {@code elicitation/create} with. Clients send one of the constants; agents + * compare the value they receive with {@code equals}, or switch on {@link #value()}, not with + * {@code ==}. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. The protocol + * forbids an agent to treat an unknown action as a known one. + * + * @param value the wire value, such as {@code "accept"} */ - @UnstableAcpApi - public enum ElicitationAction { + public record ElicitationAction(@JsonValue String value) { - @JsonProperty("accept") - ACCEPT, @JsonProperty("decline") - DECLINE, @JsonProperty("cancel") - CANCEL + /** {@code "accept"}: the user submitted the form, or agreed to open the URL. */ + public static final ElicitationAction ACCEPT = new ElicitationAction("accept"); - } + /** {@code "decline"}: the user explicitly declined. */ + public static final ElicitationAction DECLINE = new ElicitationAction("decline"); - /** - * Complete elicitation notification - signals URL-mode elicitation is done. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record CompleteElicitationNotification(@JsonProperty("elicitationId") String elicitationId, - @JsonProperty("_meta") Map meta) { - public CompleteElicitationNotification(String elicitationId) { - this(elicitationId, null); + /** {@code "cancel"}: the user dismissed the request without choosing. */ + public static final ElicitationAction CANCEL = new ElicitationAction("cancel"); + + private static final List KNOWN = List.of(ACCEPT, DECLINE, CANCEL); + + /** + * Creates an action for a wire value. Prefer {@link #of}, which returns the constant for a + * known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public ElicitationAction { + Objects.requireNonNull(value, "value"); } - } - /** - * Elicitation schema - JSON Schema describing form fields for user input. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ElicitationSchema(@JsonProperty("type") String type, - @JsonProperty("properties") Map properties, - @JsonProperty("required") List required, @JsonProperty("title") String title, - @JsonProperty("description") String description) { - public ElicitationSchema(Map properties, List required) { - this("object", properties, required, null, null); + /** + * Returns an action for a wire value: the constant when ACP v1 defines the value, and + * otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ElicitationAction of(String value) { + return knownOrNew(KNOWN, ElicitationAction::value, value, ElicitationAction::new); } - } - /** - * Elicitation property schema - defines a single form field. - */ - @UnstableAcpApi - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true) - @JsonSubTypes({ @JsonSubTypes.Type(value = StringPropertySchema.class, name = "string"), - @JsonSubTypes.Type(value = NumberPropertySchema.class, name = "number"), - @JsonSubTypes.Type(value = IntegerPropertySchema.class, name = "integer"), - @JsonSubTypes.Type(value = BooleanPropertySchema.class, name = "boolean"), - @JsonSubTypes.Type(value = MultiSelectPropertySchema.class, name = "array") }) - public interface ElicitationPropertySchema { - - } - - /** - * String property schema - text input or single-select enum. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record StringPropertySchema( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("default") String defaultValue, @JsonProperty("minLength") Integer minLength, - @JsonProperty("maxLength") Integer maxLength, @JsonProperty("pattern") String pattern, - @JsonProperty("format") String format, @JsonProperty("enum") List enumValues, - @JsonProperty("oneOf") List oneOf) implements ElicitationPropertySchema { + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } - public static StringPropertySchema text(String title) { - return new StringPropertySchema("string", title, null, null, null, null, null, null, null, null); + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); } - public static StringPropertySchema singleSelect(String title, List options) { - return new StringPropertySchema("string", title, null, null, null, null, null, null, null, options); + /** + * Returns the wire value, such as {@code accept}. + * @return the wire value + */ + @Override + public String toString() { + return value; } - } - /** - * Number property schema - floating-point input. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record NumberPropertySchema( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("default") Double defaultValue, @JsonProperty("minimum") Double minimum, - @JsonProperty("maximum") Double maximum) implements ElicitationPropertySchema { } /** - * Integer property schema - whole number input. + * The params of {@code elicitation/complete}, a notification: the agent tells the client that + * the interaction of a URL-mode elicitation, at its URL, has finished. Sending it is optional. + * The agent sends it with + * {@link com.agentclientprotocol.sdk.agent.SessionClient#completeElicitation + * completeElicitation(...)} on the prompt context's {@code client()}, or the method of the same name on + * {@code AcpAsyncAgent} or {@code AcpSyncAgent}, with the + * {@link CreateElicitationRequest#elicitationId()} of the request, and the protocol allows it + * only to the client that received that request. The client's handler, set with + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#completeElicitationHandler + * completeElicitationHandler} on the client builder, receives it. + * + *

    + * The protocol requires the client to ignore an ID it does not know or has already seen + * completed. The SDK passes every notification to the handler, so the handler makes that check. + * The agent sends it without checking the client's capabilities, and neither side checks the + * ID: a notification without one reaches the client's handler with a {@code null} ID. + * + * @param elicitationId the ID of the URL elicitation that finished + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record IntegerPropertySchema( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("default") Long defaultValue, @JsonProperty("minimum") Long minimum, - @JsonProperty("maximum") Long maximum) implements ElicitationPropertySchema { + public record CompleteElicitationNotification(@JsonProperty("elicitationId") String elicitationId, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the notification without {@code _meta}. + * @param elicitationId the ID of the URL elicitation that finished + */ + public CompleteElicitationNotification(String elicitationId) { + this(elicitationId, null); + } } /** - * Boolean property schema - checkbox/toggle input. + * The form of a form-mode elicitation: a flat JSON Schema object whose properties are the + * form's fields. It is the {@link CreateElicitationRequest#requestedSchema()} the client + * renders. {@link #properties()} maps each field's name to an + * {@link ElicitationPropertySchema}, such as a {@link StringPropertySchema}, and + * {@link #required()} lists the names the user must fill in. Build it with + * {@link #ElicitationSchema(Map, List)}, which sets the type to {@code "object"}. + * + *

    + * The type is always {@code "object"}, and the protocol reads a missing type that way. The + * other constructors write the type they are given, and leave it out when it is {@code null}; + * the SDK checks neither the type nor the names in {@code required}. A property of a type this + * SDK does not know reads as an {@link UnknownElicitationPropertySchema}. The protocol asks the + * client to fill in declared defaults and to check the values before answering, and the agent + * to check them again. + * + * @param type the schema type, {@code "object"}, or {@code null} + * @param properties the form's fields by name, or {@code null} + * @param required the names of the fields the user must fill in, or {@code null} + * @param title the form's title, or {@code null} + * @param description what the form is for, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record BooleanPropertySchema( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("default") Boolean defaultValue) implements ElicitationPropertySchema { - } + public record ElicitationSchema(@JsonProperty("type") @Nullable String type, + @JsonProperty("properties") @Nullable Map properties, + @JsonProperty("required") @Nullable List required, @JsonProperty("title") @Nullable String title, + @JsonProperty("description") @Nullable String description, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a schema without {@code _meta}. + * @param type the schema type, {@code "object"}, or {@code null} + * @param properties the form's fields by name, or {@code null} + * @param required the names of the required fields, or {@code null} + * @param title the form's title, or {@code null} + * @param description what the form is for, or {@code null} + */ + public ElicitationSchema(@Nullable String type, @Nullable Map properties, + @Nullable List required, @Nullable String title, @Nullable String description) { + this(type, properties, required, title, description, null); + } - /** - * Multi-select property schema - array of selected values. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record MultiSelectPropertySchema( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("title") String title, @JsonProperty("description") String description, - @JsonProperty("default") List defaultValues, @JsonProperty("items") MultiSelectItems items, - @JsonProperty("minItems") Long minItems, - @JsonProperty("maxItems") Long maxItems) implements ElicitationPropertySchema { + /** + * Creates an object schema with these fields, without a title, description or + * {@code _meta}. + * @param properties the form's fields by name, or {@code null} + * @param required the names of the required fields, or {@code null} + */ + public ElicitationSchema(@Nullable Map properties, @Nullable List required) { + this("object", properties, required, null, null, null); + } } /** - * Multi-select items - defines allowed values for multi-select. + * One field of an elicitation form: a value of {@link ElicitationSchema#properties()}, keyed by + * the field's name. An agent builds a form from the variant records and sends it with + * {@link CreateElicitationRequest#form(String, String, ElicitationSchema) + * CreateElicitationRequest.form}; the client's elicitation handler checks the variant with + * {@code instanceof} and shows the user an input for it. + * + *

    + * The variants: {@link StringPropertySchema} for text, or for one choice when it lists + * {@code enum} values or {@code oneOf} options; {@link NumberPropertySchema} and + * {@link IntegerPropertySchema} for numbers; {@link BooleanPropertySchema} for yes or no; and + * {@link MultiSelectPropertySchema} for several choices. The user's answer, the + * {@link CreateElicitationResponse#content()} of an accepted form, holds a string, a number, a + * boolean or a list of strings for the field, by its name. + * + *

    + * On the wire the {@code type} member names the variant, and each variant record has it as its + * first component. A property of a type this SDK does not know, or one without {@code type}, + * reads as an {@link UnknownElicitationPropertySchema}, so the request that carries it still + * reaches the handler. The interface is not sealed: end an {@code instanceof} chain with a + * branch for anything else (see {@link AcpSchema} on forward compatibility). A property's + * bounds, lengths, pattern and format are for the client to apply: the SDK checks none of them, + * nor the values in the answer. */ - @UnstableAcpApi - @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) - @JsonSubTypes({ @JsonSubTypes.Type(value = UntitledMultiSelectItems.class), - @JsonSubTypes.Type(value = TitledMultiSelectItems.class) }) - public interface MultiSelectItems { - - } + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownElicitationPropertySchema.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = StringPropertySchema.class, name = "string"), + @JsonSubTypes.Type(value = NumberPropertySchema.class, name = "number"), + @JsonSubTypes.Type(value = IntegerPropertySchema.class, name = "integer"), + @JsonSubTypes.Type(value = BooleanPropertySchema.class, name = "boolean"), + @JsonSubTypes.Type(value = MultiSelectPropertySchema.class, name = "array") }) + public interface ElicitationPropertySchema { - /** - * Untitled multi-select items - plain string enum values. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record UntitledMultiSelectItems(@JsonProperty("type") String type, - @JsonProperty("enum") List enumValues) implements MultiSelectItems { } /** - * Titled multi-select items - options with const/title pairs. + * A form property of a type this SDK does not know, kept as received: the agent is on a newer + * protocol version or sent an extension, whose types begin with {@code _}. The protocol forbids + * a client to show it as an input it knows; a client should keep it as it is when it stores or + * forwards the request, and a proxy can forward it unchanged. + * + *

    + * It keeps the {@code type} discriminator ({@code null} when the property had none) and every + * other member in {@link #fields()}, an unmodifiable map in wire order, and writes them back + * unchanged. Names are case sensitive: {@code "STRING"} is an unknown type, not a + * {@link StringPropertySchema}. A nested {@code "object"} property, which the protocol's flat + * forms do not allow, also reads as this record. + * + * @param type the discriminator as received, or {@code null} + * @param fields every other member, in wire order */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record TitledMultiSelectItems( - @JsonProperty("anyOf") List anyOf) implements MultiSelectItems { + public record UnknownElicitationPropertySchema(@JsonProperty("type") @Nullable String type, + @JsonAnySetter @JsonAnyGetter Map fields) implements ElicitationPropertySchema { + /** + * Creates an unknown property. The fields are copied in their order, and {@code null} + * fields become an empty map. + * @param type the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownElicitationPropertySchema { + fields = unknownFields(fields); + } } /** - * Enum option - a named value for single-select or multi-select. + * A text field of an elicitation form, or a field where the user picks one value from a list. + * Build a text field with {@link #text(String)} and a choice with + * {@link #singleSelect(String, List)}, and put it in an {@link ElicitationSchema}; the + * canonical constructor sets the rest. The user's answer for it is a string. + * + *

    + * It is a choice when it lists the values: {@link #enumValues()} (the {@code enum} member) + * gives plain strings, which the client shows as they are, and {@link #oneOf()} gives + * {@link EnumOption}s, each a value with a label. The answer is one of the values. The schema + * expects one of the two lists; the SDK checks neither that nor that the default or the answer + * is one of the values. + * + *

    + * For a text field, {@link #minLength()}, {@link #maxLength()}, {@link #pattern()} and + * {@link #format()} constrain what the user may enter. The format is an open string; the + * protocol defines {@code "email"}, {@code "uri"}, {@code "date"} ({@code YYYY-MM-DD}) and + * {@code "date-time"} (ISO 8601). The SDK checks none of these: the client should check the + * answer, and the agent should check it again. A {@code default} that is not a string fails to + * read, and with it the request that carries the form. Members the schema does not define are + * dropped when it is read. + * + * @param type the discriminator, {@code "string"} + * @param title the label the client shows, or {@code null} + * @param description a longer explanation of the field, or {@code null} + * @param defaultValue the value to fill in before the user edits it + * (the {@code default} member), or {@code null} + * @param minLength the shortest answer allowed, or {@code null} for no minimum + * @param maxLength the longest answer allowed, or {@code null} for no maximum + * @param pattern a regular expression the answer must match, or {@code null} + * @param format the kind of text expected, such as {@code "email"}, or {@code null} + * @param enumValues the values to choose from, shown as they are (the {@code enum} member), or + * {@code null} + * @param oneOf the values to choose from, each with a label, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record EnumOption(@JsonProperty("const") String constValue, - @JsonProperty("title") String title) { - } + public record StringPropertySchema(@JsonProperty("type") String type, + @JsonProperty("title") @Nullable String title, @JsonProperty("description") @Nullable String description, + @JsonProperty("default") @Nullable String defaultValue, @JsonProperty("minLength") @Nullable Integer minLength, + @JsonProperty("maxLength") @Nullable Integer maxLength, @JsonProperty("pattern") @Nullable String pattern, + @JsonProperty("format") @Nullable String format, @JsonProperty("enum") @Nullable List enumValues, + @JsonProperty("oneOf") @Nullable List oneOf, + @JsonProperty("_meta") @Nullable Map meta) implements ElicitationPropertySchema { - /** - * Elicitation capabilities - advertised by client during initialize. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ElicitationCapabilities(@JsonProperty("form") Object form, - @JsonProperty("url") Object url, - @JsonProperty("_meta") Map meta) { /** - * Creates capabilities indicating form-mode support (the default). + * Creates a property with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use {@link #text(String)} or {@link #singleSelect(String, List)}, and it + * becomes {@code "string"}. + * @param type {@code null} or {@code "string"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param minLength the shortest answer, or {@code null} + * @param maxLength the longest answer, or {@code null} + * @param pattern the pattern, or {@code null} + * @param format the format, or {@code null} + * @param enumValues the plain values to choose from, or {@code null} + * @param oneOf the labelled values to choose from, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name */ - public ElicitationCapabilities() { - this(Map.of(), null, null); + public StringPropertySchema { + type = discriminator(type, "string"); } - } - - // --------------------------- - // Capabilities - // --------------------------- - /** - * Client capabilities - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ClientCapabilities(@JsonProperty("fs") FileSystemCapability fs, - @JsonProperty("terminal") Boolean terminal, - @UnstableAcpApi @JsonProperty("elicitation") ElicitationCapabilities elicitation, - @JsonProperty("_meta") Map meta) { - public ClientCapabilities() { - this(new FileSystemCapability(), false, null, null); + /** + * Returns a text field with this label and no default or constraints. + * @param title the label the client shows, or {@code null} + * @return the property + */ + public static StringPropertySchema text(String title) { + return new StringPropertySchema("string", title, null, null, null, null, null, null, null, null, null); } - public ClientCapabilities(FileSystemCapability fs, Boolean terminal) { - this(fs, terminal, null, null); + /** + * Returns a field where the user picks one of these options, each a value with a label + * ({@code oneOf}). The answer is the chosen option's {@link EnumOption#constValue()}. + * @param title the label the client shows, or {@code null} + * @param options the options to choose from + * @return the property + */ + public static StringPropertySchema singleSelect(String title, List options) { + return new StringPropertySchema("string", title, null, null, null, null, null, null, null, options, null); } } /** - * File system capabilities + * A number field of an elicitation form, for a value that may have a fraction. Construct it and + * put it in an {@link ElicitationSchema}. The user's answer for it is a number. Use + * {@link IntegerPropertySchema} for whole numbers. + * + *

    + * It differs from {@link StringPropertySchema} in what it constrains: {@link #minimum()} and + * {@link #maximum()} are inclusive bounds, which the SDK does not check. The values are + * {@code Double}s, so a whole number is written with a fraction ({@code 3.0}). A + * {@code default} that is not a number fails to read, and with it the request that carries the + * form. + * + * @param type the discriminator, {@code "number"} + * @param title the label the client shows, or {@code null} + * @param description a longer explanation of the field, or {@code null} + * @param defaultValue the value to fill in before the user edits it + * (the {@code default} member), or {@code null} + * @param minimum the smallest value allowed, or {@code null} for no lower bound + * @param maximum the largest value allowed, or {@code null} for no upper bound + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record FileSystemCapability(@JsonProperty("readTextFile") Boolean readTextFile, - @JsonProperty("writeTextFile") Boolean writeTextFile) { - public FileSystemCapability() { - this(false, false); + public record NumberPropertySchema(@JsonProperty("type") String type, + @JsonProperty("title") @Nullable String title, @JsonProperty("description") @Nullable String description, + @JsonProperty("default") @Nullable Double defaultValue, @JsonProperty("minimum") @Nullable Double minimum, + @JsonProperty("maximum") @Nullable Double maximum, + @JsonProperty("_meta") @Nullable Map meta) implements ElicitationPropertySchema { + + /** + * Creates a property with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use a shorter constructor, and it becomes {@code "number"}. + * @param type {@code null} or {@code "number"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param minimum the lower bound, or {@code null} + * @param maximum the upper bound, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public NumberPropertySchema { + type = discriminator(type, "number"); + } + + /** + * Creates a property without {@code _meta}. + * @param type {@code null} or {@code "number"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param minimum the lower bound, or {@code null} + * @param maximum the upper bound, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public NumberPropertySchema(String type, @Nullable String title, @Nullable String description, + @Nullable Double defaultValue, @Nullable Double minimum, @Nullable Double maximum) { + this(type, title, description, defaultValue, minimum, maximum, null); } } /** - * Agent capabilities + * A whole-number field of an elicitation form. Construct it and put it in an + * {@link ElicitationSchema}. The user's answer for it is a number. Use + * {@link NumberPropertySchema} for values that may have a fraction. + * + *

    + * It differs from {@link StringPropertySchema} in what it constrains: {@link #minimum()} and + * {@link #maximum()} are inclusive bounds, which the SDK does not check. The values are + * {@code Long}s; a number with a fraction in the JSON reads as its whole part. A + * {@code default} that is not a number fails to read, and with it the request that carries the + * form. + * + * @param type the discriminator, {@code "integer"} + * @param title the label the client shows, or {@code null} + * @param description a longer explanation of the field, or {@code null} + * @param defaultValue the value to fill in before the user edits it + * (the {@code default} member), or {@code null} + * @param minimum the smallest value allowed, or {@code null} for no lower bound + * @param maximum the largest value allowed, or {@code null} for no upper bound + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AgentCapabilities(@JsonProperty("loadSession") Boolean loadSession, - @JsonProperty("sessionCapabilities") SessionCapabilities sessionCapabilities, - @JsonProperty("mcpCapabilities") McpCapabilities mcpCapabilities, - @JsonProperty("promptCapabilities") PromptCapabilities promptCapabilities, - @UnstableAcpApi @JsonProperty("providers") ProvidersCapabilities providers, - @JsonProperty("_meta") Map meta) { - public AgentCapabilities() { - this(false, null, new McpCapabilities(), new PromptCapabilities(), null, null); - } + public record IntegerPropertySchema(@JsonProperty("type") String type, + @JsonProperty("title") @Nullable String title, @JsonProperty("description") @Nullable String description, + @JsonProperty("default") @Nullable Long defaultValue, @JsonProperty("minimum") @Nullable Long minimum, + @JsonProperty("maximum") @Nullable Long maximum, + @JsonProperty("_meta") @Nullable Map meta) implements ElicitationPropertySchema { - public AgentCapabilities(Boolean loadSession, McpCapabilities mcpCapabilities, - PromptCapabilities promptCapabilities) { - this(loadSession, null, mcpCapabilities, promptCapabilities, null, null); + /** + * Creates a property with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use a shorter constructor, and it becomes {@code "integer"}. + * @param type {@code null} or {@code "integer"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param minimum the lower bound, or {@code null} + * @param maximum the upper bound, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public IntegerPropertySchema { + type = discriminator(type, "integer"); } - public AgentCapabilities(Boolean loadSession, SessionCapabilities sessionCapabilities, - McpCapabilities mcpCapabilities, PromptCapabilities promptCapabilities, Map meta) { - this(loadSession, sessionCapabilities, mcpCapabilities, promptCapabilities, null, meta); + /** + * Creates a property without {@code _meta}. + * @param type {@code null} or {@code "integer"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param minimum the lower bound, or {@code null} + * @param maximum the upper bound, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public IntegerPropertySchema(String type, @Nullable String title, @Nullable String description, + @Nullable Long defaultValue, @Nullable Long minimum, @Nullable Long maximum) { + this(type, title, description, defaultValue, minimum, maximum, null); } } /** - * Session capabilities advertised by the agent. Presence of a non-null field - * signals support for that session method. + * A yes-or-no field of an elicitation form, which a client usually shows as a checkbox or a + * switch. Construct it and put it in an {@link ElicitationSchema}. The user's answer for it is + * a boolean. + * + *

    + * It differs from {@link StringPropertySchema} in having no constraints, only a label, a + * description and a default. A {@code default} that is not a boolean fails to read, and with it + * the request that carries the form. + * + * @param type the discriminator, {@code "boolean"} + * @param title the label the client shows, or {@code null} + * @param description a longer explanation of the field, or {@code null} + * @param defaultValue the value to fill in before the user edits it + * (the {@code default} member), or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionCapabilities(@JsonProperty("list") Object list, @JsonProperty("close") Object close, - @JsonProperty("resume") Object resume, @JsonProperty("delete") Object delete, - @JsonProperty("additionalDirectories") Object additionalDirectories, - @UnstableAcpApi @JsonProperty("fork") Object fork) { - public SessionCapabilities(Object list, Object close, Object resume) { - this(list, close, resume, null, null, null); + public record BooleanPropertySchema(@JsonProperty("type") String type, + @JsonProperty("title") @Nullable String title, @JsonProperty("description") @Nullable String description, + @JsonProperty("default") @Nullable Boolean defaultValue, + @JsonProperty("_meta") @Nullable Map meta) implements ElicitationPropertySchema { + + /** + * Creates a property with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use a shorter constructor, and it becomes {@code "boolean"}. + * @param type {@code null} or {@code "boolean"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public BooleanPropertySchema { + type = discriminator(type, "boolean"); } - public SessionCapabilities(Object list, Object close, Object resume, Object fork) { - this(list, close, resume, null, null, fork); + /** + * Creates a property without {@code _meta}. + * @param type {@code null} or {@code "boolean"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValue the default, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public BooleanPropertySchema(String type, @Nullable String title, @Nullable String description, + @Nullable Boolean defaultValue) { + this(type, title, description, defaultValue, null); } } /** - * MCP capabilities supported by agent + * A field of an elicitation form where the user picks any number of values from a list. + * {@link #items()} holds the values: an {@link UntitledMultiSelectItems} of plain strings, or a + * {@link TitledMultiSelectItems} of {@link EnumOption}s with labels. Construct it with the + * canonical constructor and put it in an {@link ElicitationSchema}. The user's answer for it is + * a list of strings, the chosen values. + * + *

    + * It differs from {@link StringPropertySchema} in what it constrains: {@link #minItems()} and + * {@link #maxItems()} bound how many values the user picks, and {@link #defaultValues()} lists + * the values picked before the user edits it. Its wire type is {@code "array"}. {@code items} + * is required; the SDK checks it on neither side, nor the bounds, nor that the defaults are + * among the values. A default list with an item that is not a string fails to read, and with it + * the request that carries the form. + * + * @param type the discriminator, {@code "array"} + * @param title the label the client shows, or {@code null} + * @param description a longer explanation of the field, or {@code null} + * @param defaultValues the values picked before the user edits it (the {@code default} member), + * or {@code null} + * @param items the values to choose from + * @param minItems the fewest values the user may pick, or {@code null} for no minimum + * @param maxItems the most values the user may pick, or {@code null} for no maximum + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record McpCapabilities(@JsonProperty("http") Boolean http, @JsonProperty("sse") Boolean sse) { - public McpCapabilities() { - this(false, false); + public record MultiSelectPropertySchema(@JsonProperty("type") String type, + @JsonProperty("title") @Nullable String title, @JsonProperty("description") @Nullable String description, + @JsonProperty("default") @Nullable List defaultValues, @JsonProperty("items") MultiSelectItems items, + @JsonProperty("minItems") @Nullable Long minItems, @JsonProperty("maxItems") @Nullable Long maxItems, + @JsonProperty("_meta") @Nullable Map meta) implements ElicitationPropertySchema { + + /** + * Creates a property with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, and it becomes {@code "array"}. + * @param type {@code null} or {@code "array"} + * @param title the label, or {@code null} + * @param description the explanation, or {@code null} + * @param defaultValues the values picked by default, or {@code null} + * @param items the values to choose from + * @param minItems the fewest values to pick, or {@code null} + * @param maxItems the most values to pick, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public MultiSelectPropertySchema { + type = discriminator(type, "array"); } } /** - * Prompt capabilities - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record PromptCapabilities(@JsonProperty("audio") Boolean audio, - @JsonProperty("embeddedContext") Boolean embeddedContext, @JsonProperty("image") Boolean image) { - public PromptCapabilities() { - this(false, false, false); - } - } + * The values a {@link MultiSelectPropertySchema} offers, its + * {@link MultiSelectPropertySchema#items()}: {@link UntitledMultiSelectItems} lists plain + * strings, which the client shows as they are; {@link TitledMultiSelectItems} lists + * {@link EnumOption}s, each a value with a label. A client handler checks the variant with + * {@code instanceof}. + * + *

    + * On the wire the shapes have no shared discriminator, so the SDK tells them apart by their + * members, not by the value of {@code type}. Items with a {@code type} or an {@code enum} + * member read as {@code UntitledMultiSelectItems}, whatever the type, and items with an + * {@code anyOf} member as {@code TitledMultiSelectItems}; members the chosen record does not + * have are dropped. Only items with none of these members read as an + * {@link UnknownMultiSelectItems}. The interface is not sealed: end an {@code instanceof} chain + * with a branch for anything else (see {@link AcpSchema} on forward compatibility). + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, defaultImpl = UnknownMultiSelectItems.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = UntitledMultiSelectItems.class), + @JsonSubTypes.Type(value = TitledMultiSelectItems.class) }) + public interface MultiSelectItems { - // --------------------------- - // Session Types - // --------------------------- + } /** - * Session information returned by session/list + * Multi-select items of a shape this SDK does not know, kept as received: the agent is on a + * newer protocol version or sent an extension. A proxy can forward them unchanged. + * + *

    + * It keeps every member in {@link #fields()}, an unmodifiable map in wire order, and writes + * them back unchanged. Only items without a {@code type}, {@code enum} or {@code anyOf} member + * read as this record (see {@link MultiSelectItems}): items of a newer type, which has a + * {@code type} member, read as an {@link UntitledMultiSelectItems} instead. + * + * @param fields every member, in wire order */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionInfo(@JsonProperty("sessionId") String sessionId, @JsonProperty("cwd") String cwd, - @JsonProperty("title") String title, @JsonProperty("updatedAt") String updatedAt, - @JsonProperty("additionalDirectories") List additionalDirectories, - @JsonProperty("_meta") Map meta) { - public SessionInfo(String sessionId, String cwd) { - this(sessionId, cwd, null, null, null, null); + public record UnknownMultiSelectItems(@JsonAnySetter @JsonAnyGetter Map fields) + implements MultiSelectItems { + /** + * Creates unknown items. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param fields every member + */ + public UnknownMultiSelectItems { + fields = unknownFields(fields); } } /** - * Session mode state + * The values of a {@link MultiSelectPropertySchema} as plain strings, which the client shows as + * they are: {@code {"type":"string","enum":["a","b"]}}. The user's answer lists the chosen + * strings. Use {@link TitledMultiSelectItems} to give each value a label. + * + *

    + * Pass {@code "string"} for {@link #type()}: unlike the property records, the constructor + * neither fills it in nor checks it, and a {@code null} type is left out of the JSON, which the + * schema requires. Both components are required; the SDK checks neither. + * + * @param type the item type, {@code "string"} + * @param enumValues the values to choose from (the {@code enum} member) + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionModeState(@JsonProperty("currentModeId") String currentModeId, - @JsonProperty("availableModes") List availableModes) { + public record UntitledMultiSelectItems(@JsonProperty("type") String type, + @JsonProperty("enum") List enumValues, + @JsonProperty("_meta") @Nullable Map meta) implements MultiSelectItems { + /** + * Creates items without {@code _meta}. + * @param type the item type, {@code "string"} + * @param enumValues the values to choose from + */ + public UntitledMultiSelectItems(String type, List enumValues) { + this(type, enumValues, null); + } } /** - * Session mode + * The values of a {@link MultiSelectPropertySchema} as {@link EnumOption}s, each a value with + * the label the client shows: {@code {"anyOf":[{"const":"a","title":"Option A"}]}}. The user's + * answer lists the chosen options' {@link EnumOption#constValue()}s. Use + * {@link UntitledMultiSelectItems} when the values can be shown as they are. + * + *

    + * {@link #anyOf()} is required; the SDK does not check it. + * + * @param anyOf the options to choose from + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionMode(@JsonProperty("id") String id, @JsonProperty("name") String name, - @JsonProperty("description") String description) { + public record TitledMultiSelectItems(@JsonProperty("anyOf") List anyOf, + @JsonProperty("_meta") @Nullable Map meta) implements MultiSelectItems { + /** + * Creates items without {@code _meta}. + * @param anyOf the options to choose from + */ + public TitledMultiSelectItems(List anyOf) { + this(anyOf, null); + } } /** - * Session model state. + * One choice of a form field, a value with a label: the client shows {@link #title()}, and the + * answer carries {@link #constValue()} (the {@code const} member). A + * {@link StringPropertySchema} offers them in {@link StringPropertySchema#oneOf()} for one + * choice ({@link StringPropertySchema#singleSelect(String, List)} builds one), and a + * {@link TitledMultiSelectItems} in {@link TitledMultiSelectItems#anyOf()} for several. + * + *

    + * The value and the label are required; the SDK checks neither. * - * @deprecated The session-model API (including the {@code models} field on session - * responses) was removed from the ACP spec (June 2026, v0.13.5). Model selection is now - * carried by {@code session/set_config_option} with a {@code "model"} category config - * option. Slated for removal in a future release. + * @param constValue the value the answer carries when the user picks this choice + * @param title the label the client shows + * @param description a longer explanation of the choice, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @Deprecated(forRemoval = true) - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionModelState(@JsonProperty("currentModelId") String currentModelId, - @JsonProperty("availableModels") List availableModels) { + public record EnumOption(@JsonProperty("const") String constValue, @JsonProperty("title") String title, + @JsonProperty("description") @Nullable String description, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a choice without a description or {@code _meta}. + * @param constValue the value the answer carries + * @param title the label the client shows + */ + public EnumOption(String constValue, String title) { + this(constValue, title, null, null); + } } /** - * Model info. + * The elicitation modes a client supports: the {@link ClientCapabilities#elicitation()} it + * sends in {@code initialize}. A mode is advertised only when its object is present and not + * {@code null}, so {@code {}}, or both modes {@code null}, advertises no mode. Build it with + * {@link #formOnly()}, {@link #urlOnly()} or {@link #formAndUrl()}. + * + *

    + * The agent reads it through + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} ({@code supportsElicitationForm()} and + * {@code supportsElicitationUrl()}), and refuses to send an elicitation of a mode the client + * did not advertise; the client answers such a request {@code -32602} itself + * (see {@link CreateElicitationRequest}). A client builder that advertises a mode without a + * {@code createElicitationHandler} fails at {@code build()}. Only the two modes of the protocol + * can be advertised here: other members are dropped when it is read. * - * @deprecated See {@link SessionModelState}. Slated for removal. + * @param form form mode, or {@code null} if the client does not support it + * @param url URL mode, or {@code null} if the client does not support it + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @Deprecated(forRemoval = true) - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ModelInfo(@JsonProperty("modelId") String modelId, @JsonProperty("name") String name, - @JsonProperty("description") String description) { - } - - // --------------------------- - // Session Config Types - // (session/set_config_option is stable; the "boolean" variant remains an unstable extension) - // --------------------------- - - /** - * Session config option - a configurable setting exposed by the agent. - * Discriminated by type: "select" (stable) or "boolean" (unstable extension). - */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true) - @JsonSubTypes({ @JsonSubTypes.Type(value = SessionConfigSelect.class, name = "select"), - @JsonSubTypes.Type(value = SessionConfigBoolean.class, name = "boolean") }) - public interface SessionConfigOption { + public record ElicitationCapabilities(@JsonProperty("form") @Nullable ElicitationFormCapabilities form, + @JsonProperty("url") @Nullable ElicitationUrlCapabilities url, + @JsonProperty("_meta") @Nullable Map meta) { - } + /** + * Returns capabilities that advertise form mode only. + * @return the capabilities + */ + public static ElicitationCapabilities formOnly() { + return new ElicitationCapabilities(new ElicitationFormCapabilities(), null, null); + } - /** - * Select-type config option - a dropdown with named values. - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionConfigSelect( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("id") String id, @JsonProperty("name") String name, - @JsonProperty("description") String description, @JsonProperty("category") String category, - @JsonProperty("currentValue") String currentValue, - @JsonProperty("options") List options, - @JsonProperty("_meta") Map meta) implements SessionConfigOption { - public SessionConfigSelect(String id, String name, String currentValue, - List options) { - this("select", id, name, null, null, currentValue, options, null); + /** + * Returns capabilities that advertise URL mode only. + * @return the capabilities + */ + public static ElicitationCapabilities urlOnly() { + return new ElicitationCapabilities(null, new ElicitationUrlCapabilities(), null); } - } - /** - * Boolean-type config option - a toggle. - * - *

    Unstable: the stable schema only defines the {@code select} config option variant; - * {@code boolean} is an SDK extension that may change. - */ - @UnstableAcpApi - @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionConfigBoolean( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("id") String id, @JsonProperty("name") String name, - @JsonProperty("description") String description, @JsonProperty("category") String category, - @JsonProperty("currentValue") Boolean currentValue, - @JsonProperty("_meta") Map meta) implements SessionConfigOption { - public SessionConfigBoolean(String id, String name, Boolean currentValue) { - this("boolean", id, name, null, null, currentValue, null); + /** + * Returns capabilities that advertise both form and URL mode. + * @return the capabilities + */ + public static ElicitationCapabilities formAndUrl() { + return new ElicitationCapabilities(new ElicitationFormCapabilities(), new ElicitationUrlCapabilities(), + null); } } /** - * A selectable option within a select-type config option. + * The marker that a client supports form-mode elicitation: present as + * {@link ElicitationCapabilities#form()}, it advertises the mode, whatever it holds. It has + * only {@code _meta}, and is {@code {}} on the wire. + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record SessionConfigSelectOption(@JsonProperty("value") String value, - @JsonProperty("name") String name, @JsonProperty("description") String description, - @JsonProperty("_meta") Map meta) { - public SessionConfigSelectOption(String value, String name) { - this(value, name, null, null); + public record ElicitationFormCapabilities(@JsonProperty("_meta") @Nullable Map meta) { + /** Creates the marker, without {@code _meta}. */ + public ElicitationFormCapabilities() { + this(null); } } /** - * Config option update - pushed by agent via session/update notification. + * The marker that a client supports URL-mode elicitation: present as + * {@link ElicitationCapabilities#url()}, it advertises the mode, whatever it holds. It has only + * {@code _meta}, and is {@code {}} on the wire. + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ConfigOptionUpdate( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("configOptions") List configOptions, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public ConfigOptionUpdate(String sessionUpdate, List configOptions) { - this(sessionUpdate, configOptions, null); + public record ElicitationUrlCapabilities(@JsonProperty("_meta") @Nullable Map meta) { + /** Creates the marker, without {@code _meta}. */ + public ElicitationUrlCapabilities() { + this(null); } } // --------------------------- - // Provider Types (UNSTABLE) + // Capabilities // --------------------------- /** - * Provider configuration capabilities advertised by the agent. Presence (a non-null - * value, including {@code {}}) signals that the agent supports the {@code providers/*} - * methods. + * What a client tells the agent it can do, sent in the {@code initialize} request: the file + * system and terminal methods it serves, the config option kinds and authentication method + * types it handles, and the elicitation modes it supports. Build it with {@link #builder()} and + * set it with {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec#clientCapabilities + * clientCapabilities(...)} on the client builder (or the sync builder's method of the same + * name); the client sends it in {@code initialize()}. The agent reads it as a + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}. + * + *

    + * Advertise exactly what the client serves. A client builder without + * {@code clientCapabilities(...)} derives them from its handlers (file reads and writes, + * terminals with all five handlers, form-mode elicitation); set them explicitly for anything + * else, and then the client builder's {@code build()} throws an + * {@link IllegalStateException} when {@code fs.readTextFile}, {@code fs.writeTextFile}, + * {@code terminal} or an elicitation mode is advertised without the handlers that serve it, and + * logs a warning for a handler whose capability is not advertised, since an agent will not call + * it. An agent built with this SDK fails its own file read and write, {@code terminal/create} + * and elicitation calls with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} when the client did not + * advertise them, without sending them. + * + *

    + * {@code new ClientCapabilities()} and an empty builder advertise no file system access and no + * terminal. A {@code null} component is left out of the JSON and counts as not advertised. + * Capabilities this SDK does not model are dropped when the record is read from JSON; put + * custom capabilities in {@code _meta}. + * + *

    {@code
    +	 * ClientCapabilities caps = ClientCapabilities.builder()
    +	 *     .readTextFile()
    +	 *     .terminal()
    +	 *     .elicitationForm()
    +	 *     .session(ClientSessionCapabilities.withBooleanConfigOptions())
    +	 *     .build();
    +	 * }
    + * + * @param fs the {@code fs/*} methods the client serves, or {@code null} for none + * @param terminal whether the client serves the {@code terminal/*} methods, or {@code null}, + * read as {@code false} + * @param session the session features the client supports, such as boolean config options, or + * {@code null} for none + * @param auth the authentication method types the client handles, or {@code null} for none + * @param elicitation the elicitation modes the client supports, or {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ProvidersCapabilities(@JsonProperty("_meta") Map meta) { - public ProvidersCapabilities() { - this((Map) null); + public record ClientCapabilities(@JsonProperty("fs") @Nullable FileSystemCapability fs, + @JsonProperty("terminal") @Nullable Boolean terminal, + @JsonProperty("session") @Nullable ClientSessionCapabilities session, + @JsonProperty("auth") @Nullable AuthCapabilities auth, + @JsonProperty("elicitation") @Nullable ElicitationCapabilities elicitation, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the capabilities of a client that serves no file system or terminal methods: + * {@code fs} with both flags {@code false}, {@code terminal} {@code false}, and nothing + * else. {@link #builder()} starts from the same values. + */ + public ClientCapabilities() { + this(new FileSystemCapability(), false, null, null, null, null); + } + + /** + * Creates capabilities with file system and terminal support only, without session, auth, + * elicitation or {@code _meta}. + * @param fs the {@code fs/*} methods the client serves, or {@code null} + * @param terminal whether the client serves the {@code terminal/*} methods, or {@code null} + */ + public ClientCapabilities(@Nullable FileSystemCapability fs, @Nullable Boolean terminal) { + this(fs, terminal, null, null, null, null); + } + + /** + * Returns a builder that starts from {@code new ClientCapabilities()}: no file system + * access, no terminal, and no session, auth, elicitation or {@code _meta}. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds {@link ClientCapabilities} one component at a time, without positional + * {@code null}s. Get one from {@link ClientCapabilities#builder()}. Each setter replaces + * one component, and {@link #build()} can be called more than once. A builder is not safe + * for use by several threads at once. + */ + public static final class Builder { + + private @Nullable FileSystemCapability fs = new FileSystemCapability(); + + private @Nullable Boolean terminal = false; + + private @Nullable ClientSessionCapabilities session; + + private @Nullable AuthCapabilities auth; + + private @Nullable ElicitationCapabilities elicitation; + + private @Nullable Map meta; + + private Builder() { + } + + /** + * Sets the {@code fs/*} methods the client serves. For each flag set to {@code true}, + * register the matching handler ({@code readTextFileHandler} or + * {@code writeTextFileHandler}) on the client builder as well, or its {@code build()} + * throws. + * @param fs the file system capability, or {@code null} to advertise none + * @return this builder + */ + public Builder fs(@Nullable FileSystemCapability fs) { + this.fs = fs; + return this; + } + + /** + * Sets whether the client serves the {@code terminal/*} methods. When {@code true}, + * register all five terminal handlers on the client builder as well, or its + * {@code build()} throws. + * @param terminal {@code true} if the client serves the terminal methods; {@code false} + * or {@code null} if not + * @return this builder + */ + public Builder terminal(@Nullable Boolean terminal) { + this.terminal = terminal; + return this; + } + + /** + * Advertises {@code fs.readTextFile}, keeping the {@code fs.writeTextFile} flag as it + * is. Register a {@code readTextFileHandler} on the client builder as well. + * @return this builder + */ + public Builder readTextFile() { + FileSystemCapability current = this.fs; + this.fs = new FileSystemCapability(true, (current != null) ? current.writeTextFile() : null, + (current != null) ? current.meta() : null); + return this; + } + + /** + * Advertises {@code fs.writeTextFile}, keeping the {@code fs.readTextFile} flag as it + * is. Register a {@code writeTextFileHandler} on the client builder as well. + * @return this builder + */ + public Builder writeTextFile() { + FileSystemCapability current = this.fs; + this.fs = new FileSystemCapability((current != null) ? current.readTextFile() : null, true, + (current != null) ? current.meta() : null); + return this; + } + + /** + * Advertises the {@code terminal/*} methods, as {@code terminal(true)} does. Register + * all five terminal handlers on the client builder as well. + * @return this builder + */ + public Builder terminal() { + this.terminal = true; + return this; + } + + /** + * Advertises form-mode elicitation, keeping URL mode as it is. Register a + * {@code createElicitationHandler} on the client builder as well. + * @return this builder + */ + public Builder elicitationForm() { + ElicitationCapabilities current = this.elicitation; + this.elicitation = new ElicitationCapabilities(new ElicitationFormCapabilities(), + (current != null) ? current.url() : null, (current != null) ? current.meta() : null); + return this; + } + + /** + * Advertises URL-mode elicitation, keeping form mode as it is. Register a + * {@code createElicitationHandler} on the client builder as well. + * @return this builder + */ + public Builder elicitationUrl() { + ElicitationCapabilities current = this.elicitation; + this.elicitation = new ElicitationCapabilities((current != null) ? current.form() : null, + new ElicitationUrlCapabilities(), (current != null) ? current.meta() : null); + return this; + } + + /** + * Sets the session features the client supports, for example + * {@link ClientSessionCapabilities#withBooleanConfigOptions()} for a client that + * handles boolean config options. + * @param session the session capabilities, or {@code null} for none + * @return this builder + */ + public Builder session(@Nullable ClientSessionCapabilities session) { + this.session = session; + return this; + } + + /** + * Sets the authentication method types the client handles, for example + * {@code new AuthCapabilities(true)} for a client that can run terminal auth methods. + * @param auth the auth capabilities, or {@code null} for none + * @return this builder + */ + public Builder auth(@Nullable AuthCapabilities auth) { + this.auth = auth; + return this; + } + + /** + * Sets the elicitation modes the client supports, for example + * {@link ElicitationCapabilities#formOnly()}. With a mode set, register an elicitation + * handler on the client builder as well, or its {@code build()} throws. + * @param elicitation the elicitation capabilities, or {@code null} for none + * @return this builder + */ + public Builder elicitation(@Nullable ElicitationCapabilities elicitation) { + this.elicitation = elicitation; + return this; + } + + /** + * Sets the {@code _meta} map. + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + * @return this builder + */ + public Builder meta(@Nullable Map meta) { + this.meta = meta; + return this; + } + + /** + * Returns capabilities with the components set so far. + * @return the capabilities + */ + public ClientCapabilities build() { + return new ClientCapabilities(this.fs, this.terminal, this.session, this.auth, this.elicitation, + this.meta); + } + + } + } + + /** + * Session features a client supports beyond the baseline, as the {@code session} component of + * {@link ClientCapabilities}. Today that is only the config option kinds it handles beyond + * {@code select}, and ACP defines one: {@code boolean}. Use {@link #withBooleanConfigOptions()} + * for a client that handles boolean config options. + * + *

    + * An agent checks it with {@code supportsBooleanConfigOptions()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}, and should offer {@link SessionConfigBoolean} options only to a + * client that advertises it. The SDK checks this on neither side. A {@code null} + * {@code configOptions} advertises no extra kinds. + * + * @param configOptions the config option kinds beyond {@code select} the client handles, or + * {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ClientSessionCapabilities( + @JsonProperty("configOptions") @Nullable SessionConfigOptionsCapabilities configOptions, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the session capabilities without {@code _meta}. + * @param configOptions the config option kinds beyond {@code select}, or {@code null} + */ + public ClientSessionCapabilities(@Nullable SessionConfigOptionsCapabilities configOptions) { + this(configOptions, null); + } + + /** + * Returns the session capabilities of a client that handles boolean config options, written + * {@code {"configOptions":{"boolean":{}}}}. Pass it to + * {@link ClientCapabilities.Builder#session}. + * @return the session capabilities + */ + public static ClientSessionCapabilities withBooleanConfigOptions() { + return new ClientSessionCapabilities(SessionConfigOptionsCapabilities.withBoolean()); + } + } + + /** + * The config option kinds a client handles beyond {@code select}, as + * {@link ClientSessionCapabilities#configOptions()}. ACP defines one, {@code boolean}: when + * present, an agent may offer {@link SessionConfigBoolean} options, and the client may send + * boolean values in {@code session/set_config_option}. Most code uses + * {@link ClientSessionCapabilities#withBooleanConfigOptions()} rather than this record. + * + *

    + * The component is named {@code booleanOptions} because {@code boolean} is a Java keyword; on + * the wire it is the member {@code "boolean"}. + * + * @param booleanOptions the marker for boolean config options, or {@code null} if the client + * does not handle them + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionConfigOptionsCapabilities( + @JsonProperty("boolean") @Nullable BooleanConfigOptionCapabilities booleanOptions, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the capabilities without {@code _meta}. + * @param booleanOptions the marker for boolean config options, or {@code null} + */ + public SessionConfigOptionsCapabilities(@Nullable BooleanConfigOptionCapabilities booleanOptions) { + this(booleanOptions, null); + } + + /** + * Returns the capabilities of a client that handles boolean config options, written + * {@code {"boolean":{}}}. + * @return the capabilities + */ + public static SessionConfigOptionsCapabilities withBoolean() { + return new SessionConfigOptionsCapabilities(new BooleanConfigOptionCapabilities()); + } + } + + /** + * The marker that a client handles boolean config options, as + * {@link SessionConfigOptionsCapabilities#booleanOptions()}. It carries only {@code _meta}: its + * presence, written {@code {}}, is the capability, and {@code null} in its place means the + * client does not handle them. + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record BooleanConfigOptionCapabilities(@JsonProperty("_meta") @Nullable Map meta) { + /** Creates the marker without {@code _meta}, written {@code {}}. */ + public BooleanConfigOptionCapabilities() { + this(null); + } + } + + /** + * Which {@code fs/*} methods a client serves, as the {@code fs} component of + * {@link ClientCapabilities}: {@code fs/read_text_file} ({@link ReadTextFileRequest}) and + * {@code fs/write_text_file} ({@link WriteTextFileRequest}). With them an agent reads and + * writes files through the client, which can include unsaved changes in the user's editor. An + * agent checks them with {@code supportsReadTextFile()} and {@code supportsWriteTextFile()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}. + * + *

    + * A flag set to {@code true} needs its handler on the client builder + * ({@code readTextFileHandler} or {@code writeTextFileHandler}), or the builder's + * {@code build()} throws. An agent built with this SDK fails a read or write the client did not + * advertise with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}, without + * sending it. {@code null} counts as {@code false}. + * + * @param readTextFile whether the client serves {@code fs/read_text_file}, or {@code null}, + * read as {@code false} + * @param writeTextFile whether the client serves {@code fs/write_text_file}, or {@code null}, + * read as {@code false} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FileSystemCapability(@JsonProperty("readTextFile") @Nullable Boolean readTextFile, + @JsonProperty("writeTextFile") @Nullable Boolean writeTextFile, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the capability without {@code _meta}. + * @param readTextFile whether the client serves {@code fs/read_text_file}, or {@code null} + * @param writeTextFile whether the client serves {@code fs/write_text_file}, or + * {@code null} + */ + public FileSystemCapability(@Nullable Boolean readTextFile, @Nullable Boolean writeTextFile) { + this(readTextFile, writeTextFile, null); + } + + /** + * Creates the capability of a client that serves neither method: both flags {@code false}. + */ + public FileSystemCapability() { + this(false, false); + } + } + + /** + * What an agent tells the client it can do, sent in its {@code initialize} answer as + * {@link InitializeResponse#agentCapabilities()}: whether it loads sessions, which other + * optional session methods it serves, which MCP server transports and prompt content it + * accepts, whether it supports {@code logout}, and its provider support. Build it with + * {@link #builder()}. The client reads it as a + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}, returned by {@code getAgentCapabilities()} on the client. + * + *

    + * Agents built with this SDK derive it from their handlers unless they answer + * {@code initialize} themselves. A builder agent without an initialize handler, and an + * annotated agent, advertise each optional method they have a handler for: a load-session + * handler advertises {@code loadSession}, a list-sessions handler + * {@code sessionCapabilities.list}, a logout handler {@code auth.logout}, and so on. An + * annotated agent also takes its MCP transports from + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent @AcpAgent} and its prompt content from + * {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt}, and lays the answer of an + * {@link com.agentclientprotocol.sdk.annotation.Initialize @Initialize} method over the derived + * one. A builder agent with + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.AsyncAgentBuilder#initializeHandler + * initializeHandler} sends what its handler returns. An annotated agent advertises + * {@code sessionCapabilities.additionalDirectories} when + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent#additionalDirectories() @AcpAgent} + * declares it; a builder agent advertises it from its initialize handler. + * + *

    + * The client fails {@code session/load}, {@code session/list}, {@code session/close}, + * {@code session/delete}, {@code session/resume}, {@code logout} and the fork and provider + * calls with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}, without + * sending them, when the agent did not advertise them, and so a session call that names + * additional directories when the agent did not advertise + * {@code sessionCapabilities.additionalDirectories}. It does not check prompt content or MCP + * server types against these capabilities; check those with {@code NegotiatedCapabilities} + * before sending. + * + *

    + * {@code new AgentCapabilities()} and an empty builder advertise no {@code session/load}, no + * MCP servers over HTTP or SSE, and only text and resource links in prompts, which every agent + * must accept. A {@code null} component is left out of the JSON and counts as not advertised. + * Capabilities this SDK does not model are dropped when the record is read from JSON. + * + *

    {@code
    +	 * AgentCapabilities caps = AgentCapabilities.builder()
    +	 *     .loadSession(true)
    +	 *     .auth(AgentAuthCapabilities.withLogout())
    +	 *     .build();
    +	 * }
    + * + * @param loadSession whether the agent serves {@code session/load}, or {@code null}, read as + * {@code false} + * @param sessionCapabilities the other optional session methods the agent serves, or + * {@code null} for none + * @param mcpCapabilities the MCP server transports beyond stdio the agent accepts, or + * {@code null} for none + * @param promptCapabilities the prompt content beyond text and resource links the agent + * accepts, or {@code null} for none + * @param auth the authentication features the agent supports, such as {@code logout}, or + * {@code null} for none + * @param providers the agent's provider support, or {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AgentCapabilities(@JsonProperty("loadSession") @Nullable Boolean loadSession, + @JsonProperty("sessionCapabilities") @Nullable SessionCapabilities sessionCapabilities, + @JsonProperty("mcpCapabilities") @Nullable McpCapabilities mcpCapabilities, + @JsonProperty("promptCapabilities") @Nullable PromptCapabilities promptCapabilities, + @JsonProperty("auth") @Nullable AgentAuthCapabilities auth, + @UnstableAcpApi @JsonProperty("providers") @Nullable ProvidersCapabilities providers, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the capabilities of a minimal agent: no {@code session/load}, no MCP servers over + * HTTP or SSE, and no image, audio or embedded context in prompts. {@link #builder()} + * starts from the same values. + */ + public AgentCapabilities() { + this(false, null, new McpCapabilities(), new PromptCapabilities(), null, null, null); + } + + /** + * Creates capabilities without session, auth or provider capabilities or {@code _meta}. + * @param loadSession whether the agent serves {@code session/load}, or {@code null} + * @param mcpCapabilities the MCP server transports beyond stdio, or {@code null} + * @param promptCapabilities the prompt content beyond text and resource links, or + * {@code null} + */ + public AgentCapabilities(@Nullable Boolean loadSession, @Nullable McpCapabilities mcpCapabilities, + @Nullable PromptCapabilities promptCapabilities) { + this(loadSession, null, mcpCapabilities, promptCapabilities, null, null, null); + } + + /** + * Creates capabilities without auth or provider capabilities. + * @param loadSession whether the agent serves {@code session/load}, or {@code null} + * @param sessionCapabilities the other optional session methods, or {@code null} + * @param mcpCapabilities the MCP server transports beyond stdio, or {@code null} + * @param promptCapabilities the prompt content beyond text and resource links, or + * {@code null} + * @param meta the {@code _meta} map, or {@code null} + */ + public AgentCapabilities(@Nullable Boolean loadSession, @Nullable SessionCapabilities sessionCapabilities, + @Nullable McpCapabilities mcpCapabilities, @Nullable PromptCapabilities promptCapabilities, @Nullable Map meta) { + this(loadSession, sessionCapabilities, mcpCapabilities, promptCapabilities, null, null, meta); + } + + /** + * Returns a builder that starts from {@code new AgentCapabilities()}: no + * {@code session/load}, no MCP servers over HTTP or SSE, only text and resource links in + * prompts, and no session, auth or provider capabilities or {@code _meta}. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds {@link AgentCapabilities} one component at a time, without positional + * {@code null}s. Get one from {@link AgentCapabilities#builder()}. Each setter replaces one + * component, and {@link #build()} can be called more than once. A builder is not safe for + * use by several threads at once. + */ + public static final class Builder { + + private @Nullable Boolean loadSession = false; + + private @Nullable SessionCapabilities sessionCapabilities; + + private @Nullable McpCapabilities mcpCapabilities = new McpCapabilities(); + + private @Nullable PromptCapabilities promptCapabilities = new PromptCapabilities(); + + private @Nullable AgentAuthCapabilities auth; + + private @Nullable ProvidersCapabilities providers; + + private @Nullable Map meta; + + private Builder() { + } + + /** + * Sets whether the agent serves {@code session/load}. + * @param loadSession {@code true} if the agent serves {@code session/load}; + * {@code false} or {@code null} if not + * @return this builder + */ + public Builder loadSession(@Nullable Boolean loadSession) { + this.loadSession = loadSession; + return this; + } + + /** + * Advertises {@code session/load}, as {@code loadSession(true)} does. + * @return this builder + */ + public Builder loadSession() { + this.loadSession = true; + return this; + } + + /** + * Sets the other optional session methods the agent serves, such as + * {@code session/list} and {@code session/close}. + * @param sessionCapabilities the session capabilities, or {@code null} for none + * @return this builder + */ + public Builder sessionCapabilities(@Nullable SessionCapabilities sessionCapabilities) { + this.sessionCapabilities = sessionCapabilities; + return this; + } + + /** + * Sets the MCP server transports beyond stdio the agent accepts. + * @param mcpCapabilities the MCP capabilities, or {@code null} for none + * @return this builder + */ + public Builder mcpCapabilities(@Nullable McpCapabilities mcpCapabilities) { + this.mcpCapabilities = mcpCapabilities; + return this; + } + + /** + * Sets the prompt content beyond text and resource links the agent accepts. + * @param promptCapabilities the prompt capabilities, or {@code null} for none + * @return this builder + */ + public Builder promptCapabilities(@Nullable PromptCapabilities promptCapabilities) { + this.promptCapabilities = promptCapabilities; + return this; + } + + /** + * Advertises that the agent accepts image content in prompts, keeping the other prompt + * flags as they are. + * @return this builder + */ + public Builder promptImage() { + PromptCapabilities p = this.promptCapabilities; + this.promptCapabilities = new PromptCapabilities((p != null) ? p.audio() : null, + (p != null) ? p.embeddedContext() : null, true, (p != null) ? p.meta() : null); + return this; + } + + /** + * Advertises that the agent accepts audio content in prompts, keeping the other prompt + * flags as they are. + * @return this builder + */ + public Builder promptAudio() { + PromptCapabilities p = this.promptCapabilities; + this.promptCapabilities = new PromptCapabilities(true, (p != null) ? p.embeddedContext() : null, + (p != null) ? p.image() : null, (p != null) ? p.meta() : null); + return this; + } + + /** + * Advertises that the agent accepts embedded resources in prompts, keeping the other + * prompt flags as they are. + * @return this builder + */ + public Builder promptEmbeddedContext() { + PromptCapabilities p = this.promptCapabilities; + this.promptCapabilities = new PromptCapabilities((p != null) ? p.audio() : null, true, + (p != null) ? p.image() : null, (p != null) ? p.meta() : null); + return this; + } + + /** + * Advertises that the agent accepts MCP servers over HTTP, keeping SSE as it is. + * @return this builder + */ + public Builder mcpHttp() { + McpCapabilities m = this.mcpCapabilities; + this.mcpCapabilities = new McpCapabilities(true, (m != null) ? m.sse() : null, + (m != null) ? m.meta() : null); + return this; + } + + /** + * Advertises that the agent accepts MCP servers over SSE, keeping HTTP as it is. + * @return this builder + */ + public Builder mcpSse() { + McpCapabilities m = this.mcpCapabilities; + this.mcpCapabilities = new McpCapabilities((m != null) ? m.http() : null, true, + (m != null) ? m.meta() : null); + return this; + } + + /** + * Sets the authentication features the agent supports, for example + * {@link AgentAuthCapabilities#withLogout()} for an agent that serves {@code logout}. + * @param auth the auth capabilities, or {@code null} for none + * @return this builder + */ + public Builder auth(@Nullable AgentAuthCapabilities auth) { + this.auth = auth; + return this; + } + + /** + * Sets the agent's provider support, which advertises the {@code providers/*} methods. + * @param providers the provider capabilities, or {@code null} for none + * @return this builder + */ + @UnstableAcpApi + public Builder providers(@Nullable ProvidersCapabilities providers) { + this.providers = providers; + return this; + } + + /** + * Sets the {@code _meta} map. + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + * @return this builder + */ + public Builder meta(@Nullable Map meta) { + this.meta = meta; + return this; + } + + /** + * Returns capabilities with the components set so far. + * @return the capabilities + */ + public AgentCapabilities build() { + return new AgentCapabilities(this.loadSession, this.sessionCapabilities, this.mcpCapabilities, + this.promptCapabilities, this.auth, this.providers, this.meta); + } + + } + } + + /** + * Authentication features an agent supports besides {@code authenticate}, as + * {@link AgentCapabilities#auth()}. Today that is only {@code logout}: use + * {@link #withLogout()} for an agent that serves it. The ways a client can log in are not here; + * they are {@link InitializeResponse#authMethods()}. + * + *

    + * A builder agent without an initialize handler advertises {@code logout} when it has a + * {@code logoutHandler}, and an annotated agent when it has a + * {@link com.agentclientprotocol.sdk.annotation.Logout @Logout} method. The client fails + * {@code logout} with an {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}, + * without sending it, when {@code logout} is {@code null}. + * + * @param logout {@code {}} if the agent serves {@code logout}, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AgentAuthCapabilities(@JsonProperty("logout") @Nullable LogoutCapabilities logout, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the auth capabilities without {@code _meta}. + * @param logout {@code {}} for {@code logout}, or {@code null} + */ + public AgentAuthCapabilities(@Nullable LogoutCapabilities logout) { + this(logout, null); + } + + /** + * Returns the auth capabilities of an agent that serves {@code logout}, written + * {@code {"logout":{}}}. Pass it to {@link AgentCapabilities.Builder#auth}. + * @return the auth capabilities + */ + public static AgentAuthCapabilities withLogout() { + return new AgentAuthCapabilities(new LogoutCapabilities()); + } + } + + /** + * The marker that an agent serves {@code logout}, as {@link AgentAuthCapabilities#logout()}. It + * carries only {@code _meta}: its presence, written {@code {}}, is the capability, and + * {@code null} in its place means the agent does not serve {@code logout}. + * + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record LogoutCapabilities(@JsonProperty("_meta") @Nullable Map meta) { + /** Creates the marker without {@code _meta}, written {@code {}}. */ + public LogoutCapabilities() { + this(null); + } + } + + /** + * Which optional session methods an agent serves besides {@code session/load}, as + * {@link AgentCapabilities#sessionCapabilities()}: {@code session/list}, {@code session/close}, + * {@code session/resume}, {@code session/delete} and {@code session/fork}, and whether it + * accepts {@code additionalDirectories} on session requests. Every agent serves + * {@code session/new}, {@code session/prompt}, {@code session/cancel} and + * {@code session/update}; {@code session/load} has its own flag, + * {@link AgentCapabilities#loadSession()}. + * + *

    + * Each component is an object on the wire: present, as {@code {}}, means supported, and + * {@code null} means not. Pass an empty map, {@code Map.of()}, or {@link Boolean#TRUE} for a + * supported method, and {@code null} or {@link Boolean#FALSE} for one that is not: the record + * holds {@code TRUE} as an empty map and {@code FALSE} as {@code null}. The components are + * typed {@link Object}, and other values are written as they are: a plain + * {@code new Object()} cannot be written at all. A record read from JSON holds a map, or + * {@code null}: a value other than an object, such as a peer's {@code false} or {@code true}, + * reads as not advertised. + * + *

    + * The client fails {@code session/list}, {@code session/close}, {@code session/resume}, + * {@code session/delete} and {@code session/fork} calls with an + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException}, without sending them, when + * the matching component is {@code null}, and a session call that names additional directories + * when {@code additionalDirectories} is {@code null}. A builder agent without an initialize + * handler and an annotated agent advertise list, close, resume, delete and fork from their + * handlers; an annotated agent advertises {@code additionalDirectories} when + * {@code @AcpAgent(additionalDirectories = true)} declares it. + * + * @param list {@code {}} if the agent serves {@code session/list}, or {@code null} + * @param close {@code {}} if the agent serves {@code session/close}, or {@code null} + * @param resume {@code {}} if the agent serves {@code session/resume}, or {@code null} + * @param delete {@code {}} if the agent serves {@code session/delete}, or {@code null} + * @param additionalDirectories {@code {}} if the agent accepts {@code additionalDirectories} on + * session requests and may report them in {@link SessionInfo}, or {@code null} + * @param fork {@code {}} if the agent serves {@code session/fork}, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionCapabilities(@JsonProperty("list") @Nullable Object list, @JsonProperty("close") @Nullable Object close, + @JsonProperty("resume") @Nullable Object resume, @JsonProperty("delete") @Nullable Object delete, + @JsonProperty("additionalDirectories") @Nullable Object additionalDirectories, + @UnstableAcpApi @JsonProperty("fork") @Nullable Object fork, + @JsonProperty("_meta") @Nullable Map meta) { + + /** + * Normalizes what a caller passes: {@link Boolean#TRUE} becomes {@code {}} (an empty map), + * and {@link Boolean#FALSE} becomes {@code null}, so neither writes a boolean the schema + * forbids. + */ + public SessionCapabilities { + list = supported(list); + close = supported(close); + resume = supported(resume); + delete = supported(delete); + additionalDirectories = supported(additionalDirectories); + fork = supported(fork); + } + + /** + * Reads the capabilities a peer sent: only a JSON object counts as advertised, so a + * {@code false}, a {@code true} or another value the schema forbids reads as {@code null}. + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + @SuppressWarnings("unchecked") + static SessionCapabilities read(Map json) { + Object meta = json.get("_meta"); + return new SessionCapabilities(objectOnly(json.get("list")), objectOnly(json.get("close")), + objectOnly(json.get("resume")), objectOnly(json.get("delete")), + objectOnly(json.get("additionalDirectories")), objectOnly(json.get("fork")), + (meta instanceof Map) ? (Map) meta : null); + } + + private static @Nullable Object supported(@Nullable Object value) { + if (Boolean.TRUE.equals(value)) { + return Map.of(); + } + return Boolean.FALSE.equals(value) ? null : value; + } + + private static @Nullable Object objectOnly(@Nullable Object value) { + return (value instanceof Map) ? value : null; + } + + /** + * Creates session capabilities without {@code _meta}. + * @param list {@code {}} for {@code session/list}, or {@code null} + * @param close {@code {}} for {@code session/close}, or {@code null} + * @param resume {@code {}} for {@code session/resume}, or {@code null} + * @param delete {@code {}} for {@code session/delete}, or {@code null} + * @param additionalDirectories {@code {}} for {@code additionalDirectories}, or + * {@code null} + * @param fork {@code {}} for {@code session/fork}, or {@code null} + */ + @UnstableAcpApi + public SessionCapabilities(@Nullable Object list, @Nullable Object close, @Nullable Object resume, + @Nullable Object delete, @Nullable Object additionalDirectories, @Nullable Object fork) { + this(list, close, resume, delete, additionalDirectories, fork, null); + } + + /** + * Creates session capabilities for list, close and resume only, without delete, additional + * directories, fork or {@code _meta}. + * @param list {@code {}} for {@code session/list}, or {@code null} + * @param close {@code {}} for {@code session/close}, or {@code null} + * @param resume {@code {}} for {@code session/resume}, or {@code null} + */ + public SessionCapabilities(@Nullable Object list, @Nullable Object close, @Nullable Object resume) { + this(list, close, resume, null, null, null); + } + + /** + * Creates session capabilities for list, close, resume and fork only, without delete, + * additional directories or {@code _meta}. + * @param list {@code {}} for {@code session/list}, or {@code null} + * @param close {@code {}} for {@code session/close}, or {@code null} + * @param resume {@code {}} for {@code session/resume}, or {@code null} + * @param fork {@code {}} for {@code session/fork}, or {@code null} + */ + @UnstableAcpApi + public SessionCapabilities(@Nullable Object list, @Nullable Object close, @Nullable Object resume, + @Nullable Object fork) { + this(list, close, resume, null, null, fork); + } + } + + /** + * Which MCP server transports beyond stdio an agent accepts, as + * {@link AgentCapabilities#mcpCapabilities()}: HTTP and SSE. Every agent accepts + * {@link McpServerStdio} servers; a client passes {@link McpServerHttp} or {@link McpServerSse} + * servers in {@code session/new}, {@code session/load} or {@code session/resume} only when the + * matching flag is {@code true}. + * + *

    + * An annotated agent takes the flags from the {@code mcpHttp} and {@code mcpSse} attributes of + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent @AcpAgent}; a builder agent without an + * initialize handler advertises both {@code false}. The SDK checks the MCP server types on + * neither side: a client checks {@code supportsMcpHttp()} and {@code supportsMcpSse()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} before it sends them. + * + * @param http whether the agent accepts MCP servers over HTTP, or {@code null}, read as + * {@code false} + * @param sse whether the agent accepts MCP servers over SSE, or {@code null}, read as + * {@code false} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record McpCapabilities(@JsonProperty("http") @Nullable Boolean http, @JsonProperty("sse") @Nullable Boolean sse, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the MCP capabilities without {@code _meta}. + * @param http whether the agent accepts MCP servers over HTTP, or {@code null} + * @param sse whether the agent accepts MCP servers over SSE, or {@code null} + */ + public McpCapabilities(@Nullable Boolean http, @Nullable Boolean sse) { + this(http, sse, null); + } + + /** + * Creates the MCP capabilities of an agent that accepts only stdio MCP servers: both flags + * {@code false}. + */ + public McpCapabilities() { + this(false, false); + } + } + + /** + * Which content beyond text and resource links an agent accepts in {@code session/prompt}, as + * {@link AgentCapabilities#promptCapabilities()}: images, audio and embedded resources. Every + * agent accepts {@link TextContent} and {@link ResourceLink} blocks; a client sends + * {@link ImageContent}, {@link AudioContent} or an embedded {@link Resource} only when the + * matching flag is {@code true}. + * + *

    + * An annotated agent takes the flags from the {@code image}, {@code audio} and + * {@code embeddedContext} attributes of its + * {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} method; a builder agent without + * an initialize handler advertises all three {@code false}. The SDK checks prompt content on + * neither side: a client checks {@code supportsImageContent()}, {@code supportsAudioContent()} + * and {@code supportsEmbeddedContext()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} before it sends such blocks. Note the component order: audio, + * embedded context, image. + * + * @param audio whether the agent accepts {@link AudioContent} blocks, or {@code null}, read as + * {@code false} + * @param embeddedContext whether the agent accepts {@link Resource} blocks, which embed a + * resource's contents in the prompt, or {@code null}, read as {@code false} + * @param image whether the agent accepts {@link ImageContent} blocks, or {@code null}, read as + * {@code false} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PromptCapabilities(@JsonProperty("audio") @Nullable Boolean audio, + @JsonProperty("embeddedContext") @Nullable Boolean embeddedContext, @JsonProperty("image") @Nullable Boolean image, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the prompt capabilities without {@code _meta}. The order is audio, embedded + * context, image. + * @param audio whether the agent accepts audio, or {@code null} + * @param embeddedContext whether the agent accepts embedded resources, or {@code null} + * @param image whether the agent accepts images, or {@code null} + */ + public PromptCapabilities(@Nullable Boolean audio, @Nullable Boolean embeddedContext, @Nullable Boolean image) { + this(audio, embeddedContext, image, null); + } + + /** + * Creates the prompt capabilities of an agent that accepts only text and resource links: + * all three flags {@code false}. + */ + public PromptCapabilities() { + this(false, false, false); + } + } + + // --------------------------- + // Session Types + // --------------------------- + + /** + * One ACP session as {@code session/list} reports it: its id and working directory and, when + * the agent knows them, a title and the time of its last activity. The agent builds one per + * session in a {@link ListSessionsResponse}. A client shows them, for example as a session + * history, and reopens one by passing its {@link #sessionId()} in a {@link LoadSessionRequest} + * or {@link ResumeSessionRequest}. + * + *

    + * The protocol makes {@code updatedAt} an ISO 8601 timestamp; the SDK keeps it as a string and + * does not check it. A present {@code additionalDirectories} is the session's complete ordered + * list of additional workspace roots; a missing and an empty list both mean none. While a + * session runs, the agent reports a new title or last activity with a {@link SessionInfoUpdate} + * session update. + * + * @param sessionId the session's id + * @param cwd the session's working directory, an absolute path + * @param title a human-readable title, or {@code null} + * @param updatedAt the time of the last activity as an ISO 8601 timestamp, or {@code null} + * @param additionalDirectories the session's additional workspace roots as absolute paths, or + * {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionInfo(@JsonProperty("sessionId") String sessionId, @JsonProperty("cwd") String cwd, + @JsonProperty("title") @Nullable String title, @JsonProperty("updatedAt") @Nullable String updatedAt, + @JsonProperty("additionalDirectories") @Nullable List additionalDirectories, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates an entry with only an id and a working directory: no title, last activity, + * additional directories or {@code _meta}. + * @param sessionId the session's id + * @param cwd the working directory, an absolute path + */ + public SessionInfo(String sessionId, String cwd) { + this(sessionId, cwd, null, null, null, null); + } + } + + /** + * The modes an agent offers for an ACP session, and the one the session is in. An agent that + * has modes returns it as {@code modes} in its answer to {@code session/new} + * ({@link NewSessionResponse}), {@code session/load} ({@link LoadSessionResponse}) and + * {@code session/resume} ({@link ResumeSessionResponse}). A client shows the available modes + * and switches with a {@link SetSessionModeRequest}. + * + *

    + * The state is a snapshot. When the agent changes the mode on its own, it sends a + * {@link CurrentModeUpdate} session update; the SDK does not keep the current mode for either + * side. + * + * @param currentModeId the {@link SessionMode#id()} of the mode the session is in + * @param availableModes the modes the agent offers for the session + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionModeState(@JsonProperty("currentModeId") String currentModeId, + @JsonProperty("availableModes") List availableModes, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a state without {@code _meta}. + * @param currentModeId the id of the current mode + * @param availableModes the modes offered + */ + public SessionModeState(String currentModeId, List availableModes) { + this(currentModeId, availableModes, null); + } + } + + /** + * One mode an agent offers for an ACP session, such as "ask" or "code": an id for the protocol, + * and a name and description for the user. A mode may change how the agent works on prompts and + * what it asks permission for. Modes appear in {@link SessionModeState#availableModes()}; a + * client switches to one by sending its {@link #id()} in a {@link SetSessionModeRequest}. + * + * @param id the mode's id, as {@link SetSessionModeRequest#modeId()} and + * {@link SessionModeState#currentModeId()} name it + * @param name the name to show the user + * @param description more detail to show with the name, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionMode(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a mode without {@code _meta}. + * @param id the mode's id + * @param name the name to show the user + * @param description more detail, or {@code null} + */ + public SessionMode(String id, String name, @Nullable String description) { + this(id, name, description, null); + } + } + + // --------------------------- + // Session Config Types + // --------------------------- + + /** + * One setting an agent offers for an ACP session, such as the model, the mode or the reasoning + * level, with its current value. The agent lists the session's options as {@code configOptions} + * in its answers to {@code session/new} ({@link NewSessionResponse}), {@code session/load} + * ({@link LoadSessionResponse}) and {@code session/resume} ({@link ResumeSessionResponse}); the + * client shows them and changes one with a {@link SetSessionConfigOptionRequest}. The answer to + * that request, and a {@link ConfigOptionUpdate} when the agent changes options on its own, + * carry the full list again. + * + *

    + * The variants: {@link SessionConfigSelect}, where the user picks one value from a list, and + * {@link SessionConfigBoolean}, which the user turns on or off. Each has an {@code id}, which + * {@link SetSessionConfigOptionRequest#configId()} names, a {@code name} to show, an optional + * {@code category} (see {@link SessionConfigOptionCategory}) and a current value. The protocol + * requires a current value for every option, so the agent works even if the client shows none + * of them, and asks the agent to put the most important options first. It forbids sending a + * boolean option to a client that did not advertise {@code session.configOptions.boolean}; the + * SDK does not check this, so check {@code supportsBooleanConfigOptions()} on the + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} first. + * + *

    + * Config options supersede session modes ({@code session/set_mode}, {@link SessionModeState}). + * During the transition, the protocol asks an agent with modes to send both: a select with the + * category {@link SessionConfigOptionCategory#MODE} and the modes, kept in step. A client that + * supports config options uses them and ignores the modes. + * + *

    + * On the wire the {@code type} member names the variant, and each variant record has it as its + * first component. An option of a type this SDK does not know, or one without {@code type}, + * reads as an {@link UnknownSessionConfigOption}. The interface is not sealed: end an + * {@code instanceof} chain with a branch for anything else + * (see {@link AcpSchema} on forward compatibility). + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownSessionConfigOption.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = SessionConfigSelect.class, name = "select"), + @JsonSubTypes.Type(value = SessionConfigBoolean.class, name = "boolean") }) + public interface SessionConfigOption { + + } + + /** + * A config option of a type this SDK does not know, kept as received: the agent is on a newer + * protocol version or sent an extension. The protocol asks a client to ignore an option of a + * type it does not know; the agent keeps using that option's current value. A proxy can forward + * it unchanged. + * + *

    + * It keeps the {@code type} discriminator ({@code null} when the option had none) and every + * other member, {@code id} and {@code name} included, in {@link #fields()}, an unmodifiable map + * in wire order, and writes them back unchanged. Names are case sensitive: {@code "SELECT"} is + * an unknown type, not a {@link SessionConfigSelect}. + * + * @param type the discriminator as received, or {@code null} + * @param fields every other member, in wire order + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UnknownSessionConfigOption(@JsonProperty("type") @Nullable String type, + @JsonAnySetter @JsonAnyGetter Map fields) implements SessionConfigOption { + /** + * Creates an unknown option. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param type the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownSessionConfigOption { + fields = unknownFields(fields); + } + } + + /** + * The category names ACP v1 reserves for a config option's {@code category}, as {@code String} + * constants. A category tells a client what an option is for, so it can place it, give it an + * icon or a keyboard shortcut, for example put the model picker next to the prompt. Set it with + * the builders' {@code category(...)}; + * {@link SessionConfigSelect#model(String, String, String, List)} sets {@link #MODEL}. + * + *

    + * A category is for the user interface only: the protocol forbids making it required for + * correctness, and requires a client to handle a missing or unknown category. It is an open + * string that the SDK does not check. Names starting with {@code _}, such as + * {@code "_my_category"}, are free for custom use; other names are reserved for the protocol. + * When several options share a category, the protocol asks a client to prefer the one earlier + * in the list. This is a holder of constants, not meant to be implemented. + */ + public interface SessionConfigOptionCategory { + + /** + * The session's mode, such as ask or code: the select an agent offers next to its + * {@link SessionModeState} (see {@link SessionConfigOption}). + */ + String MODE = "mode"; + + /** + * The model the agent uses. {@link SessionConfigSelect#model(String, String, String, List)} + * builds a select with this category. + */ + String MODEL = "model"; + + /** + * A model setting, such as the context size or a trade-off between speed and quality. The + * protocol asks a client to show it near the model picker. + */ + String MODEL_CONFIG = "model_config"; + + /** How much the model thinks or reasons before it answers. */ + String THOUGHT_LEVEL = "thought_level"; + + } + + /** + * A config option where the user picks one value from a list, such as the model or the + * reasoning level, which most clients show as a drop-down. {@link #currentValue()} is the + * {@link SessionConfigSelectOption#value()} of the chosen option, and {@link #options()} lists + * the options, flat or in groups. Build a model picker with + * {@link #model(String, String, String, List) model}, and any other select with + * {@link #builder()}; the agent returns it in the {@code configOptions} of its session answers, + * and the client changes it with + * {@link SetSessionConfigOptionRequest#select(String, String, String)}. + * + *

    + * {@link Builder#build()} checks that the select has at least one option and that + * {@code currentValue} is the value of one of them. The constructors and {@code model(...)} + * check neither, since the JSON mapper uses them to read whatever the agent sent: a select read + * from the wire may name a value it does not list, or list none. The SDK checks neither that + * ids are unique nor that values are. A {@code currentValue} that is not a string, or an option + * list the SDK cannot read (see {@link SessionConfigSelectOptions}), fails to read, and with it + * the message that carries it. + * + *

    {@code
    +	 * SessionConfigSelect model = SessionConfigSelect.model("model", "Model", "fast",
    +	 *         List.of(new SessionConfigSelectOption("fast", "Fast"),
    +	 *                 new SessionConfigSelectOption("smart", "Smart")));
    +	 *
    +	 * SessionConfigSelectOption low = new SessionConfigSelectOption("low", "Low");
    +	 * SessionConfigSelectOption high = new SessionConfigSelectOption("high", "High");
    +	 * SessionConfigSelect effort = SessionConfigSelect.builder()
    +	 *     .id("effort").name("Effort").category(SessionConfigOptionCategory.THOUGHT_LEVEL)
    +	 *     .currentValue("low").options(List.of(low, high))
    +	 *     .build();
    +	 * }
    + * + * @param type the discriminator, {@code "select"} + * @param id the option's id, unique within the session, which the client names to change it + * @param name the label the client shows + * @param description a longer explanation the client may show, or {@code null} + * @param category what the option is for, one of the {@link SessionConfigOptionCategory} + * constants or a custom name starting with {@code _}, or {@code null} + * @param currentValue the value of the chosen option + * @param options the options to choose from, flat or in groups + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionConfigSelect( + @JsonProperty("type") String type, + @JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, @JsonProperty("category") @Nullable String category, + @JsonProperty("currentValue") String currentValue, + @JsonProperty("options") SessionConfigSelectOptions options, + @JsonProperty("_meta") @Nullable Map meta) implements SessionConfigOption { + /** + * Creates a select with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use a shorter constructor, and it becomes {@code "select"}. It checks + * neither the options nor {@code currentValue}. + * @param type {@code null} or {@code "select"} + * @param id the option's id + * @param name the label + * @param description the explanation, or {@code null} + * @param category the category, or {@code null} + * @param currentValue the value of the chosen option + * @param options the options + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public SessionConfigSelect { + type = discriminator(type, "select"); + } + + /** + * Creates a select from a flat list of options, without a description, category or + * {@code _meta}. It does not check {@code currentValue} against the options. + * @param id the option's id + * @param name the label + * @param currentValue the value of the chosen option + * @param options the options, ungrouped + */ + public SessionConfigSelect(String id, String name, String currentValue, + List options) { + this("select", id, name, null, null, currentValue, SessionConfigSelectOptions.ungrouped(options), null); + } + + /** + * Creates a select from options that may be in groups, without a description, category or + * {@code _meta}. It does not check {@code currentValue} against the options. + * @param id the option's id + * @param name the label + * @param currentValue the value of the chosen option + * @param options the options, grouped or ungrouped + */ + public SessionConfigSelect(String id, String name, String currentValue, SessionConfigSelectOptions options) { + this("select", id, name, null, null, currentValue, options, null); + } + + /** + * Returns a model picker: a select with the category + * {@link SessionConfigOptionCategory#MODEL} and these options, without a description or + * {@code _meta}. It is how an agent lets the user choose its model. It does not check + * {@code currentValue} against the options. + * @param id the option's id, for example {@code "model"} + * @param name the label the client shows + * @param currentValue the value of the model in use + * @param options the models, ungrouped + * @return the model picker + */ + public static SessionConfigSelect model(String id, String name, String currentValue, + List options) { + return model(id, name, currentValue, SessionConfigSelectOptions.ungrouped(options)); + } + + /** + * Returns a model picker whose models may be in groups, for example by provider; otherwise + * the same as {@link #model(String, String, String, List)}. + * @param id the option's id, for example {@code "model"} + * @param name the label the client shows + * @param currentValue the value of the model in use + * @param options the models, grouped or ungrouped + * @return the model picker + */ + public static SessionConfigSelect model(String id, String name, String currentValue, + SessionConfigSelectOptions options) { + return new SessionConfigSelect("select", id, name, null, SessionConfigOptionCategory.MODEL, currentValue, + options, null); + } + + /** + * Returns an empty builder. {@code id}, {@code name}, {@code currentValue} and the options + * are required, and {@link Builder#build()} checks the select. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds a {@link SessionConfigSelect} one component at a time and checks it (see + * {@link #build()}). Get one from {@link SessionConfigSelect#builder()}. Each setter + * replaces one component; {@link #options(List)}, {@link #groups(List)} and + * {@link #options(SessionConfigSelectOptions)} set the same component, so the last call + * wins. {@link #build()} can be called more than once. A builder is not safe for use by + * several threads at once. + */ + public static final class Builder { + + private @Nullable String id; + + private @Nullable String name; + + private @Nullable String description; + + private @Nullable String category; + + private @Nullable String currentValue; + + private @Nullable SessionConfigSelectOptions options; + + private @Nullable Map meta; + + private Builder() { + } + + /** + * Sets the option's id, which the client names in + * {@link SetSessionConfigOptionRequest#configId()} to change it. Required. + * @param id the option's id, unique within the session + * @return this builder + */ + public Builder id(String id) { + this.id = id; + return this; + } + + /** + * Sets the label the client shows. Required. + * @param name the label + * @return this builder + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets a longer explanation the client may show with the label. + * @param description the explanation, or {@code null} for none + * @return this builder + */ + public Builder description(@Nullable String description) { + this.description = description; + return this; + } + + /** + * Sets what the option is for, which helps the client place it. + * @param category one of the {@link SessionConfigOptionCategory} constants, a custom + * name starting with {@code _}, or {@code null} for none + * @return this builder + */ + public Builder category(@Nullable String category) { + this.category = category; + return this; + } + + /** + * Sets the value of the chosen option. Required: {@link #build()} checks that it is the + * {@link SessionConfigSelectOption#value()} of one of the options. + * @param currentValue the value of the chosen option + * @return this builder + */ + public Builder currentValue(String currentValue) { + this.currentValue = currentValue; + return this; + } + + /** + * Sets the options as a flat list, replacing options set before. + * @param options the options, in the order the client shows them; not {@code null} + * @return this builder + */ + public Builder options(List options) { + return options(SessionConfigSelectOptions.ungrouped(options)); + } + + /** + * Sets the options in groups, each shown under its own heading, replacing options set + * before. + * @param groups the groups, in the order the client shows them; not {@code null}, and + * no group's options {@code null} + * @return this builder + */ + public Builder groups(List groups) { + return options(SessionConfigSelectOptions.grouped(groups)); + } + + /** + * Sets the options, flat or in groups, replacing options set before. + * @param options the options; not {@code null} + * @return this builder + */ + public Builder options(SessionConfigSelectOptions options) { + this.options = options; + return this; + } + + /** + * Sets the {@code _meta} map. + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + * @return this builder + */ + public Builder meta(@Nullable Map meta) { + this.meta = meta; + return this; + } + + /** + * Builds the select, after checking that it can be offered: it has at least one option, + * across all groups, and {@code currentValue} is the value of one of them. The record's + * constructors check neither, since they also read other agents' options. Neither + * checks that values are unique. + * @return the select + * @throws IllegalStateException if {@code id}, {@code name}, {@code currentValue} or + * the options were not set, if there are no options, or if {@code currentValue} is not + * the value of one of them + */ + public SessionConfigSelect build() { + String id = required(this.id, "id"); + String name = required(this.name, "name"); + String currentValue = required(this.currentValue, "currentValue"); + SessionConfigSelectOptions options = required(this.options, "options"); + List values = options.allOptions().stream().map(SessionConfigSelectOption::value).toList(); + if (values.isEmpty()) { + throw new IllegalStateException("Select config option '" + id + "' has no options to choose from"); + } + if (!values.contains(currentValue)) { + throw new IllegalStateException("Select config option '" + id + "' has currentValue '" + + currentValue + "', which is not one of its option values " + values); + } + return new SessionConfigSelect("select", id, name, this.description, + this.category, currentValue, options, this.meta); + } + + } + } + + /** + * A config option the user turns on or off, such as a switch that lets the agent act without + * asking for confirmation. {@link #currentValue()} is whether it is on. Build it with + * {@link #builder()} or the shorter constructor; the agent returns it in the + * {@code configOptions} of its session answers, and the client changes it with + * {@link SetSessionConfigOptionRequest#bool(String, String, boolean)}. + * + *

    + * It differs from {@link SessionConfigSelect} in having no options and a boolean value. The + * protocol forbids an agent to offer it to a client that did not advertise + * {@code session.configOptions.boolean} + * ({@link ClientSessionCapabilities#withBooleanConfigOptions()}). The SDK does not check this + * when sending, so check {@code supportsBooleanConfigOptions()} on the + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}, and offer a select in its place, or nothing, to other clients. + * {@code currentValue} is required; a value that is not a JSON boolean fails to read, and with + * it the message that carries it. + * + * @param type the discriminator, {@code "boolean"} + * @param id the option's id, unique within the session, which the client names to change it + * @param name the label the client shows + * @param description a longer explanation the client may show, or {@code null} + * @param category what the option is for, one of the {@link SessionConfigOptionCategory} + * constants or a custom name starting with {@code _}, or {@code null} + * @param currentValue whether the option is on + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionConfigBoolean( + @JsonProperty("type") String type, + @JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, @JsonProperty("category") @Nullable String category, + @JsonProperty("currentValue") Boolean currentValue, + @JsonProperty("_meta") @Nullable Map meta) implements SessionConfigOption { + /** + * Creates an option with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use a shorter constructor, and it becomes {@code "boolean"}. + * @param type {@code null} or {@code "boolean"} + * @param id the option's id + * @param name the label + * @param description the explanation, or {@code null} + * @param category the category, or {@code null} + * @param currentValue whether the option is on + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public SessionConfigBoolean { + type = discriminator(type, "boolean"); + } + + /** + * Creates an option without a description, category or {@code _meta}. + * @param id the option's id + * @param name the label + * @param currentValue whether the option is on + */ + public SessionConfigBoolean(String id, String name, Boolean currentValue) { + this("boolean", id, name, null, null, currentValue, null); + } + + /** + * Returns an empty builder. {@code id}, {@code name} and {@code currentValue} are required. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds a {@link SessionConfigBoolean} one component at a time. Get one from + * {@link SessionConfigBoolean#builder()}. Each setter replaces one component, and + * {@link #build()} can be called more than once. A builder is not safe for use by several + * threads at once. + */ + public static final class Builder { + + private @Nullable String id; + + private @Nullable String name; + + private @Nullable String description; + + private @Nullable String category; + + private @Nullable Boolean currentValue; + + private @Nullable Map meta; + + private Builder() { + } + + /** + * Sets the option's id, which the client names in + * {@link SetSessionConfigOptionRequest#configId()} to change it. Required. + * @param id the option's id, unique within the session + * @return this builder + */ + public Builder id(String id) { + this.id = id; + return this; + } + + /** + * Sets the label the client shows. Required. + * @param name the label + * @return this builder + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets a longer explanation the client may show with the label. + * @param description the explanation, or {@code null} for none + * @return this builder + */ + public Builder description(@Nullable String description) { + this.description = description; + return this; + } + + /** + * Sets what the option is for, which helps the client place it. + * @param category one of the {@link SessionConfigOptionCategory} constants, a custom + * name starting with {@code _}, or {@code null} for none + * @return this builder + */ + public Builder category(@Nullable String category) { + this.category = category; + return this; + } + + /** + * Sets whether the option is on. Required. + * @param currentValue {@code true} if the option is on + * @return this builder + */ + public Builder currentValue(boolean currentValue) { + this.currentValue = currentValue; + return this; + } + + /** + * Sets the {@code _meta} map. + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + * @return this builder + */ + public Builder meta(@Nullable Map meta) { + this.meta = meta; + return this; + } + + /** + * Builds the option. + * @return the option + * @throws IllegalStateException if {@code id}, {@code name} or {@code currentValue} was + * not set + */ + public SessionConfigBoolean build() { + return new SessionConfigBoolean("boolean", required(this.id, "id"), required(this.name, "name"), + this.description, this.category, required(this.currentValue, "currentValue"), this.meta); + } + + } + } + + /** + * The options of a {@link SessionConfigSelect}: a flat list ({@link UngroupedSelectOptions}) or + * a list of groups, each shown under its own heading ({@link GroupedSelectOptions}). Create + * them with {@link #ungrouped(List)} or {@link #grouped(List)}, or let the select's builder do + * it. {@link #allOptions()} returns every option either way, for example to check a value the + * client sent. + * + *

    + * Both are written as a JSON array, and the protocol does not let one list mix options and + * groups. When a list is read, it reads as grouped when all its items are groups, and as + * ungrouped otherwise, the empty list included (see {@link SessionConfigSelectItem}). A list + * that mixes options and groups, or has an item that is neither, fails to read, and with it the + * message that carries it. + */ + public interface SessionConfigSelectOptions { + + /** + * Returns the options for the items of a list read from JSON: grouped when every item is a + * group, ungrouped otherwise. The JSON mapper calls it. + * @param items the list's items, options or groups + * @return grouped or ungrouped options + * @throws IllegalArgumentException if the list mixes options and groups, which the protocol + * does not allow + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + static SessionConfigSelectOptions of(List items) { + long groups = items.stream().filter(SessionConfigSelectGroup.class::isInstance).count(); + if (groups != 0 && groups != items.size()) { + throw new IllegalArgumentException( + "A select option list mixes options and groups; it must be one or the other"); + } + return groups == 0 ? ungrouped(items.stream().map(SessionConfigSelectOption.class::cast).toList()) + : grouped(items.stream().map(SessionConfigSelectGroup.class::cast).toList()); + } + + /** + * Returns a flat list of options. + * @param options the options, in the order the client shows them; kept, not copied + * @return ungrouped options + */ + static SessionConfigSelectOptions ungrouped(List options) { + return new UngroupedSelectOptions(options); + } + + /** + * Returns options in groups. + * @param groups the groups, in the order the client shows them; kept, not copied + * @return grouped options + */ + static SessionConfigSelectOptions grouped(List groups) { + return new GroupedSelectOptions(groups); + } + + /** + * Returns every option, in order: for grouped options, the options of each group in turn. + * @return the options + */ + List allOptions(); + + } + + /** + * A flat list of select options: the {@link SessionConfigSelectOptions} of a select whose + * options need no headings. It is written as the JSON array of its options, and keeps the list + * it is given without copying it. + * + * @param options the options, in the order the client shows them + */ + public record UngroupedSelectOptions(@JsonValue List options) + implements SessionConfigSelectOptions { + /** + * {@inheritDoc} + *

    + * It is {@link #options()} itself. + */ + @Override + public List allOptions() { + return options; + } + } + + /** + * Select options in groups, each with its own heading: the {@link SessionConfigSelectOptions} + * for a long list, such as models by provider. It is written as the JSON array of its + * {@link SessionConfigSelectGroup}s, and keeps the list it is given without copying it. A + * select's {@code currentValue} and the client's choice name an option's value, never a group. + * + * @param groups the groups, in the order the client shows them + */ + public record GroupedSelectOptions(@JsonValue List groups) + implements SessionConfigSelectOptions { + /** + * {@inheritDoc} + *

    + * Each call returns a new, unmodifiable list. Every group's options must not be + * {@code null}. + */ + @Override + public List allOptions() { + return groups.stream().flatMap(group -> group.options().stream()).toList(); + } + } + + /** + * An item of a select's option list as it is read from JSON: a + * {@link SessionConfigSelectOption} or a {@link SessionConfigSelectGroup}. The JSON mapper + * reads the items as these and {@link SessionConfigSelectOptions#of(List)} sorts them; + * application code works with {@link SessionConfigSelectOptions} and {@code allOptions()} + * instead. + * + *

    + * The items have no discriminator, so the SDK tells them apart by their members: an item with + * {@code value} or {@code description} reads as an option, and one with {@code group} or + * {@code options} as a group. An item with neither, such as one with only a {@code name}, fails + * to read, and with it the message that carries it. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ @JsonSubTypes.Type(SessionConfigSelectOption.class), + @JsonSubTypes.Type(SessionConfigSelectGroup.class) }) + public interface SessionConfigSelectItem { + + } + + /** + * A group of a select's options, shown under its own heading: the group's id, the heading and + * the options. Groups go in a {@link GroupedSelectOptions}, which + * {@link SessionConfigSelectOptions#grouped(List)} or the select builder's + * {@link SessionConfigSelect.Builder#groups(List) groups} creates. + * + *

    + * All three components are required; the SDK does not check them when sending, and the options + * must not be {@code null} for {@link SessionConfigSelectOptions#allOptions()} and the + * builder's check. + * + * @param group the group's id, unique within the select + * @param name the heading the client shows + * @param options the options in the group + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionConfigSelectGroup(@JsonProperty("group") String group, @JsonProperty("name") String name, + @JsonProperty("options") List options, + @JsonProperty("_meta") @Nullable Map meta) implements SessionConfigSelectItem { + /** + * Creates a group without {@code _meta}. + * @param group the group's id + * @param name the heading + * @param options the options in the group + */ + public SessionConfigSelectGroup(String group, String name, List options) { + this(group, name, options, null); + } + } + + /** + * One value a {@link SessionConfigSelect} offers: its id, which the select's + * {@code currentValue} and a client's {@link SetSessionConfigOptionRequest} name, and the label + * the client shows, for example {@code new SessionConfigSelectOption("fast", "Fast")}. + * + *

    + * {@code value} and {@code name} are required. The SDK checks neither when sending, nor that + * values are unique within the select. + * + * @param value the value's id, unique within the select + * @param name the label the client shows + * @param description a longer explanation of what the value does, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SessionConfigSelectOption(@JsonProperty("value") String value, + @JsonProperty("name") String name, @JsonProperty("description") @Nullable String description, + @JsonProperty("_meta") @Nullable Map meta) implements SessionConfigSelectItem { + /** + * Creates an option without a description or {@code _meta}. + * @param value the value's id + * @param name the label + */ + public SessionConfigSelectOption(String value, String name) { + this(value, name, null, null); + } + } + + /** + * Tells the client that the agent changed config options of an ACP session on its own, streamed + * as a session update: for example it switched the mode after a planning phase, or fell back to + * another model after hitting rate limits. Like {@link SetSessionConfigOptionResponse}, it + * carries every config option of the session with its current value, not only the changed ones, + * and the list replaces the client's copy. + * + *

    + * A change the client asked for with {@link SetSessionConfigOptionRequest} is confirmed by that + * request's answer. The SDK never sends this update by itself; the agent sends it, with + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendSessionUpdate PromptContext.sendSessionUpdate} + * in a prompt turn or {@link com.agentclientprotocol.sdk.agent.AcpAsyncAgent#sendSessionUpdate + * AcpAsyncAgent.sendSessionUpdate} outside one. + * + * @param sessionUpdate the discriminator, {@code "config_option_update"} + * @param configOptions every config option of the session, with its current value + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ConfigOptionUpdate( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("configOptions") List configOptions, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use the shorter constructor, and it becomes + * {@code "config_option_update"}. + * @param sessionUpdate {@code null} or {@code "config_option_update"} + * @param configOptions every config option of the session + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public ConfigOptionUpdate { + sessionUpdate = discriminator(sessionUpdate, "config_option_update"); + } + + /** + * Creates an update without {@code _meta}. + * @param configOptions every config option of the session, not only the changed ones + */ + public ConfigOptionUpdate(List configOptions) { + this("config_option_update", configOptions, null); + } + } + + // --------------------------- + // Provider Types (UNSTABLE) + // --------------------------- + + /** + * Provider configuration capabilities advertised by the agent. Presence (a non-null + * value, including {@code {}}) signals that the agent supports the {@code providers/*} + * methods. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ProvidersCapabilities(@JsonProperty("_meta") @Nullable Map meta) { + public ProvidersCapabilities() { + this(null); + } + } + + /** + * The current effective (non-secret) routing config for a provider. + * + *

    {@code apiType} is a well-known {@code LlmProtocol} identifier (for example + * {@code "anthropic"}, {@code "openai"}, {@code "azure"}, {@code "vertex"}, + * {@code "bedrock"}) or a custom string. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ProviderCurrentConfig(@JsonProperty("apiType") String apiType, + @JsonProperty("baseUrl") String baseUrl) { + } + + /** + * Describes a configurable provider returned by {@code providers/list}. + * + * @param providerId provider identifier, for example {@code "main"} or {@code "openai"} + * @param supported supported {@code LlmProtocol} identifiers for this provider + * @param required whether this provider is mandatory and cannot be disabled + * @param current current effective non-secret routing config, or {@code null} + * @param meta reserved metadata + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ProviderInfo(@JsonProperty("providerId") String providerId, + @JsonProperty("supported") List supported, + @JsonProperty("required") Boolean required, @JsonProperty("current") @Nullable ProviderCurrentConfig current, + @JsonProperty("_meta") @Nullable Map meta) { + public ProviderInfo(String providerId, List supported, Boolean required, + @Nullable ProviderCurrentConfig current) { + this(providerId, supported, required, current, null); + } + } + + /** + * Request for {@code providers/list} - lists configurable providers. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ListProvidersRequest(@JsonProperty("_meta") @Nullable Map meta) { + public ListProvidersRequest() { + this(null); + } + } + + /** + * Response to {@code providers/list}. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ListProvidersResponse(@JsonProperty("providers") List providers, + @JsonProperty("_meta") @Nullable Map meta) { + public ListProvidersResponse(List providers) { + this(providers, null); + } + } + + /** + * Request for {@code providers/set} - configures a provider. + * + * @param providerId provider id to configure + * @param apiType protocol type for this provider (an {@code LlmProtocol} identifier) + * @param baseUrl base URL for requests sent through this provider + * @param headers full headers map for this provider (may include authorization), or {@code null} + * @param meta reserved metadata + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SetProviderRequest(@JsonProperty("providerId") String providerId, @JsonProperty("apiType") String apiType, + @JsonProperty("baseUrl") String baseUrl, @JsonProperty("headers") @Nullable Map headers, + @JsonProperty("_meta") @Nullable Map meta) { + public SetProviderRequest(String providerId, String apiType, String baseUrl) { + this(providerId, apiType, baseUrl, null, null); + } + + public SetProviderRequest(String providerId, String apiType, String baseUrl, + @Nullable Map headers) { + this(providerId, apiType, baseUrl, headers, null); } } /** - * The current effective (non-secret) routing config for a provider. + * Response to {@code providers/set}. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record SetProviderResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + public SetProviderResponse() { + this(null); + } + } + + /** + * Request for {@code providers/disable} - disables a provider by id. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record DisableProviderRequest(@JsonProperty("providerId") String providerId, + @JsonProperty("_meta") @Nullable Map meta) { + public DisableProviderRequest(String providerId) { + this(providerId, null); + } + } + + /** + * Response to {@code providers/disable}. + */ + @UnstableAcpApi + @JsonInclude(JsonInclude.Include.NON_NULL) + public record DisableProviderResponse(@JsonProperty("_meta") @Nullable Map meta) implements DefaultOnNull { + public DisableProviderResponse() { + this(null); + } + } + + // --------------------------- + // Content Types + // --------------------------- + + /** + * One piece of what a user or an agent says: text, an image, audio, or a resource, linked or + * embedded. A user's message is a list of them, the {@link PromptRequest#prompt()} of + * {@code session/prompt}; an agent streams messages one block per session update + * ({@link AgentMessageChunk}, {@link AgentThoughtChunk}, {@link UserMessageChunk}), and a tool + * call shows its output in a {@link ToolCallContentBlock}. Create one of the variant records; + * when reading, check the variant with {@code instanceof}. + * + *

    + * The variants: {@link TextContent}, plain text or Markdown, the usual block; + * {@link ImageContent} and {@link AudioContent}, base64-encoded media; {@link ResourceLink}, a + * reference to a resource the agent reads itself; {@link Resource}, a resource's contents + * embedded in the message. Each can carry {@link Annotations} for the client. The blocks have + * the same shape as the Model Context Protocol's, so an agent can pass on an MCP tool's content + * without converting it. + * + *

    + * In a prompt, every agent accepts text and resource links. A client may send images, audio or + * embedded resources only when the agent advertises them in its {@link PromptCapabilities}, as + * {@code image}, {@code audio} and {@code embeddedContext}; an annotated agent declares them on + * its {@link com.agentclientprotocol.sdk.annotation.Prompt @Prompt} method. The SDK checks + * prompt content on neither side: a client checks + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} before it sends such blocks. + * + *

    + * On the wire the {@code type} member names the variant, and each variant record has it as its + * first component. A block of a kind this SDK does not know, or without {@code type}, reads as + * an {@link UnknownContentBlock}, so the message that carries it is still read. The interface + * is not sealed: end an {@code instanceof} chain with a branch for anything else + * (see {@link AcpSchema} on forward compatibility). + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownContentBlock.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), + @JsonSubTypes.Type(value = ImageContent.class, name = "image"), + @JsonSubTypes.Type(value = AudioContent.class, name = "audio"), + @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link"), + @JsonSubTypes.Type(value = Resource.class, name = "resource") }) + public interface ContentBlock { + + } + + /** + * A content block of a kind this SDK does not know, kept as received: the peer is on a newer + * protocol version or sent an extension. A receiver that does not understand it should ignore + * it; a proxy can forward it unchanged. The other blocks of the same prompt are still read. * - *

    {@code apiType} is a well-known {@code LlmProtocol} identifier (for example - * {@code "anthropic"}, {@code "openai"}, {@code "azure"}, {@code "vertex"}, - * {@code "bedrock"}) or a custom string. + *

    + * It keeps the {@code type} discriminator ({@code null} when the block had none) and every + * other member in {@link #fields()}, an unmodifiable map in wire order, and writes them back + * unchanged. Names are case sensitive: {@code "TEXT"} is an unknown block, not a + * {@link TextContent}. + * + * @param type the discriminator as received, or {@code null} + * @param fields every other member, in wire order */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ProviderCurrentConfig(@JsonProperty("apiType") String apiType, - @JsonProperty("baseUrl") String baseUrl) { + public record UnknownContentBlock(@JsonProperty("type") @Nullable String type, + @JsonAnySetter @JsonAnyGetter Map fields) implements ContentBlock { + /** + * Creates an unknown block. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param type the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownContentBlock { + fields = unknownFields(fields); + } } /** - * Describes a configurable provider returned by {@code providers/list}. + * Text in a message, plain or Markdown: the most common content block. A user's prompt is + * usually one {@code TextContent}, and an agent streams its reply as text blocks, one per + * {@link AgentMessageChunk}. Create one with {@code new TextContent("...")}; + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendMessage(String) + * PromptContext.sendMessage} and {@code sendThought} wrap their text in one. * - * @param id provider identifier, for example {@code "main"} or {@code "openai"} - * @param supported supported {@code LlmProtocol} identifiers for this provider - * @param required whether this provider is mandatory and cannot be disabled - * @param current current effective non-secret routing config, or {@code null} - * @param meta reserved metadata + *

    + * The protocol requires every agent to accept text blocks in a prompt, and asks clients to + * render the text as Markdown; the SDK passes the text on as it is. + * + * @param type the discriminator, {@code "text"} + * @param text the text, plain or Markdown + * @param annotations hints for the client on how to use or show the block, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ProviderInfo(@JsonProperty("id") String id, @JsonProperty("supported") List supported, - @JsonProperty("required") Boolean required, @JsonProperty("current") ProviderCurrentConfig current, - @JsonProperty("_meta") Map meta) { - public ProviderInfo(String id, List supported, Boolean required, ProviderCurrentConfig current) { - this(id, supported, required, current, null); + public record TextContent(@JsonProperty("type") String type, + @JsonProperty("text") String text, @JsonProperty("annotations") @Nullable Annotations annotations, + @JsonProperty("_meta") @Nullable Map meta) implements ContentBlock { + /** + * Creates a text block with every component, as the JSON mapper does. Pass {@code null} for + * {@code type}, or use the shorter constructor, and it becomes {@code "text"}. + * @param type {@code null} or {@code "text"} + * @param text the text + * @param annotations the annotations, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public TextContent { + type = discriminator(type, "text"); + } + + /** + * Creates a text block without annotations or metadata. + * @param text the text, plain or Markdown + */ + public TextContent(String text) { + this("text", text, null, null); } } /** - * Request for {@code providers/list} - lists configurable providers. + * An image in a message, as base64-encoded data with its MIME type, such as a screenshot the + * user attaches to a prompt. A client sends one in a prompt only when the agent advertises + * {@code promptCapabilities.image} (see {@link ContentBlock}); an agent may also send images in + * its reply or in a tool call's content. + * + *

    + * It differs from {@link TextContent} in what it carries: {@link #data()} is the image's bytes + * in base64, which the SDK neither encodes nor checks, and {@link #uri()} may name where the + * image came from. It has no shorter constructor: pass {@code null} for the type and for the + * optional components. + * + * @param type the discriminator, {@code "image"} + * @param data the image's bytes, base64-encoded + * @param mimeType the image's MIME type, such as {@code "image/png"} + * @param uri where the image came from, or {@code null} + * @param annotations hints for the client on how to use or show the block, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ListProvidersRequest(@JsonProperty("_meta") Map meta) { - public ListProvidersRequest() { - this((Map) null); + public record ImageContent(@JsonProperty("type") String type, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("uri") @Nullable String uri, @JsonProperty("annotations") @Nullable Annotations annotations, + @JsonProperty("_meta") @Nullable Map meta) implements ContentBlock { + /** + * Creates an image block with every component, as the JSON mapper does. Pass {@code null} + * for {@code type} and it becomes {@code "image"}. + * @param type {@code null} or {@code "image"} + * @param data the image's bytes, base64-encoded + * @param mimeType the image's MIME type + * @param uri where the image came from, or {@code null} + * @param annotations the annotations, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ImageContent { + type = discriminator(type, "image"); } } /** - * Response to {@code providers/list}. + * Audio in a message, as base64-encoded data with its MIME type, such as a voice recording for + * the agent to transcribe. A client sends one in a prompt only when the agent advertises + * {@code promptCapabilities.audio} (see {@link ContentBlock}). + * + *

    + * Like {@link ImageContent}, it carries base64 data, which the SDK neither encodes nor checks, + * and a MIME type, but it has no {@code uri}. It has no shorter constructor: pass {@code null} + * for the type and for the optional components. + * + * @param type the discriminator, {@code "audio"} + * @param data the audio's bytes, base64-encoded + * @param mimeType the audio's MIME type, such as {@code "audio/wav"} + * @param annotations hints for the client on how to use or show the block, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record ListProvidersResponse(@JsonProperty("providers") List providers, - @JsonProperty("_meta") Map meta) { - public ListProvidersResponse(List providers) { - this(providers, null); + public record AudioContent(@JsonProperty("type") String type, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") @Nullable Annotations annotations, + @JsonProperty("_meta") @Nullable Map meta) implements ContentBlock { + /** + * Creates an audio block with every component, as the JSON mapper does. Pass {@code null} + * for {@code type} and it becomes {@code "audio"}. + * @param type {@code null} or {@code "audio"} + * @param data the audio's bytes, base64-encoded + * @param mimeType the audio's MIME type + * @param annotations the annotations, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public AudioContent { + type = discriminator(type, "audio"); } } /** - * Request for {@code providers/set} - configures a provider. + * A reference to a resource, such as a file, by its URI and a name, for the agent to read + * itself: the block carries no contents. A client puts one in a prompt to point the agent at a + * file the user mentioned; every agent must accept resource links in a prompt. A tool call's + * content can carry them too. * - * @param id provider id to configure - * @param apiType protocol type for this provider (an {@code LlmProtocol} identifier) - * @param baseUrl base URL for requests sent through this provider - * @param headers full headers map for this provider (may include authorization), or {@code null} - * @param meta reserved metadata + *

    + * Unlike an embedded {@link Resource}, it leaves reading the resource to the agent, which must + * be able to reach it. The optional components describe the resource for display: a title, a + * description, its MIME type and its size. The SDK checks none of them and reads nothing from + * the URI. It has no shorter constructor: pass {@code null} for the type and for the optional + * components. + * + * @param type the discriminator, {@code "resource_link"} + * @param name a human-readable name for the resource, such as its file name + * @param uri the resource's URI, such as {@code "file:///home/user/document.pdf"} + * @param title a title to show the user, or {@code null} + * @param description a human-readable description of the resource, or {@code null} + * @param mimeType the resource's MIME type, or {@code null} + * @param size the resource's size in bytes, or {@code null} if unknown + * @param annotations hints for the client on how to use or show the block, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record SetProviderRequest(@JsonProperty("id") String id, @JsonProperty("apiType") String apiType, - @JsonProperty("baseUrl") String baseUrl, @JsonProperty("headers") Map headers, - @JsonProperty("_meta") Map meta) { - public SetProviderRequest(String id, String apiType, String baseUrl) { - this(id, apiType, baseUrl, null, null); + public record ResourceLink(@JsonProperty("type") String type, + @JsonProperty("name") String name, @JsonProperty("uri") String uri, @JsonProperty("title") @Nullable String title, + @JsonProperty("description") @Nullable String description, @JsonProperty("mimeType") @Nullable String mimeType, + @JsonProperty("size") @Nullable Long size, @JsonProperty("annotations") @Nullable Annotations annotations, + @JsonProperty("_meta") @Nullable Map meta) implements ContentBlock { + /** + * Creates a resource link with every component, as the JSON mapper does. Pass {@code null} + * for {@code type} and it becomes {@code "resource_link"}. + * @param type {@code null} or {@code "resource_link"} + * @param name a human-readable name for the resource + * @param uri the resource's URI + * @param title a title to show the user, or {@code null} + * @param description a description of the resource, or {@code null} + * @param mimeType the resource's MIME type, or {@code null} + * @param size the resource's size in bytes, or {@code null} + * @param annotations the annotations, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ResourceLink { + type = discriminator(type, "resource_link"); } + } - public SetProviderRequest(String id, String apiType, String baseUrl, Map headers) { - this(id, apiType, baseUrl, headers, null); + /** + * A resource's contents embedded in the message, such as the text of a file the user mentioned. + * It is the preferred way to give an agent context in a prompt: the agent need not read + * anything, and the client can include context the agent cannot reach. The schema calls this + * block {@code EmbeddedResource}; its {@code type} is {@code "resource"}. + * + *

    + * A client sends one in a prompt only when the agent advertises + * {@code promptCapabilities.embeddedContext} (see {@link ContentBlock}). Unlike a + * {@link ResourceLink}, it carries the contents themselves: a {@link TextResourceContents} for + * text, or a {@link BlobResourceContents} for binary data. It has no shorter constructor: pass + * {@code null} for the type and for the optional components. + * + * @param type the discriminator, {@code "resource"} + * @param resource the resource's URI and contents + * @param annotations hints for the client on how to use or show the block, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Resource(@JsonProperty("type") String type, + @JsonProperty("resource") EmbeddedResourceResource resource, + @JsonProperty("annotations") @Nullable Annotations annotations, + @JsonProperty("_meta") @Nullable Map meta) implements ContentBlock { + /** + * Creates an embedded resource with every component, as the JSON mapper does. Pass + * {@code null} for {@code type} and it becomes {@code "resource"}. + * @param type {@code null} or {@code "resource"} + * @param resource the resource's URI and contents + * @param annotations the annotations, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public Resource { + type = discriminator(type, "resource"); } } /** - * Response to {@code providers/set}. + * The contents of an embedded {@link Resource}: its URI and either text or binary data. Create + * a {@link TextResourceContents} or a {@link BlobResourceContents}; when reading, check which + * one with {@code instanceof}. + * + *

    + * The wire form has no discriminator: contents with a {@code text} member read as + * {@code TextResourceContents}, and contents with a {@code blob} member as + * {@code BlobResourceContents}. There is no unknown variant. Contents with neither member + * cannot be read, so a prompt that carries them is answered with {@code -32602} + * (Invalid params) and a session update that carries them is skipped; contents with both read + * as text, and the blob is dropped. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) + @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class), + @JsonSubTypes.Type(value = BlobResourceContents.class) }) + public interface EmbeddedResourceResource { + + } + + /** + * The contents of a text resource, such as a source file, embedded in a {@link Resource}: its + * URI, its text and, optionally, its MIME type. + * + *

    + * It differs from {@link BlobResourceContents} only in carrying text instead of base64 data. + * + * @param text the resource's text + * @param uri the resource's URI, such as {@code "file:///home/user/script.py"} + * @param mimeType the text's MIME type, such as {@code "text/x-python"}, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record SetProviderResponse(@JsonProperty("_meta") Map meta) { - public SetProviderResponse() { - this((Map) null); + public record TextResourceContents(@JsonProperty("text") String text, @JsonProperty("uri") String uri, + @JsonProperty("mimeType") @Nullable String mimeType, + @JsonProperty("_meta") @Nullable Map meta) implements EmbeddedResourceResource { + /** + * Creates text contents without {@code _meta}. + * @param text the resource's text + * @param uri the resource's URI + * @param mimeType the text's MIME type, or {@code null} + */ + public TextResourceContents(String text, String uri, @Nullable String mimeType) { + this(text, uri, mimeType, null); } } /** - * Request for {@code providers/disable} - disables a provider by id. + * The contents of a binary resource, such as an image or a PDF file, embedded in a + * {@link Resource}: its URI, its bytes in base64 and, optionally, its MIME type. + * + *

    + * Like {@link TextResourceContents}, but {@link #blob()} holds the bytes, base64-encoded; the + * SDK neither encodes nor checks them. + * + * @param blob the resource's bytes, base64-encoded + * @param uri the resource's URI + * @param mimeType the resource's MIME type, such as {@code "application/pdf"}, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record DisableProviderRequest(@JsonProperty("id") String id, - @JsonProperty("_meta") Map meta) { - public DisableProviderRequest(String id) { - this(id, null); + public record BlobResourceContents(@JsonProperty("blob") String blob, @JsonProperty("uri") String uri, + @JsonProperty("mimeType") @Nullable String mimeType, + @JsonProperty("_meta") @Nullable Map meta) implements EmbeddedResourceResource { + /** + * Creates binary contents without {@code _meta}. + * @param blob the resource's bytes, base64-encoded + * @param uri the resource's URI + * @param mimeType the resource's MIME type, or {@code null} + */ + public BlobResourceContents(String blob, String uri, @Nullable String mimeType) { + this(blob, uri, mimeType, null); } } /** - * Response to {@code providers/disable}. + * Optional hints on a content block for the client: whom the content is for, how important it + * is, and when the resource behind it last changed. Text, image, audio and resource blocks + * carry them as their {@code annotations} component, usually {@code null}; a client may use + * them to decide how to show or route the content. + * + *

    + * {@link #audience()} lists the {@link Role}s the content is meant for, such as only the user; + * {@link #priority()} is its relative importance when the client chooses what to show; + * {@link #lastModified()} is a timestamp, which the SDK keeps as a string and does not parse. + * The SDK neither sets nor acts on any of them, and a role this SDK does not know is kept. A + * member of the wrong JSON type, such as a priority that is not a number, fails the message + * that carries it. + * + * @param audience the roles the content is meant for, or {@code null} + * @param priority the content's relative importance, or {@code null} + * @param lastModified when the resource behind the content last changed, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @UnstableAcpApi @JsonInclude(JsonInclude.Include.NON_NULL) - public record DisableProviderResponse(@JsonProperty("_meta") Map meta) { - public DisableProviderResponse() { - this((Map) null); + public record Annotations(@JsonProperty("audience") @Nullable List audience, @JsonProperty("priority") @Nullable Double priority, + @JsonProperty("lastModified") @Nullable String lastModified, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates annotations without {@code _meta}. + * @param audience the roles the content is meant for, or {@code null} + * @param priority the content's relative importance, or {@code null} + * @param lastModified when the resource last changed, or {@code null} + */ + public Annotations(@Nullable List audience, @Nullable Double priority, @Nullable String lastModified) { + this(audience, priority, lastModified, null); } } // --------------------------- - // Content Types + // Session Updates // --------------------------- /** - * Content block - base type for all content + * One update that an agent streams to the client in a {@link SessionNotification}: a piece of + * its reply or reasoning, a tool call or a change to one, its plan, or a change to the session. + * An agent creates one of the variant records and sends it with + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendSessionUpdate PromptContext.sendSessionUpdate}; + * a client checks the variant with {@code instanceof} in its session-update handler and shows + * what it understands. + * + *

    + * The variants: {@link AgentMessageChunk}, {@link AgentThoughtChunk} and + * {@link UserMessageChunk} carry a message in pieces; {@link ToolCall} announces a tool call + * and {@link ToolCallUpdateNotification} changes it; {@link Plan} reports the agent's plan; + * {@link AvailableCommandsUpdate}, {@link CurrentModeUpdate}, {@link ConfigOptionUpdate} and + * {@link SessionInfoUpdate} report the session's slash commands, mode, config options and + * title; {@link UsageUpdate} reports how much of the context window the session uses, and its + * cost. A plan, a command list and a config option list are always complete: each update + * replaces the previous one. + * + *

    + * On the wire the {@code sessionUpdate} member names the variant, and each variant record has + * it as its first component. An update of a kind this SDK does not know, or one without + * {@code sessionUpdate}, reads as an {@link UnknownSessionUpdate}, so the notification that + * carries it still reaches the update handlers. The interface is not sealed: end an + * {@code instanceof} chain with a branch for anything else (see {@link AcpSchema} on forward + * compatibility). + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "sessionUpdate", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownSessionUpdate.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = UserMessageChunk.class, name = "user_message_chunk"), + @JsonSubTypes.Type(value = AgentMessageChunk.class, name = "agent_message_chunk"), + @JsonSubTypes.Type(value = AgentThoughtChunk.class, name = "agent_thought_chunk"), + @JsonSubTypes.Type(value = ToolCall.class, name = "tool_call"), + @JsonSubTypes.Type(value = ToolCallUpdateNotification.class, name = "tool_call_update"), + @JsonSubTypes.Type(value = Plan.class, name = "plan"), + @JsonSubTypes.Type(value = AvailableCommandsUpdate.class, name = "available_commands_update"), + @JsonSubTypes.Type(value = CurrentModeUpdate.class, name = "current_mode_update"), + @JsonSubTypes.Type(value = UsageUpdate.class, name = "usage_update"), + @JsonSubTypes.Type(value = ConfigOptionUpdate.class, name = "config_option_update"), + @JsonSubTypes.Type(value = SessionInfoUpdate.class, name = "session_info_update") }) + public interface SessionUpdate { + + } + + /** + * A session update of a kind this SDK does not know, kept as received: the agent is on a newer + * protocol version or sent an extension. A client that does not understand it should + * ignore it; a proxy can forward it unchanged. + * + *

    + * It keeps the {@code sessionUpdate} discriminator ({@code null} when the update had none) and + * every other member in {@link #fields()}, an unmodifiable map in wire order, and writes them + * back unchanged. Names are case sensitive: {@code "PLAN"} is an unknown update, not a + * {@link Plan}. + * + * @param sessionUpdate the discriminator as received, or {@code null} + * @param fields every other member, in wire order */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true) - @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), - @JsonSubTypes.Type(value = ImageContent.class, name = "image"), - @JsonSubTypes.Type(value = AudioContent.class, name = "audio"), - @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link"), - @JsonSubTypes.Type(value = Resource.class, name = "resource") }) - public interface ContentBlock { + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UnknownSessionUpdate(@JsonProperty("sessionUpdate") @Nullable String sessionUpdate, + @JsonAnySetter @JsonAnyGetter Map fields) implements SessionUpdate { + /** + * Creates an unknown update. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param sessionUpdate the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownSessionUpdate { + fields = unknownFields(fields); + } + } + + /** + * A piece of a user's message, streamed as a session update. An agent sends these to replay the + * user's side of a conversation: when it answers {@code session/load}, it sends the whole + * conversation as user and agent message chunks before its answer. In a live prompt turn the + * client already has the user's message, the {@link PromptRequest}. + * + *

    + * It differs from {@link AgentMessageChunk} only in whose message it carries: the content and + * the message id work the same way. The prompt context has no shortcut for it; send it with + * {@code PromptContext.sendSessionUpdate} or {@code AcpSyncAgent.sendSessionUpdate}. + * + * @param sessionUpdate the discriminator, {@code "user_message_chunk"} + * @param content the piece of the message, one content block + * @param messageId the id of the message the piece belongs to, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UserMessageChunk( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") @Nullable String messageId, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates a chunk with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "user_message_chunk"}. + * @param sessionUpdate {@code null} or {@code "user_message_chunk"} + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public UserMessageChunk { + sessionUpdate = discriminator(sessionUpdate, "user_message_chunk"); + } + + /** + * Creates a chunk without a message id or {@code _meta}. + * @param content the piece of the message + */ + public UserMessageChunk(ContentBlock content) { + this("user_message_chunk", content, null, null); + } + /** + * Creates a chunk without {@code _meta}. + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + */ + public UserMessageChunk(ContentBlock content, @Nullable String messageId) { + this("user_message_chunk", content, messageId, null); + } } /** - * Text content + * A piece of the agent's reply to the user, streamed as a session update. An agent sends its + * reply as a run of these, each with one {@link ContentBlock}, usually a {@link TextContent}, + * and the client appends them to show the message as it grows. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendMessage(String) + * PromptContext.sendMessage} sends one with a text block. + * + *

    + * Chunks with the same {@link #messageId()} belong to one message, and a new id starts a new + * message. The id is optional and opaque; the SDK neither sets nor checks it, and + * {@code sendMessage(text, messageId)} sends one the agent chose. Together with + * {@link UserMessageChunk}s, an agent also sends these to replay a conversation before it + * answers {@code session/load}. + * + * @param sessionUpdate the discriminator, {@code "agent_message_chunk"} + * @param content the piece of the message, one content block + * @param messageId the id of the message the piece belongs to, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record TextContent(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("text") String text, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ContentBlock { - public TextContent(String text) { - this("text", text, null, null); + public record AgentMessageChunk( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") @Nullable String messageId, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates a chunk with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "agent_message_chunk"}. + * @param sessionUpdate {@code null} or {@code "agent_message_chunk"} + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public AgentMessageChunk { + sessionUpdate = discriminator(sessionUpdate, "agent_message_chunk"); + } + + /** + * Creates a chunk without a message id or {@code _meta}. + * @param content the piece of the message + */ + public AgentMessageChunk(ContentBlock content) { + this("agent_message_chunk", content, null, null); + } + + /** + * Creates a chunk without {@code _meta}. + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + */ + public AgentMessageChunk(ContentBlock content, @Nullable String messageId) { + this("agent_message_chunk", content, messageId, null); + } + } + + /** + * A piece of the agent's reasoning, streamed as a session update: like an + * {@link AgentMessageChunk}, but for what the agent thinks on the way to its reply, so a client + * can show it apart from the reply, for example folded away. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#sendThought(String) + * PromptContext.sendThought} sends one with a text block. + * + *

    + * It differs from {@link AgentMessageChunk} only in what it carries: the content and the + * message id work the same way, and {@code sendThought(text, messageId)} sends one with an id. + * + * @param sessionUpdate the discriminator, {@code "agent_thought_chunk"} + * @param content the piece of the message, one content block + * @param messageId the id of the message the piece belongs to, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record AgentThoughtChunk( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") @Nullable String messageId, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates a chunk with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "agent_thought_chunk"}. + * @param sessionUpdate {@code null} or {@code "agent_thought_chunk"} + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public AgentThoughtChunk { + sessionUpdate = discriminator(sessionUpdate, "agent_thought_chunk"); + } + + /** + * Creates a chunk without a message id or {@code _meta}. + * @param content the piece of the message + */ + public AgentThoughtChunk(ContentBlock content) { + this("agent_thought_chunk", content, null, null); + } + + /** + * Creates a chunk without {@code _meta}. + * @param content the piece of the message + * @param messageId the message's id, or {@code null} + */ + public AgentThoughtChunk(ContentBlock content, @Nullable String messageId) { + this("agent_thought_chunk", content, messageId, null); } } /** - * Image content + * Announces a tool call, streamed as a session update: an action the agent takes for the + * language model, such as reading a file, running a command or fetching a page, so the client + * can show it. Later {@link ToolCallUpdateNotification}s with the same {@link #toolCallId()} + * report its progress and results. + * + *

    + * Only {@code toolCallId}, unique within the session, and {@code title} are required. A tool + * call without a status is pending in the protocol; this record reads it as {@code null}. + * {@code name} is the tool's programmatic name, such as {@code read_file}: the protocol asks + * agents to send it in the first report when they have it and not to change it later, and it + * grants nothing. {@code kind} helps the client pick an icon, and a kind this SDK does not know + * reads as {@link ToolKind#OTHER}. {@code rawInput} and {@code rawOutput} are any JSON value, + * read as maps, lists, strings, numbers or booleans. There is no shorter constructor: pass + * {@code null} for what you leave out. + * + *

    + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askPermission(String) + * PromptContext.askPermission} and {@code askChoice} send one of these themselves, with a new + * random id and status pending, before they ask the user, and settle it with a + * {@link ToolCallUpdateNotification} when the request ends. + * + * @param sessionUpdate the discriminator, {@code "tool_call"} + * @param toolCallId the tool call's id, unique within the session + * @param title a human-readable description of what the tool is doing + * @param name the tool's programmatic name, or {@code null} + * @param kind the category of tool, or {@code null} + * @param status the execution status, or {@code null} (pending) + * @param content what the tool call produced, or {@code null} + * @param locations the files the tool call reads or changes, or {@code null} + * @param rawInput the raw input sent to the tool, or {@code null} + * @param rawOutput the raw output the tool returned, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ImageContent(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, - @JsonProperty("uri") String uri, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ContentBlock { + public record ToolCall( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") String title, + @JsonProperty("name") @Nullable String name, @JsonProperty("kind") @Nullable ToolKind kind, @JsonProperty("status") @Nullable ToolCallStatus status, + @JsonProperty("content") @Nullable List content, + @JsonProperty("locations") @Nullable List locations, @JsonProperty("rawInput") @Nullable Object rawInput, + @JsonProperty("rawOutput") @Nullable Object rawOutput, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates a tool call with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes {@code "tool_call"}. + * @param sessionUpdate {@code null} or {@code "tool_call"} + * @param toolCallId the tool call's id + * @param title what the tool is doing + * @param name the tool's programmatic name, or {@code null} + * @param kind the category of tool, or {@code null} + * @param status the execution status, or {@code null} + * @param content what the tool call produced, or {@code null} + * @param locations the files the tool call affects, or {@code null} + * @param rawInput the raw input sent to the tool, or {@code null} + * @param rawOutput the raw output the tool returned, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public ToolCall { + sessionUpdate = discriminator(sessionUpdate, "tool_call"); + } + } + + /** + * The tool call a permission request asks about: the + * {@link RequestPermissionRequest#toolCall()} an agent sends with + * {@code session/request_permission}. Only {@link #toolCallId()} is required; the other fields + * describe the action, so the client can show the user what it is asked to allow. The + * four-argument constructor covers the usual case: id, title, kind and status. + * + *

    + * The protocol defines it as an update to a tool call the agent announced with a + * {@link ToolCall} session update: the id names that tool call, a field left out, {@code null} + * here, is unchanged, and {@code content} and {@code locations}, when present, replace the + * whole list. It has the fields of a {@link ToolCallUpdateNotification} without the + * discriminator. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askPermission(String, ToolKind) + * PromptContext.askPermission} and {@code askChoice} announce a pending tool call first and + * send this with its id, title, kind and status. + * + * @param toolCallId the id of the tool call the request is about + * @param title the human-readable title, or {@code null} + * @param name the tool's programmatic name, or {@code null} + * @param kind the category of tool, or {@code null} + * @param status the execution status, or {@code null} + * @param content the content that replaces the tool call's content, or {@code null} + * @param locations the locations that replace the tool call's locations, or {@code null} + * @param rawInput the raw input sent to the tool, or {@code null} + * @param rawOutput the raw output the tool returned, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + // CPD-OFF: the same components as ToolCallUpdateNotification, by design. Both are the + // schema's ToolCallUpdate: bare in a permission request, and as a session update with its + // discriminator. + @JsonInclude(JsonInclude.Include.NON_NULL) + public record ToolCallUpdate(@JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") @Nullable String title, + @JsonProperty("name") @Nullable String name, @JsonProperty("kind") @Nullable ToolKind kind, @JsonProperty("status") @Nullable ToolCallStatus status, + @JsonProperty("content") @Nullable List content, + @JsonProperty("locations") @Nullable List locations, @JsonProperty("rawInput") @Nullable Object rawInput, + @JsonProperty("rawOutput") @Nullable Object rawOutput, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a tool call update with an id, a title, a kind and a status, the usual form for a + * permission request, without a name, content, locations, raw input or output, or + * {@code _meta}. + * @param toolCallId the id of the tool call + * @param title the human-readable title, or {@code null} + * @param kind the category of tool, or {@code null} + * @param status the execution status, or {@code null} + */ + public ToolCallUpdate(String toolCallId, @Nullable String title, @Nullable ToolKind kind, + @Nullable ToolCallStatus status) { + this(toolCallId, title, null, kind, status, null, null, null, null, null); + } } + // CPD-ON /** - * Audio content + * Changes a tool call the agent announced with a {@link ToolCall}, streamed as a session + * update: its status, its results or any other field. Only {@link #toolCallId()} is required. A + * field left out, {@code null} here, is unchanged; {@code content} and {@code locations}, when + * present, replace the whole list. + * + *

    + * A tool call typically goes from pending to in progress to completed or failed + * ({@link ToolCallStatus}), with an update at each step and the output in {@code content}. It + * has the same fields as {@link ToolCallUpdate}, the form a permission request carries, plus + * the discriminator. There is no shorter constructor: pass {@code null} for what does not + * change. + * + * @param sessionUpdate the discriminator, {@code "tool_call_update"} + * @param toolCallId the id of the tool call to change + * @param title a new human-readable title, or {@code null} + * @param name a new programmatic name, or {@code null} + * @param kind a new category, or {@code null} + * @param status a new execution status, or {@code null} + * @param content the content that replaces the tool call's content, or {@code null} + * @param locations the locations that replace the tool call's locations, or {@code null} + * @param rawInput a new raw input, or {@code null} + * @param rawOutput a new raw output, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AudioContent(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, - @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ContentBlock { + public record ToolCallUpdateNotification( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") @Nullable String title, + @JsonProperty("name") @Nullable String name, @JsonProperty("kind") @Nullable ToolKind kind, @JsonProperty("status") @Nullable ToolCallStatus status, + @JsonProperty("content") @Nullable List content, + @JsonProperty("locations") @Nullable List locations, @JsonProperty("rawInput") @Nullable Object rawInput, + @JsonProperty("rawOutput") @Nullable Object rawOutput, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "tool_call_update"}. + * @param sessionUpdate {@code null} or {@code "tool_call_update"} + * @param toolCallId the id of the tool call to change + * @param title a new title, or {@code null} + * @param name the tool's programmatic name, or {@code null} + * @param kind the category of tool, or {@code null} + * @param status the execution status, or {@code null} + * @param content what the tool call produced, or {@code null} + * @param locations the files the tool call affects, or {@code null} + * @param rawInput the raw input sent to the tool, or {@code null} + * @param rawOutput the raw output the tool returned, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public ToolCallUpdateNotification { + sessionUpdate = discriminator(sessionUpdate, "tool_call_update"); + } + } + + /** + * The agent's plan for the task at hand, streamed as a session update: a list of + * {@link PlanEntry} items, each with a priority and a status, so the client can show the steps + * and their progress. Every update is the whole plan: the agent sends all entries with their + * current status each time, and the client replaces the plan it shows. + * + *

    + * The protocol asks agents to report a plan when the model makes one, and to send another as + * the work goes on. The SDK does not check the entries. + * + * @param sessionUpdate the discriminator, {@code "plan"} + * @param entries every entry of the plan, with its current status + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Plan( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("entries") List entries, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates a plan with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes {@code "plan"}. + * @param sessionUpdate {@code null} or {@code "plan"} + * @param entries every entry of the plan + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public Plan { + sessionUpdate = discriminator(sessionUpdate, "plan"); + } + + /** + * Creates a plan without {@code _meta}. + * @param entries every entry of the plan + */ + public Plan(List entries) { + this("plan", entries, null); + } } /** - * Resource link + * The slash commands the agent offers in an ACP session, such as {@code /web} or {@code /test}, + * streamed as a session update so the client can offer them as the user types. The user runs + * one by sending it as text in a prompt. Each update is the full list and replaces the previous + * one: the agent can send it after creating the session and again whenever its commands change. + * + * @param sessionUpdate the discriminator, {@code "available_commands_update"} + * @param availableCommands every command the session offers now + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ResourceLink(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("name") String name, @JsonProperty("uri") String uri, @JsonProperty("title") String title, - @JsonProperty("description") String description, @JsonProperty("mimeType") String mimeType, - @JsonProperty("size") Long size, @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ContentBlock { - } + public record AvailableCommandsUpdate( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("availableCommands") List availableCommands, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "available_commands_update"}. + * @param sessionUpdate {@code null} or {@code "available_commands_update"} + * @param availableCommands every command the session offers + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public AvailableCommandsUpdate { + sessionUpdate = discriminator(sessionUpdate, "available_commands_update"); + } - /** - * Embedded resource - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Resource(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("resource") EmbeddedResourceResource resource, - @JsonProperty("annotations") Annotations annotations, - @JsonProperty("_meta") Map meta) implements ContentBlock { + /** + * Creates an update without {@code _meta}. + * @param availableCommands every command the session offers + */ + public AvailableCommandsUpdate(List availableCommands) { + this("available_commands_update", availableCommands, null); + } } /** - * Embedded resource content + * Tells the client that the agent switched an ACP session to another mode on its own, streamed + * as a session update: for example from a planning mode to a coding mode when the model is + * ready to change code. {@link #currentModeId()} is the {@link SessionMode#id()} of one of the + * modes the agent offered in the session's {@link SessionModeState}. + * + *

    + * A switch the client asked for with {@link SetSessionModeRequest} is confirmed by that + * request's answer. The SDK never sends this update by itself; the agent sends it. + * + * @param sessionUpdate the discriminator, {@code "current_mode_update"} + * @param currentModeId the id of the mode the session is in now + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) - @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class), - @JsonSubTypes.Type(value = BlobResourceContents.class) }) - public interface EmbeddedResourceResource { + @JsonInclude(JsonInclude.Include.NON_NULL) + public record CurrentModeUpdate( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("currentModeId") String currentModeId, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "current_mode_update"}. + * @param sessionUpdate {@code null} or {@code "current_mode_update"} + * @param currentModeId the id of the mode the session is in now + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public CurrentModeUpdate { + sessionUpdate = discriminator(sessionUpdate, "current_mode_update"); + } + /** + * Creates an update without {@code _meta}. + * @param currentModeId the id of the mode the session is in now + */ + public CurrentModeUpdate(String currentModeId) { + this("current_mode_update", currentModeId, null); + } } /** - * Text resource contents + * New metadata for an ACP session, its title or the time of its last activity, streamed as a + * session update so a client can show a current session name without asking + * {@code session/list} again. Every field is optional: the agent sends only what changed, and a + * field left out is unchanged. These are fields of the {@link SessionInfo} a + * {@code session/list} answer carries; the session id is in the {@link SessionNotification}. + * + *

    + * The protocol also lets a peer send {@code null} to clear a field. This record cannot tell an + * explicit {@code null} from a missing field (both read as {@code null}) and never writes + * {@code null}, so it can neither receive nor send a clear. + * + * @param sessionUpdate the discriminator, {@code "session_info_update"} + * @param title the session's new human-readable title, or {@code null} if unchanged + * @param updatedAt the time of the session's last activity, an ISO 8601 timestamp, or + * {@code null} if unchanged; the SDK does not check the format + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record TextResourceContents(@JsonProperty("text") String text, @JsonProperty("uri") String uri, - @JsonProperty("mimeType") String mimeType) implements EmbeddedResourceResource { + public record SessionInfoUpdate(@JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("title") @Nullable String title, @JsonProperty("updatedAt") @Nullable String updatedAt, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "session_info_update"}. + * @param sessionUpdate {@code null} or {@code "session_info_update"} + * @param title the new title, or {@code null} + * @param updatedAt the time of the last activity, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public SessionInfoUpdate { + sessionUpdate = discriminator(sessionUpdate, "session_info_update"); + } + + /** + * Creates an update without {@code _meta}. + * @param title the new title, or {@code null} + * @param updatedAt the time of the last activity, or {@code null} + */ + public SessionInfoUpdate(@Nullable String title, @Nullable String updatedAt) { + this("session_info_update", title, updatedAt, null); + } } /** - * Blob resource contents + * How much of the model's context window an ACP session uses, and what the session has cost so + * far, streamed as a session update so a client can show it. {@link #used()} is the number of + * tokens now in the context and {@link #size()} the window's total size; both are required, and + * {@link #cost()} is optional. The agent can send one whenever the numbers change. + * + *

    + * The protocol defines both counts as non-negative; the SDK does not check them. + * + * @param sessionUpdate the discriminator, {@code "usage_update"} + * @param used the number of tokens now in the context window + * @param size the context window's total size, in tokens + * @param cost the session's total cost so far, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record BlobResourceContents(@JsonProperty("blob") String blob, @JsonProperty("uri") String uri, - @JsonProperty("mimeType") String mimeType) implements EmbeddedResourceResource { + public record UsageUpdate( + @JsonProperty("sessionUpdate") String sessionUpdate, + @JsonProperty("used") Long used, @JsonProperty("size") Long size, @JsonProperty("cost") @Nullable Cost cost, + @JsonProperty("_meta") @Nullable Map meta) implements SessionUpdate { + /** + * Creates an update with every component, as the JSON mapper does. Pass {@code null} for + * {@code sessionUpdate}, or use a shorter constructor, and it becomes + * {@code "usage_update"}. + * @param sessionUpdate {@code null} or {@code "usage_update"} + * @param used the tokens now in the context window + * @param size the context window's size, in tokens + * @param cost the session's total cost, or {@code null} + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code sessionUpdate} is any other name + */ + public UsageUpdate { + sessionUpdate = discriminator(sessionUpdate, "usage_update"); + } + + /** + * Creates an update without a cost or {@code _meta}. + * @param used the tokens now in the context window + * @param size the context window's size, in tokens + */ + public UsageUpdate(Long used, Long size) { + this("usage_update", used, size, null, null); + } } /** - * Annotations for content + * The total cost of an ACP session so far: the {@link UsageUpdate#cost()} an agent reports with + * its context-window usage. It is an amount and the currency it is in, both required. + * + *

    + * The amount adds up over the whole session; it is not the cost of one prompt turn. The + * currency is an ISO 4217 code, such as {@code "USD"}; the SDK checks neither value. + * + * @param amount the session's total cost so far, in {@code currency} + * @param currency the ISO 4217 currency code, such as {@code "USD"} or {@code "EUR"} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record Annotations(@JsonProperty("audience") List audience, @JsonProperty("priority") Double priority, - @JsonProperty("lastModified") String lastModified) { + public record Cost(@JsonProperty("amount") Double amount, + @JsonProperty("currency") String currency, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a cost without {@code _meta}. + * @param amount the session's total cost so far + * @param currency the ISO 4217 currency code + */ + public Cost(Double amount, String currency) { + this(amount, currency, null); + } } // --------------------------- - // Session Updates + // Tool Call Types // --------------------------- /** - * Session update - different types of updates + * Something a tool call produced, for the client to show with it: one item of the + * {@code content} list of a {@link ToolCall}, a {@link ToolCallUpdateNotification} or a + * {@link ToolCallUpdate}. An agent creates one of the variant records; a client checks the + * variant with {@code instanceof} and shows what it understands. + * + *

    + * The variants: {@link ToolCallContentBlock} carries an ordinary {@link ContentBlock}, such as + * text or an image; {@link ToolCallDiff} shows a change to a file; {@link ToolCallTerminal} + * embeds the live output of a terminal the agent created. + * + *

    + * On the wire the {@code type} member names the variant, and each variant record has it as its + * first component. Content of a kind this SDK does not know, or without {@code type}, reads as + * an {@link UnknownToolCallContent}, so the message that carries it is still read. The + * interface is not sealed: end an {@code instanceof} chain with a branch for anything else + * (see {@link AcpSchema} on forward compatibility). */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "sessionUpdate", visible = true) - @JsonSubTypes({ @JsonSubTypes.Type(value = UserMessageChunk.class, name = "user_message_chunk"), - @JsonSubTypes.Type(value = AgentMessageChunk.class, name = "agent_message_chunk"), - @JsonSubTypes.Type(value = AgentThoughtChunk.class, name = "agent_thought_chunk"), - @JsonSubTypes.Type(value = ToolCall.class, name = "tool_call"), - @JsonSubTypes.Type(value = ToolCallUpdateNotification.class, name = "tool_call_update"), - @JsonSubTypes.Type(value = Plan.class, name = "plan"), - @JsonSubTypes.Type(value = AvailableCommandsUpdate.class, name = "available_commands_update"), - @JsonSubTypes.Type(value = CurrentModeUpdate.class, name = "current_mode_update"), - @JsonSubTypes.Type(value = UsageUpdate.class, name = "usage_update"), - @JsonSubTypes.Type(value = ConfigOptionUpdate.class, name = "config_option_update") }) - public interface SessionUpdate { + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownToolCallContent.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = ToolCallContentBlock.class, name = "content"), + @JsonSubTypes.Type(value = ToolCallDiff.class, name = "diff"), + @JsonSubTypes.Type(value = ToolCallTerminal.class, name = "terminal") }) + public interface ToolCallContent { } /** - * User message chunk + * Tool call content of a kind this SDK does not know, kept as received: the peer is on a newer + * protocol version or sent an extension. A client that does not understand it should ignore it; + * a proxy can forward it unchanged. + * + *

    + * It keeps the {@code type} discriminator ({@code null} when the content had none) and every + * other member in {@link #fields()}, an unmodifiable map in wire order, and writes them back + * unchanged. Names are case sensitive: {@code "DIFF"} is unknown content, not a + * {@link ToolCallDiff}. + * + * @param type the discriminator as received, or {@code null} + * @param fields every other member, in wire order */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record UserMessageChunk( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") String messageId, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public UserMessageChunk(String sessionUpdate, ContentBlock content) { - this(sessionUpdate, content, null, null); - } - - public UserMessageChunk(String sessionUpdate, ContentBlock content, String messageId) { - this(sessionUpdate, content, messageId, null); + public record UnknownToolCallContent(@JsonProperty("type") @Nullable String type, + @JsonAnySetter @JsonAnyGetter Map fields) implements ToolCallContent { + /** + * Creates unknown content. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param type the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownToolCallContent { + fields = unknownFields(fields); } } /** - * Agent message chunk + * Tool call content that is an ordinary {@link ContentBlock}, such as the text a command + * printed or an image it made, for the client to show with the tool call. It is the most common + * variant of {@link ToolCallContent}: an agent wraps the tool's output in it, usually a + * {@link TextContent}. + * + *

    + * A content block of a kind this SDK does not know reads as an {@link UnknownContentBlock} + * inside it, so the tool call content itself is still a {@code ToolCallContentBlock}. + * + * @param type the discriminator, {@code "content"} + * @param content the content block + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AgentMessageChunk( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") String messageId, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public AgentMessageChunk(String sessionUpdate, ContentBlock content) { - this(sessionUpdate, content, null, null); + public record ToolCallContentBlock( + @JsonProperty("type") String type, + @JsonProperty("content") ContentBlock content, + @JsonProperty("_meta") @Nullable Map meta) implements ToolCallContent { + /** + * Creates content without {@code _meta}. Unlike the shorter constructors of the session + * updates, it still takes the discriminator: pass {@code null} or {@code "content"}. + * @param type {@code null} or {@code "content"} + * @param content the content block + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallContentBlock(String type, ContentBlock content) { + this(type, content, null); } - public AgentMessageChunk(String sessionUpdate, ContentBlock content, String messageId) { - this(sessionUpdate, content, messageId, null); + /** + * Creates content with every component, as the JSON mapper does. Pass {@code null} for + * {@code type} and it becomes {@code "content"}. + * @param type {@code null} or {@code "content"} + * @param content the content block + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallContentBlock { + type = discriminator(type, "content"); } } /** - * Agent thought chunk + * Tool call content that shows a change to a file: its path and its text before and after, from + * which the client shows the user a diff. An agent adds one to the tool call that changes the + * file, typically of kind {@link ToolKind#EDIT}. + * + *

    + * Like {@link ToolCallContentBlock}, it is one item of a tool call's content, but it carries a + * file change instead of a content block. The protocol asks for an absolute path, and + * {@code oldText} is {@code null} for a new file. The SDK checks neither and does not touch the + * file: the record only describes the change. + * + * @param type the discriminator, {@code "diff"} + * @param path the absolute path of the file + * @param oldText the file's text before the change, or {@code null} for a new file + * @param newText the file's text after the change + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AgentThoughtChunk( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("content") ContentBlock content, @JsonProperty("messageId") String messageId, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public AgentThoughtChunk(String sessionUpdate, ContentBlock content) { - this(sessionUpdate, content, null, null); + public record ToolCallDiff(@JsonProperty("type") String type, + @JsonProperty("path") String path, @JsonProperty("oldText") @Nullable String oldText, + @JsonProperty("newText") String newText, + @JsonProperty("_meta") @Nullable Map meta) implements ToolCallContent { + /** + * Creates a diff without {@code _meta}. Unlike the shorter constructors of the session + * updates, it still takes the discriminator: pass {@code null} or {@code "diff"}. + * @param type {@code null} or {@code "diff"} + * @param path the absolute path of the file + * @param oldText the text before the change, or {@code null} for a new file + * @param newText the text after the change + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallDiff(String type, String path, @Nullable String oldText, String newText) { + this(type, path, oldText, newText, null); } - public AgentThoughtChunk(String sessionUpdate, ContentBlock content, String messageId) { - this(sessionUpdate, content, messageId, null); + /** + * Creates a diff with every component, as the JSON mapper does. Pass {@code null} for + * {@code type} and it becomes {@code "diff"}. + * @param type {@code null} or {@code "diff"} + * @param path the absolute path of the file + * @param oldText the text before the change, or {@code null} for a new file + * @param newText the text after the change + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallDiff { + type = discriminator(type, "diff"); } } /** - * Tool call + * Tool call content that embeds a terminal, so the client shows a command's output live as part + * of the tool call. The terminal is one the agent created with {@code terminal/create} + * ({@link CreateTerminalRequest}), named by the {@link CreateTerminalResponse#terminalId()} the + * client answered with. + * + *

    + * Like {@link ToolCallContentBlock}, it is one item of a tool call's content, but it carries a + * terminal id instead of a content block. The protocol requires the agent to add the terminal + * to a tool call before it releases the terminal with {@code terminal/release}; the client then + * keeps showing the output after the release. The SDK does not check the order. + * + * @param type the discriminator, {@code "terminal"} + * @param terminalId the id of the terminal, as the client returned it + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCall( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") String title, - @JsonProperty("kind") ToolKind kind, @JsonProperty("status") ToolCallStatus status, - @JsonProperty("content") List content, - @JsonProperty("locations") List locations, @JsonProperty("rawInput") Object rawInput, - @JsonProperty("rawOutput") Object rawOutput, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - } + public record ToolCallTerminal(@JsonProperty("type") String type, + @JsonProperty("terminalId") String terminalId, + @JsonProperty("_meta") @Nullable Map meta) implements ToolCallContent { + /** + * Creates terminal content without {@code _meta}. Unlike the shorter constructors of the + * session updates, it still takes the discriminator: pass {@code null} or + * {@code "terminal"}. + * @param type {@code null} or {@code "terminal"} + * @param terminalId the id of the terminal + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallTerminal(String type, String terminalId) { + this(type, terminalId, null); + } - /** - * Tool call update - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallUpdate(@JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") String title, - @JsonProperty("kind") ToolKind kind, @JsonProperty("status") ToolCallStatus status, - @JsonProperty("content") List content, - @JsonProperty("locations") List locations, @JsonProperty("rawInput") Object rawInput, - @JsonProperty("rawOutput") Object rawOutput) { + /** + * Creates terminal content with every component, as the JSON mapper does. Pass {@code null} + * for {@code type} and it becomes {@code "terminal"}. + * @param type {@code null} or {@code "terminal"} + * @param terminalId the id of the terminal + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code type} is any other name + */ + public ToolCallTerminal { + type = discriminator(type, "terminal"); + } } /** - * Tool call update notification + * A file a tool call reads or changes, with an optional line, so a client can follow the agent: + * for example, open the file the agent works on as it works. Agents list these in the + * {@code locations} of a {@link ToolCall} or a {@link ToolCallUpdateNotification}. + * + *

    + * The protocol asks for an absolute path and a line number from 0 up to an unsigned 32-bit + * maximum; the SDK checks neither. This record holds the line as an {@code Integer}, so a + * received line above {@link Integer#MAX_VALUE} cannot be read, and the message that carries it + * fails to read as a whole. + * + * @param path the absolute path of the file + * @param line the line in the file, or {@code null} for none in particular + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallUpdateNotification( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("toolCallId") String toolCallId, @JsonProperty("title") String title, - @JsonProperty("kind") ToolKind kind, @JsonProperty("status") ToolCallStatus status, - @JsonProperty("content") List content, - @JsonProperty("locations") List locations, @JsonProperty("rawInput") Object rawInput, - @JsonProperty("rawOutput") Object rawOutput, - @JsonProperty("_meta") Map meta) implements SessionUpdate { + public record ToolCallLocation(@JsonProperty("path") String path, @JsonProperty("line") @Nullable Integer line, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a location without {@code _meta}. + * @param path the absolute path of the file + * @param line the line in the file, or {@code null} + */ + public ToolCallLocation(String path, @Nullable Integer line) { + this(path, line, null); + } } + // --------------------------- + // Enums + // --------------------------- + /** - * Plan update + * Why the agent ended a prompt turn: the {@link PromptResponse#stopReason()} of every + * answered {@code session/prompt}. Agents answer with one of the constants; clients compare + * the value they receive with {@code equals}, or switch on {@link #value()}. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message (see + * {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a known + * wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "end_turn"} */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Plan( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("entries") List entries, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public Plan(String sessionUpdate, List entries) { - this(sessionUpdate, entries, null); + public record StopReason(@JsonValue String value) { + + /** {@code "end_turn"}: the turn ended normally. */ + public static final StopReason END_TURN = new StopReason("end_turn"); + + /** {@code "max_tokens"}: the agent reached its maximum number of tokens. */ + public static final StopReason MAX_TOKENS = new StopReason("max_tokens"); + + /** + * {@code "max_turn_requests"}: the agent reached the maximum number of agent requests it + * allows between user turns. + */ + public static final StopReason MAX_TURN_REQUESTS = new StopReason("max_turn_requests"); + + /** + * {@code "refusal"}: the agent refused to continue. The refused prompt and everything after + * it will not be part of the next prompt, and a client should show this to the user. + */ + public static final StopReason REFUSAL = new StopReason("refusal"); + + /** + * {@code "cancelled"}: the client cancelled the turn with {@code session/cancel}. An agent + * must answer with this after a cancel, even when the cancel made its work fail. + */ + public static final StopReason CANCELLED = new StopReason("cancelled"); + + private static final List KNOWN = List.of(END_TURN, MAX_TOKENS, MAX_TURN_REQUESTS, REFUSAL, CANCELLED); + + /** + * Creates a stop reason for a wire value. Prefer {@link #of}, which returns the constant + * for a known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public StopReason { + Objects.requireNonNull(value, "value"); + } + + /** + * Returns the stop reason for a wire value: the constant when ACP v1 defines the value, and + * otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static StopReason of(String value) { + return knownOrNew(KNOWN, StopReason::value, value, StopReason::new); + } + + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } + + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } + + /** + * Returns the wire value, such as {@code end_turn}. + * @return the wire value + */ + @Override + public String toString() { + return value; } + } /** - * Available commands update + * Where a tool call is in its run: pending, in progress, completed or failed. It is the + * {@code status} of a {@link ToolCall}, a {@link ToolCallUpdateNotification} and a + * {@link ToolCallUpdate}. Agents send one of the constants as the tool call goes on; clients + * compare the value they receive with {@code equals}, or switch on {@link #value()}. + * + *

    + * A tool call usually starts pending, moves to in progress when it runs, and ends completed or + * failed, with a {@link ToolCallUpdateNotification} at each step. A tool call announced without + * a status is pending in the protocol, but its status reads as {@code null} here. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "in_progress"} */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record AvailableCommandsUpdate( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("availableCommands") List availableCommands, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public AvailableCommandsUpdate(String sessionUpdate, List availableCommands) { - this(sessionUpdate, availableCommands, null); + public record ToolCallStatus(@JsonValue String value) { + + /** + * {@code "pending"}: the tool call has not started, because its input is still streaming or + * it waits for the user's permission. + */ + public static final ToolCallStatus PENDING = new ToolCallStatus("pending"); + + /** {@code "in_progress"}: the tool call is running. */ + public static final ToolCallStatus IN_PROGRESS = new ToolCallStatus("in_progress"); + + /** {@code "completed"}: the tool call completed successfully. */ + public static final ToolCallStatus COMPLETED = new ToolCallStatus("completed"); + + /** {@code "failed"}: the tool call failed with an error. */ + public static final ToolCallStatus FAILED = new ToolCallStatus("failed"); + + private static final List KNOWN = List.of(PENDING, IN_PROGRESS, COMPLETED, FAILED); + + /** + * Creates a tool call status for a wire value. Prefer {@link #of}, which returns the + * constant for a known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public ToolCallStatus { + Objects.requireNonNull(value, "value"); + } + + /** + * Returns a tool call status for a wire value: the constant when ACP v1 defines the value, + * and otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ToolCallStatus of(String value) { + return knownOrNew(KNOWN, ToolCallStatus::value, value, ToolCallStatus::new); + } + + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } + + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } + + /** + * Returns the wire value, such as {@code "in_progress"}. + * @return the wire value + */ + @Override + public String toString() { + return value; } + } /** - * Current mode update + * The category of a tool call, such as reading a file, editing one or running a command, so a + * client can pick an icon and choose how to show the call's progress. It is the {@code kind} of + * a {@link ToolCall}, a {@link ToolCallUpdateNotification} and a {@link ToolCallUpdate}. + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askPermission(String, ToolKind) + * PromptContext.askPermission} takes one for the tool call it announces, and uses + * {@link #OTHER} when given none. + * + *

    + * The kinds: {@code read} reads files or data; {@code edit} changes files or content; + * {@code delete} removes files or data; {@code move} moves or renames files; {@code search} + * searches for information; {@code execute} runs commands or code; {@code think} is internal + * reasoning or planning; {@code fetch} gets data from outside, such as a web page; + * {@code switch_mode} switches the session's mode; {@code other} is any other tool, and the + * protocol's default. + * + *

    + * Unlike {@link ToolCallStatus} and the other open values, it is a Java enum: compare with + * {@code ==} or switch on it. {@link #OTHER} also stands for every kind this SDK does not know: + * a newer peer's kind reads as {@code OTHER} and is written back as {@code "other"}, so the + * name it had is lost (see {@link AcpSchema} on forward compatibility). A tool call without a + * kind reads as {@code null}. Use {@link #value()} for the wire name: {@code toString()} + * returns the constant's Java name, such as {@code SWITCH_MODE}. */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record CurrentModeUpdate( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("currentModeId") String currentModeId, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public CurrentModeUpdate(String sessionUpdate, String currentModeId) { - this(sessionUpdate, currentModeId, null); + public enum ToolKind { + + READ("read"), EDIT("edit"), DELETE("delete"), MOVE("move"), SEARCH("search"), EXECUTE("execute"), + THINK("think"), FETCH("fetch"), SWITCH_MODE("switch_mode"), OTHER("other"); + + private final String value; + + ToolKind(String value) { + this.value = value; + } + + /** + * Returns the wire value, such as {@code "switch_mode"}, which is not the constant's Java + * name. + * @return the kind's name in ACP + */ + @JsonValue + public String value() { + return value; + } + + /** + * Returns the kind for a wire value, or {@link #OTHER} for a value this SDK does not know, + * {@code null} included. Names are case sensitive: {@code "READ"} is {@code OTHER}. + * @param value the wire value, or {@code null} + * @return the kind, never {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static ToolKind of(String value) { + for (ToolKind kind : values()) { + if (kind.value.equals(value)) { + return kind; + } + } + return OTHER; } + } /** - * Usage update - context window and cost update for the session (UNSTABLE) + * A side of a conversation, the user or the assistant: an entry of + * {@link Annotations#audience()}, which says whom a piece of content is meant for. Senders use + * the constants; receivers compare the value they receive with {@code equals}, or switch on + * {@link #value()}. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "user"} */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record UsageUpdate( - @JsonProperty(value = "sessionUpdate", access = JsonProperty.Access.WRITE_ONLY) String sessionUpdate, - @JsonProperty("used") Long used, @JsonProperty("size") Long size, @JsonProperty("cost") Cost cost, - @JsonProperty("_meta") Map meta) implements SessionUpdate { - public UsageUpdate(String sessionUpdate, Long used, Long size) { - this(sessionUpdate, used, size, null, null); + public record Role(@JsonValue String value) { + + /** {@code "assistant"}: the assistant side of a conversation. */ + public static final Role ASSISTANT = new Role("assistant"); + + /** {@code "user"}: the user side of a conversation. */ + public static final Role USER = new Role("user"); + + private static final List KNOWN = List.of(ASSISTANT, USER); + + /** + * Creates a role for a wire value. Prefer {@link #of}, which returns the constant for a + * known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public Role { + Objects.requireNonNull(value, "value"); + } + + /** + * Returns a role for a wire value: the constant when ACP v1 defines the value, and + * otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static Role of(String value) { + return knownOrNew(KNOWN, Role::value, value, Role::new); + } + + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } + + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } + + /** + * Returns the wire value, such as {@code user}. + * @return the wire value + */ + @Override + public String toString() { + return value; } + } /** - * Cost information for a session (UNSTABLE) + * What choosing a {@link PermissionOption} means: allow or reject, once or always. The client + * uses it to pick an icon and how to show the option; an "always" kind asks the client to + * remember the choice for later requests, which the SDK does not do for it. Senders use the + * constants; receivers compare the value they receive with {@code equals}, or switch on + * {@link #value()}. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "allow_once"} */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record Cost(@JsonProperty("amount") Double amount, - @JsonProperty("currency") String currency) { - } + public record PermissionOptionKind(@JsonValue String value) { - // --------------------------- - // Tool Call Types - // --------------------------- + /** {@code "allow_once"}: allow the operation this time only. */ + public static final PermissionOptionKind ALLOW_ONCE = new PermissionOptionKind("allow_once"); - /** - * Tool call content - */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", visible = true) - @JsonSubTypes({ @JsonSubTypes.Type(value = ToolCallContentBlock.class, name = "content"), - @JsonSubTypes.Type(value = ToolCallDiff.class, name = "diff"), - @JsonSubTypes.Type(value = ToolCallTerminal.class, name = "terminal") }) - public interface ToolCallContent { + /** {@code "allow_always"}: allow the operation and remember the choice. */ + public static final PermissionOptionKind ALLOW_ALWAYS = new PermissionOptionKind("allow_always"); - } + /** {@code "reject_once"}: reject the operation this time only. */ + public static final PermissionOptionKind REJECT_ONCE = new PermissionOptionKind("reject_once"); - /** - * Tool call content block - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallContentBlock( - @JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("content") ContentBlock content) implements ToolCallContent { - } + /** {@code "reject_always"}: reject the operation and remember the choice. */ + public static final PermissionOptionKind REJECT_ALWAYS = new PermissionOptionKind("reject_always"); - /** - * Tool call diff - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallDiff(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("path") String path, @JsonProperty("oldText") String oldText, - @JsonProperty("newText") String newText) implements ToolCallContent { - } + private static final List KNOWN = List.of(ALLOW_ONCE, ALLOW_ALWAYS, REJECT_ONCE, REJECT_ALWAYS); + + /** + * Creates a kind for a wire value. Prefer {@link #of}, which returns the constant for a + * known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public PermissionOptionKind { + Objects.requireNonNull(value, "value"); + } + + /** + * Returns a kind for a wire value: the constant when ACP v1 defines the value, and + * otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PermissionOptionKind of(String value) { + return knownOrNew(KNOWN, PermissionOptionKind::value, value, PermissionOptionKind::new); + } + + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } + + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } + + /** + * Returns the wire value, such as {@code allow_once}. + * @return the wire value + */ + @Override + public String toString() { + return value; + } - /** - * Tool call terminal - */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallTerminal(@JsonProperty(value = "type", access = JsonProperty.Access.WRITE_ONLY) String type, - @JsonProperty("terminalId") String terminalId) implements ToolCallContent { } /** - * Tool call location + * Where a {@link PlanEntry} of the agent's plan stands: pending, in progress or completed. + * Agents send one of the constants on each entry of every {@link Plan}; clients compare the + * value they receive with {@code equals}, or switch on {@link #value()}. + * + *

    + * There is no status for a failed or dropped step: the agent changes the plan instead, and + * leaves an entry out of the next plan to remove it. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "in_progress"} */ - @JsonInclude(JsonInclude.Include.NON_NULL) - public record ToolCallLocation(@JsonProperty("path") String path, @JsonProperty("line") Integer line) { - } + public record PlanEntryStatus(@JsonValue String value) { - // --------------------------- - // Enums - // --------------------------- + /** {@code "pending"}: the task has not started yet. */ + public static final PlanEntryStatus PENDING = new PlanEntryStatus("pending"); - public enum StopReason { + /** {@code "in_progress"}: the agent is working on the task. */ + public static final PlanEntryStatus IN_PROGRESS = new PlanEntryStatus("in_progress"); - @JsonProperty("end_turn") - END_TURN, @JsonProperty("max_tokens") - MAX_TOKENS, @JsonProperty("max_turn_requests") - MAX_TURN_REQUESTS, @JsonProperty("refusal") - REFUSAL, @JsonProperty("cancelled") - CANCELLED + /** {@code "completed"}: the task is done. */ + public static final PlanEntryStatus COMPLETED = new PlanEntryStatus("completed"); - } + private static final List KNOWN = List.of(PENDING, IN_PROGRESS, COMPLETED); - public enum ToolCallStatus { + /** + * Creates a plan entry status for a wire value. Prefer {@link #of}, which returns the + * constant for a known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public PlanEntryStatus { + Objects.requireNonNull(value, "value"); + } - @JsonProperty("pending") - PENDING, @JsonProperty("in_progress") - IN_PROGRESS, @JsonProperty("completed") - COMPLETED, @JsonProperty("failed") - FAILED + /** + * Returns a plan entry status for a wire value: the constant when ACP v1 defines the value, + * and otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlanEntryStatus of(String value) { + return knownOrNew(KNOWN, PlanEntryStatus::value, value, PlanEntryStatus::new); + } - } + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } - public enum ToolKind { + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } - @JsonProperty("read") - READ, @JsonProperty("edit") - EDIT, @JsonProperty("delete") - DELETE, @JsonProperty("move") - MOVE, @JsonProperty("search") - SEARCH, @JsonProperty("execute") - EXECUTE, @JsonProperty("think") - THINK, @JsonProperty("fetch") - FETCH, @JsonProperty("switch_mode") - SWITCH_MODE, @JsonProperty("other") - OTHER + /** + * Returns the wire value, such as {@code "in_progress"}. + * @return the wire value + */ + @Override + public String toString() { + return value; + } } - public enum Role { - - @JsonProperty("assistant") - ASSISTANT, @JsonProperty("user") - USER + /** + * How much a {@link PlanEntry} matters to the task as a whole: high, medium or low, so a client + * can show which steps of the agent's plan are critical. Agents send one of the constants on + * each entry; clients compare the value they receive with {@code equals}, or switch on + * {@link #value()}. + * + *

    + * An open value: a value this SDK does not know, from a newer peer, is kept and written back + * unchanged, and its {@link #isKnown()} is {@code false}, so it never fails the message + * (see {@link AcpSchema} on forward compatibility). {@link #of} returns the constant for a + * known wire value, so a known value read from the wire is one of the constants. + * + * @param value the wire value, such as {@code "high"} + */ + public record PlanEntryPriority(@JsonValue String value) { - } + /** {@code "high"}: the task is critical to the overall goal. */ + public static final PlanEntryPriority HIGH = new PlanEntryPriority("high"); - public enum PermissionOptionKind { + /** {@code "medium"}: the task is important but not critical. */ + public static final PlanEntryPriority MEDIUM = new PlanEntryPriority("medium"); - @JsonProperty("allow_once") - ALLOW_ONCE, @JsonProperty("allow_always") - ALLOW_ALWAYS, @JsonProperty("reject_once") - REJECT_ONCE, @JsonProperty("reject_always") - REJECT_ALWAYS + /** {@code "low"}: the task is nice to have but not essential. */ + public static final PlanEntryPriority LOW = new PlanEntryPriority("low"); - } + private static final List KNOWN = List.of(HIGH, MEDIUM, LOW); - public enum PlanEntryStatus { + /** + * Creates a plan entry priority for a wire value. Prefer {@link #of}, which returns the + * constant for a known value; a value created here still equals that constant. + * @param value the wire value + * @throws NullPointerException if {@code value} is {@code null} + */ + public PlanEntryPriority { + Objects.requireNonNull(value, "value"); + } - @JsonProperty("pending") - PENDING, @JsonProperty("in_progress") - IN_PROGRESS, @JsonProperty("completed") - COMPLETED + /** + * Returns a plan entry priority for a wire value: the constant when ACP v1 defines the + * value, and otherwise a new, unknown value. + * @param value the wire value + * @return the constant, or a new value for an unknown string + * @throws NullPointerException if {@code value} is {@code null} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static PlanEntryPriority of(String value) { + return knownOrNew(KNOWN, PlanEntryPriority::value, value, PlanEntryPriority::new); + } - } + /** + * Returns the values ACP v1 defines, in schema order. + * @return the constants, in an unmodifiable list + */ + public static List known() { + return KNOWN; + } - public enum PlanEntryPriority { + /** + * Returns whether ACP v1 defines this value; a value from a newer peer is not known. + * @return {@code true} for the value of one of the constants + */ + public boolean isKnown() { + return KNOWN.contains(this); + } - @JsonProperty("high") - HIGH, @JsonProperty("medium") - MEDIUM, @JsonProperty("low") - LOW + /** + * Returns the wire value, such as {@code "high"}. + * @return the wire value + */ + @Override + public String toString() { + return value; + } } @@ -1790,33 +7080,62 @@ public enum PlanEntryPriority { // --------------------------- /** - * Metadata about an implementation (client or agent). + * The name and version of a client or agent, exchanged at {@code initialize}: the client + * sends its own as {@link InitializeRequest#clientInfo()}, and the agent answers with + * {@link InitializeResponse#agentInfo()}. Use it for display, logs and metrics; the protocol + * attaches no behaviour to it. + * + * @param name the name for programs, also shown when there is no title + * @param version the version, such as {@code "1.0.0"} + * @param title a human-readable name for user interfaces, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record Implementation(@JsonProperty("name") String name, @JsonProperty("version") String version, - @JsonProperty("title") String title) { + @JsonProperty("title") @Nullable String title, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates an implementation without {@code _meta}. + * @param name the name for programs + * @param version the version + * @param title a human-readable name, or {@code null} + */ + public Implementation(String name, String version, @Nullable String title) { + this(name, version, title, null); + } + + /** + * Creates an implementation without a title or {@code _meta}. + * @param name the name for programs + * @param version the version + */ public Implementation(String name, String version) { this(name, version, null); } } /** - * MCP server configuration. + * An MCP server the client asks the agent to connect to for an ACP session, passed in the + * {@code mcpServers} of {@link NewSessionRequest}, {@link LoadSessionRequest}, + * {@link ResumeSessionRequest} and {@link ForkSessionRequest}. The agent can then use the + * server's tools and context while it works on prompts. There are three transports: + * {@link McpServerStdio}, which every agent accepts, and {@link McpServerHttp} and + * {@link McpServerSse}, which an agent accepts only when it advertises them in + * {@link McpCapabilities}. + * *

    - * Per the ACP spec: - *

      - *
    • Stdio transport: NO type field (default)
    • - *
    • HTTP transport: type="http"
    • - *
    • SSE transport: type="sse"
    • - *
    - *

    + * The SDK only carries these records: the agent's session handlers connect to the servers, and + * the protocol says they should connect to all of them. The SDK checks the transports against + * {@link McpCapabilities} on neither side; a client checks {@code supportsMcpHttp()} and + * {@code supportsMcpSse()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} before it sends HTTP or SSE servers. + * *

    - * Uses {@code EXISTING_PROPERTY} so that: - *

      - *
    • McpServerStdio (no type method) serializes WITHOUT type field
    • - *
    • McpServerHttp/Sse (with type method) serialize WITH type field
    • - *
    - *

    + * On the wire the {@code type} member tells the transports apart: {@code "http"}, + * {@code "sse"}, and none for stdio. A server without {@code type} reads as an + * {@link McpServerStdio}, and so does a server of a transport this SDK does not know: that + * record then has no command, and the members only that transport has are dropped. */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = McpServerStdio.class) @@ -1827,22 +7146,72 @@ public interface McpServer { } /** - * STDIO MCP server (default transport, no type field in JSON). + * An MCP server the agent starts as a child process and talks to over standard input and + * output: it runs {@link #command()} with {@link #args()}, and sets {@link #env()} in the + * process's environment. Every agent must accept this transport, so a client can always send + * it. It is written without a {@code type} member. + * + *

    + * The protocol requires an absolute path in {@code command}, and requires {@code args} and + * {@code env} on the wire; the SDK checks none of these. Pass empty lists, not {@code null}: a + * {@code null} list is left out of the JSON. A record read from a peer that left them out has + * {@code null} there. + * + * @param name a name for the server, shown to people + * @param command the absolute path of the server's executable + * @param args the command-line arguments, possibly empty + * @param env the environment variables to set for the server, possibly empty + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record McpServerStdio(@JsonProperty("name") String name, @JsonProperty("command") String command, - @JsonProperty("args") List args, @JsonProperty("env") List env) implements McpServer { + @JsonProperty("args") List args, @JsonProperty("env") List env, + @JsonProperty("_meta") @Nullable Map meta) implements McpServer { + /** + * Creates a server without {@code _meta}. + * @param name a name for the server + * @param command the absolute path of the executable + * @param args the command-line arguments, possibly empty + * @param env the environment variables, possibly empty + */ + public McpServerStdio(String name, String command, List args, List env) { + this(name, command, args, env, null); + } } /** - * HTTP MCP server. + * An MCP server the agent reaches over HTTP at {@link #url()}, sending {@link #headers()} with + * its requests. A client sends it only to an agent that advertises {@code mcpCapabilities.http} + * ({@link McpCapabilities}); the protocol recommends that new agents support it. It is written + * with {@code "type": "http"}. + * + *

    + * The protocol requires {@code headers} on the wire: pass an empty list, not {@code null}, + * which is left out of the JSON. A record read from a peer that left it out has {@code null} + * there. The SDK does not check the URL. + * + * @param name a name for the server, shown to people + * @param url the server's URL + * @param headers the HTTP headers to send to the server, possibly empty + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record McpServerHttp(@JsonProperty("name") String name, @JsonProperty("url") String url, - @JsonProperty("headers") List headers) implements McpServer { + @JsonProperty("headers") List headers, + @JsonProperty("_meta") @Nullable Map meta) implements McpServer { + /** + * Creates a server without {@code _meta}. + * @param name a name for the server + * @param url the server's URL + * @param headers the HTTP headers, possibly empty + */ + public McpServerHttp(String name, String url, List headers) { + this(name, url, headers, null); + } /** - * Returns the transport type identifier. + * Returns the discriminator, {@code "http"}, which the JSON carries as {@code type}. + * @return {@code "http"} */ @JsonProperty("type") public String type() { @@ -1851,14 +7220,38 @@ public String type() { } /** - * SSE MCP server. + * An MCP server the agent reaches over HTTP with server-sent events (SSE) at {@link #url()}, + * sending {@link #headers()} with its requests. A client sends it only to an agent that + * advertises {@code mcpCapabilities.sse} ({@link McpCapabilities}). It is written with + * {@code "type": "sse"}. + * + *

    + * It differs from {@link McpServerHttp} only in the transport, which the MCP specification has + * deprecated: prefer {@link McpServerHttp} for an agent that accepts both. As there, pass an + * empty list of headers, not {@code null}, which is left out of the JSON. + * + * @param name a name for the server, shown to people + * @param url the server's URL + * @param headers the HTTP headers to send to the server, possibly empty + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record McpServerSse(@JsonProperty("name") String name, @JsonProperty("url") String url, - @JsonProperty("headers") List headers) implements McpServer { + @JsonProperty("headers") List headers, + @JsonProperty("_meta") @Nullable Map meta) implements McpServer { + /** + * Creates a server without {@code _meta}. + * @param name a name for the server + * @param url the server's URL + * @param headers the HTTP headers, possibly empty + */ + public McpServerSse(String name, String url, List headers) { + this(name, url, headers, null); + } /** - * Returns the transport type identifier. + * Returns the discriminator, {@code "sse"}, which the JSON carries as {@code type}. + * @return {@code "sse"} */ @JsonProperty("type") public String type() { @@ -1867,47 +7260,310 @@ public String type() { } /** - * Environment variable + * One environment variable, a name and a value. It is an item of {@link McpServerStdio#env()}, + * for an MCP server the agent starts, and of {@link CreateTerminalRequest#env()}, for a command + * the agent asks the client to run. Neither may be {@code null}: a {@code null} one is left out + * of the JSON, which the protocol does not allow. + * + * @param name the variable's name + * @param value the variable's value + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record EnvVariable(@JsonProperty("name") String name, @JsonProperty("value") String value, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a variable without {@code _meta}. + * @param name the variable's name + * @param value the variable's value + */ + public EnvVariable(String name, String value) { + this(name, value, null); + } + } + + /** + * One HTTP header, a name and a value, that the agent sends with its requests to an MCP server, + * for example an {@code Authorization} header. It is an item of {@link McpServerHttp#headers()} + * and {@link McpServerSse#headers()}. Neither may be {@code null}: a {@code null} one is left + * out of the JSON, which the protocol does not allow. + * + * @param name the header's name + * @param value the header's value + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record EnvVariable(@JsonProperty("name") String name, @JsonProperty("value") String value) { + public record HttpHeader(@JsonProperty("name") String name, @JsonProperty("value") String value, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a header without {@code _meta}. + * @param name the header's name + * @param value the header's value + */ + public HttpHeader(String name, String value) { + this(name, value, null); + } } /** - * HTTP header + * How a terminal's command ended: its exit code, or the signal that ended it. It is the + * {@link TerminalOutputResponse#exitStatus()} of a {@code terminal/output} answer, present once + * the command has ended; {@code terminal/wait_for_exit} answers with the same two values in a + * {@link WaitForTerminalExitResponse}. + * + *

    + * A process ends with an exit code or with a signal, so one of the two is normally + * {@code null}. The schema allows an exit code up to 4294967295 (an unsigned 32-bit number); + * one above {@link Integer#MAX_VALUE} cannot be read into this record, and the answer that + * carries it fails the agent's call with an {@link AcpError} of code {@code -32603}. + * + * @param exitCode the exit code, or {@code null} if a signal ended the process + * @param signal the signal that ended the process, or {@code null} if it exited + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record HttpHeader(@JsonProperty("name") String name, @JsonProperty("value") String value) { + public record TerminalExitStatus(@JsonProperty("exitCode") @Nullable Integer exitCode, + @JsonProperty("signal") @Nullable String signal, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a status without {@code _meta}. + * @param exitCode the exit code, or {@code null} + * @param signal the signal that ended the process, or {@code null} + */ + public TerminalExitStatus(@Nullable Integer exitCode, @Nullable String signal) { + this(exitCode, signal, null); + } + } + + /** + * One way a client can log in to an agent, as listed in + * {@link InitializeResponse#authMethods()}. It is an {@link AuthMethodAgent}, which the client + * passes to {@code authenticate} by its {@link #id()}, or an {@link AuthMethodTerminal}, which + * the client runs itself by starting the agent program again in an interactive terminal. Check + * the type with {@code instanceof} before acting on a method. + * + *

    + * An annotated agent declares its methods with + * {@link com.agentclientprotocol.sdk.annotation.AuthMethod @AuthMethod} in the + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent#authMethods() authMethods} of + * {@link com.agentclientprotocol.sdk.annotation.AcpAgent @AcpAgent}, and the SDK lists them in + * the {@code initialize} answer: the agent methods to every client, and the terminal methods + * only to a client that advertises {@code auth.terminal}. Methods that an + * {@link com.agentclientprotocol.sdk.annotation.Initialize @Initialize} method returns are + * added after the declared ones, replacing any with the same id, and are not filtered. A + * builder agent lists the methods its initialize handler returns, and none without one. + * + *

    + * On the wire the {@code type} member tells the two apart. A method without {@code type} reads + * as an {@link AuthMethodAgent}, and so does a method of a type this SDK does not know, + * including {@code "agent"}, which some SDKs write; its {@code type} and the members only that + * type has are not kept (see {@link AcpSchema} on forward compatibility). Only + * {@code "terminal"} reads as an {@link AuthMethodTerminal}. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", include = JsonTypeInfo.As.EXISTING_PROPERTY, + defaultImpl = AuthMethodAgent.class) + @JsonSubTypes({ @JsonSubTypes.Type(value = AuthMethodTerminal.class, name = "terminal") }) + public interface AuthMethod { + + /** + * Returns the method's id, unique among the agent's methods. For an {@link AuthMethodAgent} + * the client passes it to {@code authenticate}. + * @return the method id + */ + String id(); + + /** + * Returns the name a client shows the user for this method. + * @return the human-readable name + */ + String name(); + + /** + * Returns a longer description a client may show, or {@code null}. + * @return the description, or {@code null} for none + */ + @Nullable String description(); + } /** - * Terminal exit status + * An authentication method the agent runs itself: the client passes its {@link #id()} to + * {@code authenticate} in an {@link AuthenticateRequest}, and the agent's authenticate handler + * checks the login. It is the default {@link AuthMethod} type, written without a {@code type} + * member. + * + *

    + * An annotated agent gets one for each + * {@link com.agentclientprotocol.sdk.annotation.AuthMethod @AuthMethod} of type {@code AGENT}, + * the default, in {@code @AcpAgent(authMethods = ...)}, and lists it to every client. Such an + * agent needs an {@link com.agentclientprotocol.sdk.annotation.Authenticate @Authenticate} + * method, or building it throws an {@link IllegalStateException}. A builder agent returns these + * from its initialize handler and serves them with an {@code authenticateHandler}. + * + *

    + * A method read with a {@code type} other than {@code "terminal"} also reads as this record + * (see {@link AuthMethod}). + * + * @param id the method id, which the client passes to {@code authenticate} + * @param name the name a client shows the user + * @param description a longer description, or {@code null} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record TerminalExitStatus(@JsonProperty("exitCode") Integer exitCode, - @JsonProperty("signal") String signal) { + public record AuthMethodAgent(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, + @JsonProperty("_meta") @Nullable Map meta) implements AuthMethod { + /** + * Creates a method without {@code _meta}. + * @param id the method id + * @param name the name a client shows + * @param description a longer description, or {@code null} + */ + public AuthMethodAgent(String id, String name, @Nullable String description) { + this(id, name, description, null); + } } /** - * Authentication method + * An authentication method the client runs itself: it starts the agent program again as a + * separate, interactive process, with {@link #args()} added to its arguments and {@link #env()} + * to its environment, and the user logs in there. An exit status of zero means the login + * succeeded; any other end means it failed. The client then reconnects and sends + * {@code initialize} again. The client must not pass this method to {@code authenticate}: the + * terminal process is not the ACP connection. + * + *

    + * The protocol lets an agent list it only to a client that advertises {@code auth.terminal} + * ({@link AuthCapabilities}). An annotated agent gets one for each + * {@link com.agentclientprotocol.sdk.annotation.AuthMethod @AuthMethod} of type + * {@code TERMINAL} in {@code @AcpAgent(authMethods = ...)}, with its {@code args} and its + * {@code env} entries ({@code NAME=value}), leaves it out of the {@code initialize} answer for + * a client without {@code auth.terminal}, and needs no {@code @Authenticate} method for it. A + * builder agent's initialize handler, or an {@code @Initialize} method that returns one, checks + * {@code supportsTerminalAuth()} on + * {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities} itself. + * + *

    + * The client SDK neither runs the login process nor stops an {@code authenticate} call with + * this method's id; the application does both. The method carries no command: the client reuses + * the command it starts the agent with, and how the login hands its credentials to the agent is + * up to the agent. + * + * @param id the method id; the client does not pass it to {@code authenticate} + * @param name the name a client shows the user + * @param description a longer description, or {@code null} + * @param args arguments to add to the agent program's arguments, or {@code null} for none + * @param env environment variables to set for the agent program, overriding those of the same + * name in its launch configuration, or {@code null} for none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AuthMethod(@JsonProperty("id") String id, @JsonProperty("name") String name, - @JsonProperty("description") String description) { + public record AuthMethodTerminal(@JsonProperty("id") String id, @JsonProperty("name") String name, + @JsonProperty("description") @Nullable String description, + @JsonProperty("args") @Nullable List args, @JsonProperty("env") @Nullable Map env, + @JsonProperty("_meta") @Nullable Map meta) implements AuthMethod { + /** + * Creates a method without a description or {@code _meta}. + * @param id the method id + * @param name the name a client shows + * @param args extra arguments for the agent program, or {@code null} + * @param env extra environment variables for the agent program, or {@code null} + */ + public AuthMethodTerminal(String id, String name, @Nullable List args, + @Nullable Map env) { + this(id, name, null, args, env, null); + } + + /** + * Returns the discriminator, {@code "terminal"}, which the JSON carries as {@code type}. + * @return {@code "terminal"} + */ + @JsonProperty("type") + public String type() { + return "terminal"; + } } /** - * Permission option + * The authentication method types a client handles beyond the default, as the {@code auth} + * component of {@link ClientCapabilities}. Today that is only {@code terminal}: when + * {@code true}, the agent may offer {@link AuthMethodTerminal} methods, which the client runs + * as an interactive process instead of passing them to {@code authenticate}. + * + *

    + * Set {@code terminal} only when the client can run the agent's command again in an interactive + * terminal. The client SDK does not run terminal auth methods; the application does. An + * annotated agent leaves its terminal auth methods out of its {@code initialize} answer unless + * the client sets {@code terminal}; other agents check it with {@code supportsTerminalAuth()} + * on {@link com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities + * NegotiatedCapabilities}. + * + * @param terminal whether the client handles {@code terminal} auth methods, or {@code null}, + * read as {@code false} + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record PermissionOption(@JsonProperty("optionId") String optionId, @JsonProperty("name") String name, - @JsonProperty("kind") PermissionOptionKind kind) { + public record AuthCapabilities(@JsonProperty("terminal") @Nullable Boolean terminal, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates the auth capabilities without {@code _meta}. + * @param terminal whether the client handles {@code terminal} auth methods, or {@code null} + */ + public AuthCapabilities(@Nullable Boolean terminal) { + this(terminal, null); + } } /** - * Request permission outcome + * One choice a permission request offers the user: its ID, the label the client shows, and its + * {@link PermissionOptionKind}, such as allow once or reject always. The user's answer, a + * {@link PermissionSelected}, names the chosen option by its {@link #optionId()}. + * + *

    + * All three components are required, and the ID should be unique within the request; the SDK + * checks neither when sending. {@code askPermission} on the prompt context offers + * {@code "allow"} ("Allow", allow once) and {@code "deny"} ("Deny", reject once); + * {@code askChoice} offers one option per text, with its position + * ({@code "0"}, {@code "1"}, and so on) as the ID and the kind allow once. + * + * @param optionId the option's ID, unique within the request + * @param name the label the client shows the user + * @param kind what choosing the option means, a hint for the client + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "outcome", visible = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record PermissionOption(@JsonProperty("optionId") String optionId, @JsonProperty("name") String name, + @JsonProperty("kind") PermissionOptionKind kind, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates an option without {@code _meta}. + * @param optionId the option's ID + * @param name the label the client shows + * @param kind what choosing the option means + */ + public PermissionOption(String optionId, String name, PermissionOptionKind kind) { + this(optionId, name, kind, null); + } + } + + /** + * The user's decision on a permission request: the {@link RequestPermissionResponse#outcome()} + * a client answers {@code session/request_permission} with. {@link PermissionSelected} carries + * the ID of the option the user chose, whether it allows or rejects; + * {@link PermissionCancelled} says the prompt turn was cancelled before the user answered. + * + *

    + * On the wire the {@code outcome} member names the variant, and each variant record has it as + * its first component. An outcome of a kind this SDK does not know, or one without + * {@code outcome}, reads as an {@link UnknownPermissionOutcome}. The interface is not sealed: + * end an {@code instanceof} chain with a branch for anything else + * (see {@link AcpSchema} on forward compatibility). {@code askPermission} and {@code askChoice} + * on the prompt context treat every outcome other than {@code PermissionSelected} as a + * cancelled request. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "outcome", include = JsonTypeInfo.As.EXISTING_PROPERTY, + visible = true, defaultImpl = UnknownPermissionOutcome.class) @JsonSubTypes({ @JsonSubTypes.Type(value = PermissionCancelled.class, name = "cancelled"), @JsonSubTypes.Type(value = PermissionSelected.class, name = "selected") }) public interface RequestPermissionOutcome { @@ -1915,50 +7571,208 @@ public interface RequestPermissionOutcome { } /** - * Permission cancelled + * A permission outcome of a kind this SDK does not know, kept as received: the client is on a + * newer protocol version or sent an extension. {@code askPermission} and {@code askChoice} on + * the prompt context treat it as a cancelled request. + * + *

    + * It keeps the {@code outcome} discriminator ({@code null} when the outcome had none) and every + * other member in {@link #fields()}, an unmodifiable map in wire order, and writes them back + * unchanged. Names are case sensitive: {@code "SELECTED"} is an unknown outcome, not a + * {@link PermissionSelected}. + * + * @param outcome the discriminator as received, or {@code null} + * @param fields every other member, in wire order + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record UnknownPermissionOutcome(@JsonProperty("outcome") @Nullable String outcome, + @JsonAnySetter @JsonAnyGetter Map fields) implements RequestPermissionOutcome { + /** + * Creates an unknown outcome. The fields are copied in their order, and {@code null} fields + * become an empty map. + * @param outcome the discriminator, or {@code null} + * @param fields every other member + */ + public UnknownPermissionOutcome { + fields = unknownFields(fields); + } + } + + /** + * The outcome of a permission request that ended because the prompt turn was cancelled before + * the user answered. The protocol requires a client that cancels a prompt turn + * ({@code session/cancel}) to answer every pending permission request of the turn with it. The + * SDK does not answer for the client, so a permission handler that waits for the user answers + * with it once the turn is cancelled. + * + *

    + * {@code askPermission} on the prompt context returns {@code false} for it and + * {@code askChoice} completes empty (an empty {@code Optional} on the blocking prompt context); + * both mark their tool call {@code failed}. The schema gives this outcome no {@code _meta}: the + * record has none, and a {@code _meta} member on the wire is dropped. + * + * @param outcome the discriminator, {@code "cancelled"} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionCancelled( - @JsonProperty(value = "outcome", access = JsonProperty.Access.WRITE_ONLY) String outcome) + @JsonProperty("outcome") String outcome) implements RequestPermissionOutcome { + /** + * Creates the outcome, as the JSON mapper does. Pass {@code null} for {@code outcome}, or + * use the constructor without arguments, and it becomes {@code "cancelled"}. + * @param outcome {@code null} or {@code "cancelled"} + * @throws IllegalArgumentException if {@code outcome} is any other name + */ + public PermissionCancelled { + outcome = discriminator(outcome, "cancelled"); + } + + /** Creates the cancelled outcome. */ public PermissionCancelled() { this("cancelled"); } } /** - * Permission selected + * The outcome of a permission request the user answered: {@link #optionId()} is the ID of the + * {@link PermissionOption} the user chose, whether it allows or rejects. A client handler + * answers with {@link #PermissionSelected(String) new PermissionSelected(optionId)}. The + * protocol lets a client choose for the user, by the user's settings, with the same outcome. + * + *

    + * The SDK does not check that the ID is one the request offered. {@code askChoice} on the + * prompt context checks it: for an ID it did not offer it fails with an {@link AcpError} of + * code {@code -32603} whose data has the reason {@code "unoffered-option"} and the + * {@code "optionId"}, after it marked its tool call {@code completed}. {@code askPermission} + * returns {@code true} only for its own {@code "allow"} option, and {@code false} for any other + * ID. + * + * @param outcome the discriminator, {@code "selected"} + * @param optionId the ID of the option the user chose + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PermissionSelected( - @JsonProperty(value = "outcome", access = JsonProperty.Access.WRITE_ONLY) String outcome, - @JsonProperty("optionId") String optionId) implements RequestPermissionOutcome { + @JsonProperty("outcome") String outcome, + @JsonProperty("optionId") String optionId, + @JsonProperty("_meta") @Nullable Map meta) implements RequestPermissionOutcome { + /** + * Creates an outcome without {@code _meta}. Unlike {@link #PermissionSelected(String)}, it + * still takes the discriminator: pass {@code null} or {@code "selected"}. + * @param outcome {@code null} or {@code "selected"} + * @param optionId the chosen option's ID + * @throws IllegalArgumentException if {@code outcome} is any other name + */ + public PermissionSelected(String outcome, String optionId) { + this(outcome, optionId, null); + } + + /** + * Creates an outcome with every component, as the JSON mapper does. Pass {@code null} for + * {@code outcome}, or use a shorter constructor, and it becomes {@code "selected"}. + * @param outcome {@code null} or {@code "selected"} + * @param optionId the chosen option's ID + * @param meta the {@code _meta} map, or {@code null} + * @throws IllegalArgumentException if {@code outcome} is any other name + */ + public PermissionSelected { + outcome = discriminator(outcome, "selected"); + } + + /** + * Creates the outcome for the option with this ID, without {@code _meta}. + * @param optionId the chosen option's ID + */ public PermissionSelected(String optionId) { this("selected", optionId); } } /** - * Plan entry + * One step of the agent's {@link Plan}: a task it means to do for the user's request, with its + * priority and its status. The agent sends every entry again, with its current status, in each + * plan update, and the client shows the new list in place of the old one. Entries have no id. + * + *

    + * All three components are required. The SDK does not check them when sending; a received plan + * with an entry that lacks one is skipped whole (see {@link SessionNotification}). A priority + * or status of a value this SDK does not know is kept. + * + * @param content a human-readable description of the task + * @param priority how much the task matters to the overall goal + * @param status where the task stands now + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record PlanEntry(@JsonProperty("content") String content, - @JsonProperty("priority") PlanEntryPriority priority, @JsonProperty("status") PlanEntryStatus status) { + @JsonProperty("priority") PlanEntryPriority priority, @JsonProperty("status") PlanEntryStatus status, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates an entry without {@code _meta}. + * @param content a human-readable description of the task + * @param priority how much the task matters + * @param status where the task stands now + */ + public PlanEntry(String content, PlanEntryPriority priority, PlanEntryStatus status) { + this(content, priority, status, null); + } } /** - * Available command + * A slash command the agent offers in an ACP session: its name, what it does, and whether it + * takes input. The agent lists every command it offers in an {@link AvailableCommandsUpdate}; + * the client shows them as the user types, and the user runs one by sending a slash and its + * name, such as {@code /web agent client protocol}, as text in a prompt. The agent recognizes + * the command in the prompt's text; the SDK does not route commands. + * + *

    + * The name is written without the slash, such as {@code create_plan}. A command that takes text + * after its name has an {@link AvailableCommandInput}; one without takes no input. The SDK + * checks neither the name nor the description. + * + * @param name the command's name, without the leading slash + * @param description a human-readable description of what the command does + * @param input the input the command takes, or {@code null} if it takes none + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) public record AvailableCommand(@JsonProperty("name") String name, @JsonProperty("description") String description, - @JsonProperty("input") AvailableCommandInput input) { + @JsonProperty("input") @Nullable AvailableCommandInput input, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates a command without {@code _meta}. + * @param name the command's name, without the leading slash + * @param description what the command does + * @param input the input the command takes, or {@code null} + */ + public AvailableCommand(String name, String description, @Nullable AvailableCommandInput input) { + this(name, description, input, null); + } } /** - * Available command input + * The input a slash command takes: free text the user types after the command's name, which + * reaches the agent as part of the prompt. {@link #hint()} is what the client shows in its + * place until the user types it, such as {@code "query to search for"}. + * + *

    + * The protocol defines a command's input as one of several kinds; ACP v1 has one, unstructured + * text, which this record is. A received input without a hint lacks a required member, so the + * client skips the whole {@link AvailableCommandsUpdate} that carries it. + * + * @param hint the text shown until the user types the input + * @param meta the {@code _meta} map, reserved for extensions, or {@code null} */ @JsonInclude(JsonInclude.Include.NON_NULL) - public record AvailableCommandInput(@JsonProperty("hint") String hint) { + public record AvailableCommandInput(@JsonProperty("hint") String hint, + @JsonProperty("_meta") @Nullable Map meta) { + /** + * Creates an input without {@code _meta}. + * @param hint the text shown until the user types the input + */ + public AvailableCommandInput(String hint) { + this(hint, null); + } } } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSession.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSession.java index 47c6299c..d65f24b4 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSession.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpSession.java @@ -5,6 +5,7 @@ package com.agentclientprotocol.sdk.spec; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; /** @@ -44,7 +45,10 @@ public interface AcpSession { * @param method the name of the method to be called on the peer * @param requestParams the parameters to be sent with the request * @param typeRef the TypeReference describing the expected response type - * @return a Mono that will emit the response when received + * @return a Mono that will emit the response when received. A response without a + * result ({@code "result": null}) reads as {@code {}} for an + * {@link AcpSchema.DefaultOnNull} type, completes the Mono empty for an extension + * ({@code _}-prefixed) method, and fails the Mono otherwise. */ Mono sendRequest(String method, Object requestParams, TypeRef typeRef); @@ -73,7 +77,7 @@ default Mono sendNotification(String method) { * @param params parameters to be sent with the notification * @return a Mono that completes when the notification has been sent */ - Mono sendNotification(String method, Object params); + Mono sendNotification(String method, @Nullable Object params); /** * Closes the session and releases any associated resources asynchronously. diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpTransport.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpTransport.java index 57a666a3..124ebab3 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpTransport.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/AcpTransport.java @@ -6,33 +6,30 @@ import java.util.List; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** - * Defines the asynchronous transport layer for the Agent Client Protocol (ACP). + * Moves ACP's JSON-RPC messages between this side of a connection and its peer. A transport + * carries one connection: it writes the messages the SDK sends and hands the SDK every + * message that arrives. An application chooses an implementation and passes it to a client or + * agent builder; after that it calls the transport at most to close it or to wait for its end. * - *

    - * The AcpTransport interface provides the foundation for implementing custom transport - * mechanisms in the Agent Client Protocol. It handles the bidirectional communication - * between the client and agent components, supporting asynchronous message exchange using - * JSON-RPC format. - *

    + *

    Two interfaces extend it, one per side: {@link AcpClientTransport} for a client and + * {@link AcpAgentTransport} for an agent. The protocol session that the builder puts on top of + * the transport owns everything else JSON-RPC needs: request ids, pending responses, timeouts + * and handler dispatch. * - *

    - * Implementations of this interface are responsible for: - *

    - *
      - *
    • Managing the lifecycle of the transport connection
    • - *
    • Handling incoming messages and errors from the peer
    • - *
    • Sending outbound messages to the peer
    • - *
    - * - *

    - * The transport layer is designed to be protocol-agnostic, allowing for various - * implementations such as STDIO, HTTP, SSE, or custom protocols. - *

    + *

    Implementations must accept {@link #sendMessage} from several threads at once: the + * session answers the peer's requests on the thread that reads them while user threads send + * their own requests and notifications. {@link #unmarshalFrom} must report a value that does + * not fit the requested type with an {@link IllegalArgumentException}, which + * {@link #unmarshalParams} turns into an Invalid params error. * * @author Mark Pollack * @author Christian Tzolov @@ -40,48 +37,94 @@ public interface AcpTransport { /** - * Closes the transport connection and releases any associated resources. - * - *

    - * This method ensures proper cleanup of resources when the transport is no longer - * needed. It should handle the graceful shutdown of any active connections. - *

    + * Closes the transport without reporting the outcome; {@link #closeGracefully()} is the + * variant to wait on. The interface's default subscribes to {@link #closeGracefully()} + * and returns when that subscription returns: at once when the implementation closes + * asynchronously, and only after the close when it does the work on the subscribing + * thread. A failed close is logged at WARN by this interface's logger. */ default void close() { - this.closeGracefully().subscribe(); + this.closeGracefully() + .subscribe(ignored -> { + }, error -> LoggerFactory.getLogger(AcpTransport.class) + .warn("Closing the transport failed: {}", error.toString(), error)); } /** - * Closes the transport connection and releases any associated resources - * asynchronously. - * @return a {@link Mono} that completes when the connection has been closed. + * Closes the transport: stops reading from and writing to the peer, and releases the + * threads, streams or network connections it holds. What it waits for differs per + * implementation; the stdio client, for example, gives the agent process time to exit. + * The shipped transports can be closed more than once, with this method and + * {@link #close()} in either order: only the first call closes. + * @return a Mono that completes when the transport is closed */ Mono closeGracefully(); /** - * Sends a message to the peer asynchronously. - * - *

    - * This method handles the transmission of messages to the peer in an asynchronous - * manner. Messages are sent in JSON-RPC format as specified by the ACP protocol. - *

    - * @param message the {@link JSONRPCMessage} to be sent to the peer - * @return a {@link Mono} that completes when the message has been sent + * Sends one JSON-RPC message, a request, response or notification, to the peer. When the + * returned Mono completes depends on the implementation: once the message is queued for + * writing (stdio, WebSocket), or once the peer has accepted it (Streamable HTTP, which + * posts it and waits for the HTTP answer). + * @param message the message to send + * @return a Mono that completes when the message has been handed on, or errors when it + * cannot be sent */ Mono sendMessage(JSONRPCMessage message); /** - * Unmarshals the given data into an object of the specified type. - * @param the type of the object to unmarshal - * @param data the data to unmarshal - * @param typeRef the type reference for the object to unmarshal - * @return the unmarshalled object + * Converts a value read from JSON, such as a message's params or a response's result, to + * the given type with this transport's JSON mapper. + * @param the type to convert to + * @param data the value as read from JSON, usually a map + * @param typeRef the type to convert to + * @return the value as that type + * @throws IllegalArgumentException if the value does not fit the type */ T unmarshalFrom(Object data, TypeRef typeRef); /** - * Returns the list of protocol versions supported by this transport. - * @return list of supported protocol versions + * Reads the params of an inbound request or notification as the method's params type, and + * checks that the {@link AcpSchema} records in them carry every field the ACP schema + * requires. Params of the wrong shape are the peer's mistake, not an internal error: they + * fail with JSON-RPC error -32602 (Invalid params), which the session sends back when they + * belong to a request. An application's own params types, such as an extension method's, + * are converted but not checked. + * @param the params type + * @param params the params as received + * @param typeRef the method's params type + * @return the params as that type + * @throws AcpProtocolException if the params cannot be converted to the type or a required + * field is missing; its code is {@link AcpErrorCodes#INVALID_PARAMS} + */ + default T unmarshalParams(Object params, TypeRef typeRef) { + T value; + try { + value = unmarshalFrom(params, typeRef); + } + catch (IllegalArgumentException e) { + throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "Invalid params", firstLine(e.getMessage())); + } + String missing = RequiredFields.firstMissing(value); + if (missing != null) { + throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "Invalid params", + "missing required field: " + missing); + } + return value; + } + + private static @Nullable String firstLine(@Nullable String message) { + if (message == null) { + return null; + } + int end = message.indexOf('\n'); + return end < 0 ? message : message.substring(0, end); + } + + /** + * Returns the ACP protocol versions this transport supports. The SDK does not read it when + * it negotiates a version: a client sends the version its {@code initialize} call names. + * The interface's default returns only {@link AcpSchema#LATEST_PROTOCOL_VERSION}. + * @return the supported protocol versions */ default List protocolVersions() { return List.of(AcpSchema.LATEST_PROTOCOL_VERSION); diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ActivePrompts.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ActivePrompts.java new file mode 100644 index 00000000..7579b79e --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ActivePrompts.java @@ -0,0 +1,226 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The single-turn rule of an agent session: at most one active prompt per logical ACP + * session id, which one connection may multiplex. + * + *

    + * Kotlin SDK precedent: its Agent.SessionWrapper owns a single active prompt guard per + * logical session wrapper. This Java session can multiplex multiple logical ACP sessionIds + * over one transport connection, so the same single-turn rule is applied per sessionId + * instead of once for the whole connection. + *

    + * + *

    + * A prompt holds its session through a {@link Turn}. Ending a turn releases the session only + * while that same turn still holds it: turns compare by identity, so a prompt still finishing + * after the session closed cannot release the turn of a later prompt, whatever their request + * ids. + *

    + * + *

    + * A cancel does not end a turn; the cancelled prompt's response does (see {@link #cancel}). + *

    + */ +final class ActivePrompts { + + private static final Logger logger = LoggerFactory.getLogger(ActivePrompts.class); + + private final ConcurrentHashMap active = new ConcurrentHashMap<>(); + + /** One prompt's hold on its session. Identity, not value, decides which turn is ended. */ + static final class Turn { + + private final String sessionId; + + private final @Nullable Object requestId; + + private final PromptAnswer answer = new PromptAnswer(); + + /** Completes when the turn ends and releases its session. */ + private final Sinks.Empty ended = Sinks.empty(); + + /** Set when the turn ends, before its answer is published. */ + private volatile boolean over; + + /** Runs when a cancel arrives; set by {@link PromptDeadlines} to start the grace period. */ + private volatile @Nullable Runnable onCancelRequested; + + private Turn(String sessionId, @Nullable Object requestId) { + this.sessionId = sessionId; + this.requestId = requestId; + } + + PromptAnswer answer() { + return this.answer; + } + + /** + * Whether the prompt has been answered: a deadline claimed the answer, or the turn ended, + * which it does just before any answer is published. + */ + boolean isAnswered() { + return this.over || this.answer.isAnswered(); + } + + /** + * Runs {@code action} when a cancel arrives for this prompt, or now if one already + * has. It may run twice when the two race: it must be idempotent. + */ + void onCancelRequested(Runnable action) { + this.onCancelRequested = action; + if (this.answer.isCancelling()) { + action.run(); + } + } + + private void requestCancel() { + if (this.answer.requestCancel()) { + Runnable action = this.onCancelRequested; + if (action != null) { + action.run(); + } + } + } + + String sessionId() { + return this.sessionId; + } + + /** Completes once the turn has ended (its prompt answered, failed or was dropped). */ + Mono ended() { + return this.ended.asMono(); + } + + @Nullable Object requestId() { + return this.requestId; + } + + } + + /** + * Starts a prompt's turn on its session. + * @return the turn, or null when the session already has an active prompt + */ + @Nullable Turn tryStart(String sessionId, @Nullable Object requestId) { + Turn turn = new Turn(sessionId, requestId); + Turn current = this.active.putIfAbsent(sessionId, turn); + if (current != null) { + logger.warn("Rejected concurrent prompt request for sessionId={}. Active requestId={}", sessionId, + current.requestId()); + return null; + } + return turn; + } + + /** + * Ends {@code turn} before {@code response} publishes its result or error. + * + *

    + * The prompt response ends the turn (ACP semantics), so the session is released + * before the response is handed downstream to the transport. Releasing in + * doFinally instead ran after the response had already reached the client; under CPU + * contention the client's next prompt then arrived before the release and was rejected + * as a concurrent prompt (#14). doOnNext and doOnError run before the signal propagates, and a Mono + * emits at most once, so the release is ordered before publication on every path; + * doFinally covers cancellation. + *

    + */ + Mono endBeforePublishing(Turn turn, Mono response) { + return response.doOnNext(result -> end(turn, "response")) + .doOnError(error -> end(turn, "error")) + .doFinally(signal -> end(turn, signal.toString())); + } + + /** + * Releases the turn's session if this turn still holds it. Idempotent: the release is + * attempted on the response, on an error and on the terminal signal, and only the first + * attempt does anything. + * @return whether this call released the session + */ + boolean end(Turn turn, String reason) { + turn.over = true; + if (this.active.remove(turn.sessionId(), turn)) { + logger.debug("Prompt lock released for sessionId={} requestId={} ({})", turn.sessionId(), + turn.requestId(), reason); + turn.ended.tryEmitEmpty(); + return true; + } + return false; + } + + /** + * Notes a {@code session/cancel}. The turn does not end here: after a cancel the agent + * may still send {@code session/update}s and must then answer the original + * {@code session/prompt} with stop reason {@code cancelled}, and only once the turn has + * completed may the client send another prompt (ACP v1, prompt turn, Cancellation). The + * turn ends when that response is published ({@link #endBeforePublishing}), when the + * handler fails (a timeout the handler applies included), when the request's + * subscription is cancelled, or when the session closes. If the handler has not answered + * within the cancel grace period ({@link PromptTimeouts}), the session answers + * {@code cancelled} itself ({@link PromptDeadlines}). + * @return whether a prompt is active, and so will end with its response + */ + boolean cancel(String sessionId) { + Turn current = this.active.get(sessionId); + if (current != null) { + current.requestCancel(); + logger.debug("Cancel requested for sessionId={} requestId={}; the turn ends with its response", + sessionId, current.requestId()); + return true; + } + return false; + } + + /** The turn holding the session, or null. */ + @Nullable Turn current(String sessionId) { + return this.active.get(sessionId); + } + + boolean isActive(String sessionId) { + return this.active.containsKey(sessionId); + } + + /** + * Whether no session has an active prompt. Looks for a key rather than asking the map's + * size: ConcurrentHashMap counts its entries in striped counters that concurrent updates + * change after the entry itself, so its isEmpty() and size() can report no entries while + * a prompt started earlier is still active (found by Lincheck, ActivePromptsLincheckTest). + */ + boolean isEmpty() { + return !this.active.keySet().iterator().hasNext(); + } + + /** One session with an active prompt, arbitrary when there are several, or null. */ + @Nullable String anySessionId() { + return this.active.keySet().stream().findFirst().orElse(null); + } + + /** + * An immutable snapshot of the sessions with an active prompt. Copied by iterating: + * Set.copyOf(keySet()) first asks isEmpty(), which can miss an active prompt (see + * {@link #isEmpty()}). + */ + Set sessionIds() { + return Set.copyOf(new HashSet<>(this.active.keySet())); + } + + void clear() { + new HashSet<>(this.active.values()).forEach(turn -> end(turn, "cleared")); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ExtensionMethods.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ExtensionMethods.java new file mode 100644 index 00000000..1c1be2f0 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ExtensionMethods.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import org.jspecify.annotations.Nullable; + +/** + * ACP's rule for extension methods, the custom requests and notifications two peers agree on + * outside the protocol: the method name must start with an underscore ({@code _}), such as + * {@code _example.com/workspace/buffers} (ACP v1, Extensibility). Every extension API of the SDK + * checks names with {@link #requireExtension}: the builders' {@code extRequestHandler} and + * {@code extNotificationHandler}, the facades' {@code sendExtRequest} and + * {@code sendExtNotification}, and the {@code @ExtRequest} and {@code @ExtNotification} annotations + * when an annotated agent is built. So a custom handler can never replace a protocol method, and a + * custom send can never pass for one. Applications call it only to check a name themselves. + * + *

    How the SDK treats extension methods, the same on both sides: + *

      + *
    • A request no handler serves is answered "Method not found" ({@code -32601}); a notification + * no handler serves is ignored, as ACP asks, and logged at DEBUG only.
    • + *
    • Params the peer omitted arrive as an empty object. Params that cannot be read as the + * handler's params type are answered "Invalid params" ({@code -32602}) for a request; for a + * notification the failure is logged and the notification dropped.
    • + *
    • A request handler must produce a result: one that produces none (an empty {@code Mono}, or + * {@code null}) is answered with an internal error ({@code -32603}). Answer with an empty map when + * there is nothing to return.
    • + *
    • When the peer answers {@code "result": null}, the sender's {@code Mono} completes empty and + * the sync API returns {@code null}.
    • + *
    • They are outside ACP's lifecycle: the SDK does not wait for {@code initialize} before sending + * or serving them, and checks no capability. ACP asks peers to advertise their extensions in the + * {@code _meta} of their capabilities.
    • + *
    + * + * @author Mark Pollack + */ +public final class ExtensionMethods { + + /** The prefix ACP reserves for extension method names: {@value}. */ + public static final String PREFIX = "_"; + + private ExtensionMethods() { + } + + /** + * Returns whether a method name is an extension method name. + * @param method the method name, or {@code null} + * @return true if it starts with {@value #PREFIX}; false for {@code null} + */ + public static boolean isExtension(@Nullable String method) { + return method != null && method.startsWith(PREFIX); + } + + /** + * Checks that a method name is an extension method name. + * @param method the method name + * @return the method name, unchanged + * @throws IllegalArgumentException if it is null or does not start with {@value #PREFIX} + */ + public static String requireExtension(@Nullable String method) { + if (method == null || !isExtension(method)) { + throw new IllegalArgumentException("Extension method names must start with '" + PREFIX + + "' (ACP reserves the other names for the protocol): " + method); + } + return method; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundMessages.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundMessages.java new file mode 100644 index 00000000..6e656fd3 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundMessages.java @@ -0,0 +1,159 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; +import java.util.function.BiFunction; +import java.util.concurrent.CancellationException; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.util.HandlerFailures; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +/** + * How a session answers the requests and notifications its peer sends, the same way for + * both session sides. + */ +final class InboundMessages { + + private static final Logger LOGGER = LoggerFactory.getLogger(InboundMessages.class); + + /** The message of the internal error a handler's unexpected exception is answered with. */ + static final String INTERNAL_ERROR_MESSAGE = "Internal error"; + + private InboundMessages() { + } + + /** + * The params a handler receives. JSON-RPC lets a request or notification omit them; + * an omitted params reads as an empty object, which is what a peer sending {@code {}} + * would deliver, so handlers never see null. + */ + static Object paramsOrEmpty(@Nullable Object params) { + return (params != null) ? params : Map.of(); + } + + /** + * Passes a notification to the handler registered for its method; a handler that fails + * (params it cannot read, say) is logged and skipped. One without a handler is ignored, + * logged by its method only: its params can carry personal data (agents send + * {@code _auth/status_update}, whose params carry the account's email address). An + * extension notification ({@code _}-prefixed) is logged at DEBUG, since ACP asks + * implementations to ignore notifications they do not recognize and a peer may send many; + * a protocol notification without a handler is logged at WARN, as it usually means a + * handler was not registered. + */ + static Mono deliver(Logger logger, AcpSchema.JSONRPCNotification notification, + Map handlers, BiFunction> handle) { + H handler = handlers.get(notification.method()); + if (handler == null) { + if (ExtensionMethods.isExtension(notification.method())) { + logger.debug("No handler registered for extension notification method: {}", notification.method()); + } + else { + logger.warn("No handler registered for notification method: {}", notification.method()); + } + return Mono.empty(); + } + // A notification has no answer to carry a failure: it is logged (method and error + // only) and the session reads on. A failure must not end the inbound stream. An Error + // (HandlerFailures) is logged at ERROR with its stack trace. + return HandlerFailures.invoke(() -> handle.apply(handler, paramsOrEmpty(notification.params()))) + .onErrorResume(error -> { + Throwable failure = HandlerFailures.contain(error); + if (failure instanceof HandlerFailures.HandlerError) { + LOGGER.error("Notification {} handler failed with an error and was skipped", + notification.method(), failure.getCause()); + } + else if (error instanceof AcpError peerError) { + // The peer's answer to a call the handler made: its message and data are the + // peer's text, logged at DEBUG only. + logger.warn("Notification {} failed and was skipped: the peer answered {} {}", + notification.method(), peerError.getCode(), AcpErrorCodes.getDescription(peerError.getCode())); + logger.debug("Notification {} failed: {}", notification.method(), peerError.toString()); + } + else { + logger.warn("Notification {} failed and was skipped: {}", notification.method(), error.toString()); + } + return Mono.empty(); + }); + } + + /** A request always gets a response: a handler that completes empty is answered with an error. */ + static Mono requireResult(Mono result, String method) { + return result.switchIfEmpty(Mono.error(() -> new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, + "The " + method + " handler produced no response"))); + } + + static AcpSchema.JSONRPCResponse result(AcpSchema.JSONRPCRequest request, Object result) { + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null); + } + + static AcpSchema.JSONRPCResponse error(AcpSchema.JSONRPCRequest request, int code, String message, + @Nullable Object data) { + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, + new AcpSchema.JSONRPCError(code, message, data)); + } + + /** + * The response to a request whose handling failed: an {@link AcpProtocolException} keeps + * its code, message and data (it is the handler's intended answer), an {@link AcpError} + * that escaped the handler passes on the error it carries unchanged, a cancellation + * ({@link #isCancellation}) is the handler cancelling its own work (ACP v1 internal + * cancellation: the same {@code -32800} as a cancel from the caller), anything else is an + * internal error with the generic message {@value #INTERNAL_ERROR_MESSAGE}: an exception's + * message can carry paths, queries or credentials the peer must not see, so it is logged + * here, at WARN with its stack trace, and not sent. A handler that failed with an + * {@link Error} ({@link HandlerFailures}) is answered with an internal error that names + * the method and the error's type only, and the error is logged at ERROR with its stack + * trace. + */ + static AcpSchema.JSONRPCResponse error(AcpSchema.JSONRPCRequest request, Throwable error) { + if (HandlerFailures.contain(error) instanceof HandlerFailures.HandlerError handlerError) { + Throwable cause = (handlerError.getCause() != null) ? handlerError.getCause() : handlerError; + LOGGER.error("The {} handler failed with {}; answered {} (Internal error)", request.method(), + cause.getClass().getName(), AcpErrorCodes.INTERNAL_ERROR, cause); + return error(request, AcpErrorCodes.INTERNAL_ERROR, + "Internal error in the " + request.method() + " handler (" + cause.getClass().getName() + ")", + null); + } + if (error instanceof AcpProtocolException protocolException) { + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, + AcpSchema.JSONRPCError.from(protocolException)); + } + if (error instanceof AcpError peerError) { + // A request the handler made failed and the handler let it escape: the peer's + // error is passed on as it is, code, message and data. + LOGGER.debug("The {} handler let a peer error escape; passed on {}", request.method(), peerError.getCode()); + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, peerError.getError()); + } + if (isCancellation(error)) { + String message = error.getMessage(); + return error(request, AcpErrorCodes.REQUEST_CANCELLED, + (message != null) ? message : InboundRequests.CANCELLED_MESSAGE, null); + } + LOGGER.warn("The {} handler failed; answered {} ({})", request.method(), AcpErrorCodes.INTERNAL_ERROR, + INTERNAL_ERROR_MESSAGE, error); + return error(request, AcpErrorCodes.INTERNAL_ERROR, INTERNAL_ERROR_MESSAGE, null); + } + + /** + * Whether a handler's failure means its work was cancelled: a + * {@link CancellationException} or an interrupt (Reactor's {@code block()} wraps it), or + * an {@link AcpProtocolException} with code -32800. + */ + static boolean isCancellation(Throwable error) { + Throwable unwrapped = Exceptions.unwrap(error); + return unwrapped instanceof CancellationException || unwrapped instanceof InterruptedException + || (error instanceof AcpProtocolException protocolException + && protocolException.getCode() == AcpErrorCodes.REQUEST_CANCELLED); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundOrder.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundOrder.java new file mode 100644 index 00000000..d27ba14a --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundOrder.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The order in which a session hands on what its peer sent: each notification to its + * handler, one at a time in arrival order, and each response to its caller only once every + * notification that arrived before it has been handled. A prompt turn's updates come before + * its response, so a caller that reads what its update handler collected once the prompt + * returns finds all of them. + * + *

    + * A response takes its place in the notification queue, so it is released by the drain + * once the notifications ahead of it are handled. One response is not held: when a handler + * that was already running when the request was sent is still running as its response + * arrives, that handler may be the one waiting for the response (it sent the request + * itself), so the response completes at once rather than deadlock it. + *

    + * + *

    + * A request from the peer takes its place the same way: it is dispatched to its handler only + * once the notifications that arrived before it have been handled, so a permission request + * reaches its handler after the {@code tool_call} update announcing it. The same escape keeps + * it from deadlocking: while the handler running now waits for a response to a request it + * sent, held requests are dispatched at once, since answering one may be what that response + * waits for. Dispatching does not wait for the request's handler to finish. + *

    + * + *

    + * The inbound thread offers notifications, responses and requests, one at a time; one subscriber + * drains them; a closing thread completes the queue. A response still held when the drain + * ends (the session was closed without waiting for it) is released then. + *

    + * + * @param the notification type + */ +final class InboundOrder implements ResponseOrder { + + private final NotificationQueue queue = new NotificationQueue<>(); + + /** How many notifications were offered; it numbers them. Written by the inbound thread only. */ + private volatile long received; + + /** The number of the notification whose handler is running, or 0 when none is. */ + private volatile long handling; + + /** The responses and requests queued behind notifications and not yet released. */ + private final Set held = ConcurrentHashMap.newKeySet(); + + /** The requests from the peer queued behind notifications and not yet dispatched. */ + private final Set heldRequests = ConcurrentHashMap.newKeySet(); + + /** + * The requests this side sent that wait for a response, each with the number of the + * handler that was running when it was sent (0 for none). + */ + private final Map awaiting = new ConcurrentHashMap<>(); + + /** + * The drain: delivers each notification, waiting for its delivery to complete before the + * next, and releases each response in its place. + * @param deliver delivers one notification; the Mono completes when it was handled + * @return the drain, to subscribe once; it completes once the queue is completed + */ + Flux drain(Function> deliver) { + return this.queue.asFlux().concatMap(item -> { + if (item instanceof Release release) { + return Mono.fromRunnable(release); + } + @SuppressWarnings("unchecked") + Numbered numbered = (Numbered) item; + return Mono.defer(() -> { + this.handling = numbered.number(); + return deliver.apply(numbered.notification()); + }).onErrorComplete().then(Mono.fromRunnable(() -> this.handling = 0)); + }); + } + + /** + * Queues a notification for delivery. + * @return the emission result: a failure when the queue is completed or completing + */ + Sinks.EmitResult offer(T notification) { + // Numbered before the emission: a handler run inside it may send a request, which + // must count this notification as already arrived. + long number = this.received + 1; + this.received = number; + return this.queue.offer(new Numbered<>(number, notification)); + } + + @Override + public long position() { + return this.received; + } + + @Override + public Mono after(long sentAt, R response) { + return Mono.create(sink -> release(sentAt, () -> sink.success(response))); + } + + /** + * Runs {@code completion} once every notification that arrived before it has been + * handled, or at once when the handler running now was already running when the request + * was sent. + */ + void release(long sentAt, Runnable completion) { + long running = this.handling; + if (running != 0 && running <= sentAt) { + // The running handler may be waiting for this very response. + completion.run(); + return; + } + Release release = new Release(this.held, null, completion); + this.held.add(release); + if (this.queue.offer(release).isFailure()) { + // The queue is closing: nothing more is delivered in order. + release.run(); + } + } + + /** + * Runs {@code dispatch} once every notification that arrived before it has been handled, + * or at once when the handler running now waits for a response (see the type comment). + * Called on the inbound thread when a request arrives. + */ + void inOrder(Runnable dispatch) { + Release release = new Release(this.held, this.heldRequests, dispatch); + this.held.add(release); + this.heldRequests.add(release); + if (runningHandlerAwaits() || this.queue.offer(release).isFailure()) { + // A waiting handler must not hold it; a closing queue delivers nothing more in order. + release.run(); + } + } + + @Override + public Runnable awaiting(long sentAt) { + Object key = new Object(); + long running = this.handling; + this.awaiting.put(key, running); + if (running != 0) { + // This handler may now wait for the response, which may need a held request answered. + for (Release request : this.heldRequests) { + request.run(); + } + } + return () -> this.awaiting.remove(key); + } + + /** Whether the handler running now sent a request that still waits for its response. */ + private boolean runningHandlerAwaits() { + long running = this.handling; + return running != 0 && this.awaiting.containsValue(running); + } + + /** Releases every response and request still held: the drain will not reach them. */ + void releaseHeld() { + for (Release release : this.held) { + release.run(); + } + } + + /** + * Completes the queue: the drain delivers what was queued before, then completes. Never + * lost to an offer emitting concurrently (see {@link NotificationQueue}). + */ + void complete() { + this.queue.complete(); + } + + /** Whether {@link #complete} has been called. */ + boolean isCompleting() { + return this.queue.isCompleting(); + } + + private record Numbered(long number, T notification) { + } + + /** A held response; runs its completion at most once, whichever releases it first. */ + private static final class Release implements Runnable { + + private final Set held; + + private final Runnable completion; + + /** The second set a held request is in, or null for a response. */ + private final @Nullable Set alsoIn; + + Release(Set held, @Nullable Set alsoIn, Runnable completion) { + this.held = held; + this.alsoIn = alsoIn; + this.completion = completion; + } + + @Override + public void run() { + if (this.held.remove(this)) { + Set other = this.alsoIn; + if (other != null) { + other.remove(this); + } + this.completion.run(); + } + } + + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundRequests.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundRequests.java new file mode 100644 index 00000000..ce0cf981 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/InboundRequests.java @@ -0,0 +1,207 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.math.BigInteger; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The requests a session received from its peer and has not answered yet, keyed by request + * id, so that a {@code $/cancel_request} can cancel one (ACP v1, Cancellation), the same way + * for both session sides. + * + *

    + * A cancelled request still gets exactly one response: the handler's, when it came first, + * otherwise the error {@code -32800} (Request cancelled). Both race inside one {@code Mono} + * ({@link Mono#takeUntilOther}), which signals at most once, so there is never a second + * response for the id. The handler's subscription is cancelled, which also cancels the + * requests it was waiting on (their sessions send {@code $/cancel_request} in turn). + *

    + */ +final class InboundRequests { + + private static final Logger logger = LoggerFactory.getLogger(InboundRequests.class); + + static final String CANCELLED_MESSAGE = "Request cancelled"; + + /** The cancel trigger of each request being handled, keyed by its normalised id. */ + private final ConcurrentHashMap inFlight = new ConcurrentHashMap<>(); + + /** One request's cancel: the trigger, and whether a cancel has been asked for. */ + private static final class Cancel { + + final Sinks.Empty trigger = Sinks.empty(); + + volatile boolean requested; + + boolean fire() { + this.requested = true; + return this.trigger.tryEmitEmpty().isSuccess(); + } + + } + + /** Set once the session closes: requests still arriving are cancelled at once. */ + private volatile boolean closed; + + /** + * Makes {@code response}, the answer to {@code request}, cancellable by + * {@link #cancel}, which answers {@code -32800}. See + * {@link #track(AcpSchema.JSONRPCRequest, Mono, Supplier)}. + */ + Mono track(AcpSchema.JSONRPCRequest request, Mono response) { + return track(request, response, () -> cancelled(request)); + } + + /** + * Makes {@code response}, the answer to {@code request}, cancellable by + * {@link #cancel}. Call it on the complete answer: whatever {@code response} emits is the + * request's only response, and when a cancel wins the race, {@code whenCancelled} + * supplies the response instead. A request without an id cannot be cancelled and is + * passed through. + * + *

    + * The request becomes cancellable only once {@code response} is subscribed, so a cancel + * always cancels a running handler (whose own cancellation, a prompt's turn included, + * runs before the answer is produced), and stops being cancellable as soon as its + * response is produced. + *

    + */ + Mono track(AcpSchema.JSONRPCRequest request, Mono response, + Supplier whenCancelled) { + Object key = key(request.id()); + if (key == null) { + return response; + } + return Mono.defer(() -> { + Cancel cancel = new Cancel(); + // takeUntilOther subscribes the trigger before the response, and passes + // onSubscribe down only once the response is subscribed: registering here means + // a cancel can only reach a handler that is running. A handler that fails once + // it is cancelled (an interrupted blocking handler throws) was cancelled too. + return response.onErrorResume(error -> cancel.requested, error -> Mono.empty()) + .takeUntilOther(cancel.trigger.asMono()) + .doOnSubscribe(subscription -> register(key, cancel, request)) + .switchIfEmpty(Mono.fromSupplier(whenCancelled)) + .doOnNext(answer -> this.inFlight.remove(key, cancel)) + .doFinally(signal -> this.inFlight.remove(key, cancel)); + }); + } + + private void register(Object key, Cancel cancel, AcpSchema.JSONRPCRequest request) { + if (this.closed) { + cancel.fire(); + return; + } + if (this.inFlight.putIfAbsent(key, cancel) != null) { + // The peer reused the id of a request still being handled: a cancel names the + // first one. + logger.warn("Request id {} is already in use by an unanswered request", request.id()); + } + else if (this.closed) { + // Closed while registering: cancelAll may not have seen this request. + cancel.fire(); + } + } + + /** + * Cancels the request with this id, if it is still being handled. + * @return whether a request was cancelled; false when the id is unknown or already + * answered + */ + boolean cancel(@Nullable Object requestId) { + Object key = key(requestId); + Cancel cancel = (key != null) ? this.inFlight.get(key) : null; + if (cancel == null || !cancel.fire()) { + logger.debug("Ignored $/cancel_request for id {}: no request with that id is being handled", requestId); + return false; + } + logger.debug("Cancelled request {}", requestId); + return true; + } + + /** + * Cancels every request being handled, and every request that arrives after: the session + * is closing (ACP v1 internal cancellation). Each is answered as if the peer had + * cancelled it, so far as the transport still delivers. + */ + void cancelAll() { + this.closed = true; + for (Object key : this.inFlight.keySet()) { + cancel(key); + } + } + + /** + * Cancels the request a {@code $/cancel_request} notification names. The params are read + * here rather than through {@link AcpTransport#unmarshalParams}: a notification has no + * response to carry a -32602, so params without a usable {@code requestId} (missing, null, + * or not a string or integer, as the schema's {@code RequestId} requires) are logged and + * ignored. + */ + boolean cancel(AcpSchema.JSONRPCNotification notification) { + Object requestId = requestId(notification.params()); + if (!isRequestId(requestId)) { + logger.debug("Ignored $/cancel_request with invalid params {}", notification.params()); + return false; + } + return cancel(requestId); + } + + /** The number of requests being handled. */ + int size() { + return this.inFlight.size(); + } + + static AcpSchema.JSONRPCResponse cancelled(AcpSchema.JSONRPCRequest request) { + return InboundMessages.error(request, AcpErrorCodes.REQUEST_CANCELLED, CANCELLED_MESSAGE, null); + } + + /** + * The id a {@code $/cancel_request} names. Its params arrive as a map from the JSON + * transports and as the typed record from in-process ones. + */ + private static @Nullable Object requestId(@Nullable Object params) { + if (params instanceof AcpSchema.CancelRequestNotification notification) { + return notification.requestId(); + } + if (params instanceof Map map) { + return map.get("requestId"); + } + return null; + } + + /** A JSON-RPC id the schema allows and a request can carry: a string or an integer. */ + private static boolean isRequestId(@Nullable Object id) { + return id instanceof String || id instanceof Integer || id instanceof Long || id instanceof Short + || id instanceof Byte || (id instanceof BigInteger big && big.bitLength() < Long.SIZE); + } + + /** + * The table key of a JSON-RPC id. An integer id may be read as an Integer in one message + * and a Long in another, so integer types compare by value; a string id never equals a + * number, and a fractional or out-of-range number is kept as it is. A null id cannot be + * correlated. + */ + static @Nullable Object key(@Nullable Object id) { + if (id instanceof Integer || id instanceof Long || id instanceof Short || id instanceof Byte) { + return ((Number) id).longValue(); + } + if (id instanceof BigInteger big && big.bitLength() < Long.SIZE) { + return big.longValue(); + } + return id; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/NotificationQueue.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/NotificationQueue.java new file mode 100644 index 00000000..c778cb9f --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/NotificationQueue.java @@ -0,0 +1,75 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Sinks; + +/** + * The inbound notifications a session has yet to deliver, in arrival order: the transport's + * inbound thread offers them and one subscriber drains them; a closing thread completes the + * queue. + * + *

    + * Completion is never lost. Offering and completing emit on one serialized sink, which refuses + * an emission that collides with another ({@code FAIL_NON_SERIALIZED}) rather than wait. A + * subscriber that handles a notification synchronously does so inside the offer's emission, so + * a close that arrives meanwhile collides with it for as long as the handler runs: no bounded + * retry would do. Instead {@link #complete} first records that completion is wanted, and every + * offer, once its emission returns, completes the queue when it is wanted. The emission a + * complete collided with is therefore followed by a completion from the thread that made it. + *

    + * + * @param the notification type + */ +final class NotificationQueue { + + private final Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + + /** Set before the first attempt to complete; read by every offer after its emission. */ + private volatile boolean completing; + + /** The notifications in arrival order; it completes once the queue is completed. */ + Flux asFlux() { + return this.sink.asFlux(); + } + + /** + * Queues a notification. + * @param notification the notification + * @return the emission result: a failure when the queue is completed or completing, or the + * notification collided with another offer + */ + Sinks.EmitResult offer(T notification) { + Sinks.EmitResult result = this.sink.tryEmitNext(notification); + if (this.completing) { + // A complete may have collided with this emission and left the completion to us. + completeNow(); + } + return result; + } + + /** + * Completes the queue: the subscriber receives what was queued before, then completion. + * Returns without waiting when an offer is emitting; that offer completes the queue. + */ + void complete() { + this.completing = true; + completeNow(); + } + + /** Whether {@link #complete} has been called. */ + boolean isCompleting() { + return this.completing; + } + + private void completeNow() { + // The result needs no handling. FAIL_NON_SERIALIZED: another thread is emitting, and + // completes the queue once it has (an offer) or is completing it now (another + // complete). FAIL_TERMINATED: already completed. + this.sink.tryEmitComplete(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/OutboundMessages.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/OutboundMessages.java new file mode 100644 index 00000000..17096311 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/OutboundMessages.java @@ -0,0 +1,205 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.SynchronousSink; + +/** + * The requests and notifications a session sends to its peer, the same way for both session + * sides: request ids, the requests still waiting for a response, the request timeout, and + * failing fast once the transport can no longer deliver. + */ +final class OutboundMessages { + + private static final Logger logger = LoggerFactory.getLogger(OutboundMessages.class); + + private final AcpTransport transport; + + private final Duration requestTimeout; + + /** The reason the transport cannot deliver (it failed to connect or start, or terminated), or null. */ + private final Supplier<@Nullable Throwable> transportFailure; + + /** Wraps the transport failure into the error a caller receives. */ + private final Function unavailable; + + /** Requests waiting for a response, keyed by request id. */ + private final PendingResponses pendingResponses; + + /** When a response that arrived may complete its caller. */ + private final ResponseOrder responseOrder; + + /** Session-specific prefix for request ids. */ + private final String idPrefix = UUID.randomUUID().toString().substring(0, 8); + + private final AtomicLong requestCounter = new AtomicLong(0); + + OutboundMessages(AcpTransport transport, Duration requestTimeout, Supplier<@Nullable Throwable> transportFailure, + Function unavailable, String peer) { + this(transport, requestTimeout, transportFailure, unavailable, peer, ResponseOrder.IMMEDIATE); + } + + OutboundMessages(AcpTransport transport, Duration requestTimeout, Supplier<@Nullable Throwable> transportFailure, + Function unavailable, String peer, ResponseOrder responseOrder) { + this.transport = transport; + this.responseOrder = responseOrder; + this.requestTimeout = requestTimeout; + this.transportFailure = transportFailure; + this.unavailable = unavailable; + this.pendingResponses = new PendingResponses(transportFailure, unavailable, peer); + } + + /** + * Sends a request and waits, at most the request timeout, for its response. An error + * response fails the returned Mono with {@link AcpError}. A response that arrived + * completes the Mono when the {@link ResponseOrder} lets it, within the same timeout. + */ + Mono sendRequest(String method, Object params, TypeRef typeRef) { + return sendRequest(method, params, typeRef, this.requestTimeout); + } + + /** + * Sends a request and waits, at most {@code timeout}, for its response; with a null + * {@code timeout} it waits until the response arrives, the caller disposes the request, or + * the transport fails. Otherwise as {@link #sendRequest(String, Object, TypeRef)}. + */ + Mono sendRequest(String method, Object params, TypeRef typeRef, @Nullable Duration timeout) { + Mono response = Mono.deferContextual(ctx -> { + // One id per subscription: a resubscribed request (a retry) is a new request. + String requestId = this.idPrefix + "-" + this.requestCounter.getAndIncrement(); + long sentAt = this.responseOrder.position(); + Runnable answered = this.responseOrder.awaiting(sentAt); + // Completes once the request is handed to the transport; a $/cancel_request + // waits for it, so it never overtakes its request on an ordered transport. + Sinks.Empty written = Sinks.empty(); + // At most one $/cancel_request per request, whether the caller disposed it or a + // RequestCancellation trigger fired. + AtomicBoolean cancelSent = new AtomicBoolean(); + Runnable cancel = () -> { + if (cancelSent.compareAndSet(false, true)) { + cancelRequest(requestId, written.asMono()); + } + }; + Disposable.Swap gracefulCancel = Disposables.swap(); + return Mono.create(responseSink -> { + if (!this.pendingResponses.register(requestId, responseSink, cancel)) { + return; + } + logger.debug("Sending request for method {} with id {}", method, requestId); + logger.trace("Outgoing request method='{}' id={} params={}", method, requestId, params); + AcpSchema.JSONRPCRequest request = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, requestId, + method, params); + this.transport.sendMessage(request).contextWrite(ctx).subscribe(v -> { + }, error -> { + written.tryEmitError(error); + this.pendingResponses.abandon(requestId, responseSink); + responseSink.error(error); + }, written::tryEmitEmpty); + Object trigger = ctx.getOrDefault(RequestCancellation.KEY, null); + if (trigger instanceof Publisher publisher) { + gracefulCancel.update(cancelOn(publisher, requestId, cancel)); + } + }).doFinally(signal -> { + gracefulCancel.dispose(); + answered.run(); + }) + .flatMap(answer -> this.responseOrder.after(sentAt, answer)); + }); + return withTimeout(response, timeout) + .handle((answer, resultSink) -> deliver(method, answer, typeRef, resultSink)); + } + + private static Mono withTimeout(Mono response, @Nullable Duration timeout) { + return (timeout != null) ? AcpSchedulers.withTimeout(response, timeout) : response; + } + + /** Runs {@code cancel} when a RequestCancellation trigger fires while the request still waits. */ + private Disposable cancelOn(Publisher trigger, String requestId, Runnable cancel) { + return Flux.from(trigger).take(1).then().subscribe(v -> { + }, error -> logger.debug("Ignored a failed cancel trigger for request {}", requestId), () -> { + if (this.pendingResponses.isPending(requestId)) { + cancel.run(); + } + }); + } + + private void deliver(String method, AcpSchema.JSONRPCResponse response, TypeRef typeRef, + SynchronousSink resultSink) { + AcpSchema.JSONRPCError error = response.error(); + if (error != null) { + // The caller's failure is the report; the error's data is the peer's payload. + logger.debug("Request {} failed with peer error {}", method, error.code()); + resultSink.error(new AcpError(error)); + } + else if (typeRef.getType().equals(Void.class)) { + resultSink.complete(); + } + else { + ResponseResults.deliver(method, response.result(), typeRef, this.transport, resultSink); + } + } + + /** + * Tells the peer that the caller gave up on a request (ACP v1 {@code $/cancel_request}): + * its subscription was cancelled, by the caller or by its timeout, before a response. + * Sent once the request itself has been handed to the transport, and not at all if that + * failed. Fire and forget: the transport may be why the caller gave up. + */ + private void cancelRequest(String requestId, Mono written) { + written.then(sendNotification(AcpSchema.METHOD_CANCEL_REQUEST, new AcpSchema.CancelRequestNotification(requestId))) + // Off the thread that cancelled, which may be the shared timeout timer: the + // transport's emission can wait for a busy outbound sink. + .subscribeOn(AcpSchedulers.timeoutDelivery()) + .subscribe(v -> { + }, error -> logger.debug("No $/cancel_request sent for id {}: {}", requestId, error.getMessage()), + () -> logger.debug("Sent $/cancel_request for id {}", requestId)); + } + + /** Sends a notification, or fails at once when the transport cannot deliver. */ + Mono sendNotification(String method, @Nullable Object params) { + return Mono.defer(() -> { + Throwable failure = this.transportFailure.get(); + if (failure != null) { + return Mono.error(this.unavailable.apply(failure)); + } + return this.transport + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, method, params)); + }); + } + + /** Completes the request this response answers. */ + void complete(AcpSchema.JSONRPCResponse response) { + this.pendingResponses.complete(response); + } + + /** Fails every request still waiting for a response. */ + void dismissPending(@Nullable Throwable cause) { + this.pendingResponses.dismissAll(cause); + } + + /** The number of requests waiting for a response. */ + int pendingRequests() { + return this.pendingResponses.size(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PendingResponses.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PendingResponses.java new file mode 100644 index 00000000..1c213da6 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PendingResponses.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; +import java.util.function.Supplier; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.MonoSink; + +/** + * The requests a session sent that are waiting for their response, keyed by request id. + * + *

    + * Each waiting request is completed at most once, by whichever comes first: its response + * ({@link #complete}), the end of the session ({@link #dismissAll}), or the request giving up + * (its timeout or cancellation disposes its sink, which removes it). Every path removes the + * entry before signalling it, so two paths never both signal one request, and no path drops an + * entry without signalling it. + *

    + * + *

    + * A request whose caller gave up before any signal (its subscription was cancelled, by the + * caller or by its timeout) is reported once to its cancel callback, which tells the peer + * ({@code $/cancel_request}). Only the cancel callback can still find the entry then: every + * other path removed it first, so a request that was answered, dismissed or failed to send + * is never reported. The peer still answers a cancelled request; that late response is + * expected and is discarded quietly. + *

    + */ +final class PendingResponses { + + private static final Logger logger = LoggerFactory.getLogger(PendingResponses.class); + + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + + /** The reason the transport cannot deliver (it failed to connect or start, or terminated), or null. */ + private final Supplier<@Nullable Throwable> transportFailure; + + /** Wraps the transport failure into the error a caller receives. */ + private final Function unavailable; + + /** Names the peer in the error a dismissed request fails with ("agent", "client"). */ + private final String peer; + + /** How many cancelled ids are remembered, so their late responses are not reported. */ + static final int REMEMBERED_CANCELLATIONS = 1024; + + /** + * The most recently cancelled ids, whose late response is expected. Bounded: a peer that + * never answers a cancelled request must not grow it. + */ + private final Set recentlyCancelled = Collections.synchronizedSet(Collections + .newSetFromMap(new LinkedHashMap(16, 0.75f, false) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > REMEMBERED_CANCELLATIONS; + } + })); + + PendingResponses(Supplier<@Nullable Throwable> transportFailure, Function unavailable, + String peer) { + this.transportFailure = transportFailure; + this.unavailable = unavailable; + this.peer = peer; + } + + /** + * Records the request as waiting for its response, or fails it at once when the + * transport cannot deliver. A sink that is disposed before its response arrives (the + * request timed out or was cancelled) is removed, so it does not stay here until the + * session ends; one cancelled before any signal is reported to {@code onCancelled}. + * @return whether the request was registered and should be sent + */ + boolean register(Object requestId, MonoSink sink, Runnable onCancelled) { + Throwable failure = this.transportFailure.get(); + if (failure != null) { + sink.error(this.unavailable.apply(failure)); + return false; + } + this.pending.put(requestId, sink); + AtomicBoolean registered = new AtomicBoolean(); + AtomicBoolean cancelledEarly = new AtomicBoolean(); + // A subscriber that cancelled before the request was created runs these callbacks + // as they are installed, before the flag is set: the request is dropped unsent, and + // the peer, which never saw it, is not told to cancel it. A cancel after the flag + // is set is reported. Only a cancellation is: a request that failed (its send + // threw, say) is disposed too, after its error signal. Reactor runs onCancel before + // onDispose, so the entry is still here on a cancel. + sink.onCancel(() -> { + if (!registered.get()) { + cancelledEarly.set(true); + } + else { + // Remembered before the removal, so a response racing the cancel finds + // either the entry or the memory, and is never reported as unknown. + this.recentlyCancelled.add(requestId); + if (this.pending.remove(requestId, sink)) { + onCancelled.run(); + } + else { + this.recentlyCancelled.remove(requestId); + } + } + }); + sink.onDispose(() -> this.pending.remove(requestId, sink)); + registered.set(true); + if (cancelledEarly.get()) { + return false; + } + // Re-check after registering: a failure recorded between the check above and the + // put may already have dismissed the map without seeing this request. + Throwable lateFailure = this.transportFailure.get(); + if (lateFailure != null) { + if (this.pending.remove(requestId, sink)) { + sink.error(this.unavailable.apply(lateFailure)); + } + return false; + } + return true; + } + + /** Forgets the request without signalling it: it failed to send. */ + void abandon(Object requestId, MonoSink sink) { + this.pending.remove(requestId, sink); + } + + /** Completes the request this response answers. */ + void complete(AcpSchema.JSONRPCResponse response) { + logger.debug("Received response for id {}", response.id()); + AcpSchema.JSONRPCError error = response.error(); + if (response.id() == null && error != null) { + // JSON-RPC 2.0: the answer to a message whose id could not be read. The peer's + // message and data are its own text: logged at DEBUG only. + logger.warn("The {} reported an error for a message it could not read: {} {}", this.peer, error.code(), + AcpErrorCodes.getDescription(error.code())); + logger.debug("The {}'s error: {} {}", this.peer, error.message(), error.data()); + return; + } + if (response.id() == null) { + // A stray response: it names no request, so it answers nothing this side sent. + logger.warn("Discarded a response from the {} without an id: it answers no request", this.peer); + return; + } + MonoSink sink = this.pending.remove(response.id()); + if (sink == null) { + if (this.recentlyCancelled.remove(response.id())) { + logger.debug("Discarded the response to cancelled request {}", response.id()); + } + else { + logger.warn("Unexpected response for unknown id {}", response.id()); + } + } + else { + logger.trace("Completing pending response for id {}", response.id()); + sink.success(response); + } + } + + /** + * Fails every request still waiting for a response with an {@link AcpConnectionException}, + * whose cause, when there is one, is why the session ended. Each entry is removed before it is + * failed: a request registered while this runs is either failed here or stays waiting + * for its response, never dropped unsignalled. + */ + void dismissAll(@Nullable Throwable cause) { + this.recentlyCancelled.clear(); + for (Object requestId : this.pending.keySet()) { + MonoSink sink = this.pending.remove(requestId); + if (sink != null) { + logger.warn("Abruptly terminating exchange for request {}", requestId); + String message = "ACP session with " + this.peer + " terminated"; + sink.error((cause != null) ? new AcpConnectionException(message, cause) + : new AcpConnectionException(message)); + } + } + } + + /** Whether this request still waits for its response. */ + boolean isPending(Object requestId) { + return this.pending.containsKey(requestId); + } + + /** The number of requests waiting for a response. */ + int size() { + return this.pending.size(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptAnswer.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptAnswer.java new file mode 100644 index 00000000..c9ba3b03 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptAnswer.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.concurrent.atomic.AtomicReference; + +import org.jspecify.annotations.Nullable; + +/** + * Who answers one {@code session/prompt}: its handler, or the session when a deadline passes + * first (the cancel grace period, or the maximum prompt duration). Each claim is one atomic + * step, so exactly one of them answers, however the handler's answer and the timers race. + * + *
    + * RUNNING --requestCancel--> CANCELLING
    + * RUNNING | CANCELLING --handler / max duration--> ANSWERED
    + * CANCELLING --grace period--> ANSWERED
    + * 
    + */ +final class PromptAnswer { + + /** What a deadline that won the claim answers with. */ + enum Forced { + + /** Stop reason {@code cancelled}: the prompt was cancelled (ACP v1, prompt turn, Cancellation). */ + CANCELLED, + + /** Error {@code -32800}: the prompt ran past the maximum prompt duration, uncancelled. */ + EXPIRED + + } + + private enum State { + + RUNNING, CANCELLING, ANSWERED + + } + + private final AtomicReference state = new AtomicReference<>(State.RUNNING); + + /** + * A {@code session/cancel} arrived for this prompt. + * @return true the first time, while the prompt is unanswered: the grace period starts + */ + boolean requestCancel() { + return this.state.compareAndSet(State.RUNNING, State.CANCELLING); + } + + boolean isCancelling() { + return this.state.get() == State.CANCELLING; + } + + /** Whether the prompt has been answered, by its handler or by a deadline. */ + boolean isAnswered() { + return this.state.get() == State.ANSWERED; + } + + /** + * The handler answered (or failed). + * @return whether its answer is the prompt's answer; false when a deadline answered first + */ + boolean handlerAnswered() { + return claim() != null; + } + + /** + * The cancel grace period passed. + * @return whether the session answers {@link Forced#CANCELLED}; false when the prompt + * was answered first + */ + boolean graceExpired() { + return this.state.compareAndSet(State.CANCELLING, State.ANSWERED); + } + + /** + * The maximum prompt duration passed. + * @return what the session answers with, or null when the prompt was answered first. A + * prompt that is being cancelled is answered {@link Forced#CANCELLED}, as a cancelled + * prompt must be. + */ + @Nullable Forced maxDurationExpired() { + State previous = claim(); + if (previous == null) { + return null; + } + return previous == State.CANCELLING ? Forced.CANCELLED : Forced.EXPIRED; + } + + /** Moves to ANSWERED, returning the state it left, or null when it was already answered. */ + private @Nullable State claim() { + while (true) { + State current = this.state.get(); + if (current == State.ANSWERED) { + return null; + } + if (this.state.compareAndSet(current, State.ANSWERED)) { + return current; + } + } + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptDeadlines.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptDeadlines.java new file mode 100644 index 00000000..eee52f77 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptDeadlines.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.Disposables; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; + +/** + * Enforces {@link PromptTimeouts} on a prompt: the handler's answer is the prompt's answer, + * unless a deadline passes first, in which case the handler's subscription is cancelled and + * the session answers. {@link PromptAnswer} decides which, so exactly one answer is given. + * + *

    + * The forced answer leaves through the same pipeline as the handler's would have: the turn + * ends just before it is published, and it is queued on the transport behind every message + * the handler had already sent, its {@code session/update}s included. A handler that keeps + * running after its subscription is cancelled (a blocking sync handler does) and sends more + * updates through its prompt context has them dropped: before each update the context asks the + * prompt's turn, which counts as answered once this {@link PromptAnswer} is claimed or the turn + * has ended, and ACP requires a prompt's updates to precede its answer. + *

    + */ +final class PromptDeadlines { + + private static final Logger logger = LoggerFactory.getLogger(PromptDeadlines.class); + + private final PromptTimeouts timeouts; + + /** Emits once after the given delay, off the timer's thread. */ + private final Function> timer; + + PromptDeadlines(PromptTimeouts timeouts, Function> timer) { + this.timeouts = timeouts; + this.timer = timer; + } + + /** + * The prompt's response: {@code handled}'s, or the one a deadline forces. + * @param turn the prompt's turn, through which a cancel starts the grace period + * @param request the prompt request + * @param handled the handler's response + */ + Mono answer(ActivePrompts.Turn turn, AcpSchema.JSONRPCRequest request, + Mono handled) { + if (!this.timeouts.hasCancelGracePeriod() && !this.timeouts.hasMaxPromptDuration()) { + return handled; + } + return Mono.create(sink -> { + PromptAnswer answer = turn.answer(); + // Disposed once the response is out or the prompt is cancelled downstream: the + // timers, and the handler's subscription when a deadline answered. + Disposable.Composite resources = Disposables.composite(); + sink.onDispose(resources); + if (this.timeouts.hasCancelGracePeriod()) { + startGracePeriodOnCancel(turn, request, sink, resources); + } + if (this.timeouts.hasMaxPromptDuration()) { + resources.add(after(this.timeouts.maxPromptDuration(), () -> maxDurationExpired(answer, request, sink))); + } + resources.add(handled.contextWrite(sink.contextView()) + .subscribe(response -> handlerAnswered(answer, () -> sink.success(response)), + error -> handlerAnswered(answer, () -> sink.error(error)))); + }); + } + + private static void handlerAnswered(PromptAnswer answer, Runnable publish) { + if (answer.handlerAnswered()) { + publish.run(); + } + } + + /** Starts the grace period when a cancel arrives, or at once if one already has; once. */ + private void startGracePeriodOnCancel(ActivePrompts.Turn turn, AcpSchema.JSONRPCRequest request, + MonoSink sink, Disposable.Composite resources) { + AtomicBoolean started = new AtomicBoolean(); + turn.onCancelRequested(() -> { + if (started.compareAndSet(false, true)) { + resources.add(after(this.timeouts.cancelGracePeriod(), () -> graceExpired(turn.answer(), request, sink))); + } + }); + } + + private void graceExpired(PromptAnswer answer, AcpSchema.JSONRPCRequest request, + MonoSink sink) { + if (answer.graceExpired()) { + logger.warn("Prompt {} not answered {} after session/cancel; answering cancelled", request.id(), + this.timeouts.cancelGracePeriod()); + sink.success(cancelled(request)); + } + } + + private void maxDurationExpired(PromptAnswer answer, AcpSchema.JSONRPCRequest request, + MonoSink sink) { + PromptAnswer.Forced forced = answer.maxDurationExpired(); + if (forced == null) { + return; + } + logger.warn("Prompt {} ran past maxPromptDuration {}; answering {}", request.id(), + this.timeouts.maxPromptDuration(), forced); + sink.success(forced == PromptAnswer.Forced.CANCELLED ? cancelled(request) : expired(request)); + } + + private Disposable after(Duration delay, Runnable action) { + return this.timer.apply(delay).subscribe(tick -> action.run()); + } + + private static AcpSchema.JSONRPCResponse cancelled(AcpSchema.JSONRPCRequest request) { + return InboundMessages.result(request, new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + } + + private AcpSchema.JSONRPCResponse expired(AcpSchema.JSONRPCRequest request) { + Duration max = this.timeouts.maxPromptDuration(); + return InboundMessages.error(request, AcpErrorCodes.REQUEST_CANCELLED, + "Prompt exceeded maxPromptDuration of " + max, Map.of("maxPromptDuration", max.toString())); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptTimeouts.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptTimeouts.java new file mode 100644 index 00000000..bd983b13 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/PromptTimeouts.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.util.Assert; + +/** + * The two time limits an agent puts on a prompt turn: the cancel grace period, how long a prompt + * handler has to answer after {@code session/cancel} before the SDK answers {@code cancelled} for + * it, and the maximum prompt duration, after which the SDK ends the turn with {@code -32800}. Set + * them with {@code cancelGracePeriod} and {@code maxPromptDuration} on + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.AsyncAgentBuilder}, + * {@link com.agentclientprotocol.sdk.agent.AcpAgent.SyncAgentBuilder} or + * {@code AcpAgentSupport.Builder}; this record holds the values and the defaults + * ({@link #DEFAULTS}). {@link Duration#ZERO} turns a limit off. Both limits are Java SDK policy; + * ACP defines neither. + * + *

    They exist because ACP requires every cancelled prompt to be answered with stop reason + * {@code cancelled} and a session runs one prompt turn at a time: a handler that never answers + * would keep its session busy for good. Whichever limit passes, the SDK cancels the handler (a sync + * handler's thread is interrupted) and sends the answer itself; exactly one answer goes to each + * prompt, and the session updates the handler sent before it reach the client first. A handler + * should stop when cancelled: updates it sends through its prompt context after the SDK's answer + * are dropped, since ACP requires a prompt's updates to precede its answer. + * + *

      + *
    • The cancel grace period starts when {@code session/cancel} arrives, not with the prompt. When + * it passes, the answer is stop reason {@code cancelled}, which ends the turn. A handler that + * answers or fails within it is answered {@code cancelled} too, whatever it returned. On by + * default, 60 seconds ({@link #DEFAULT_CANCEL_GRACE_PERIOD}).
    • + *
    • The maximum prompt duration starts with the prompt. When it passes, the answer is error + * {@code -32800} (request cancelled; ACP v1, Cancellation: an internally cancelled request, such as + * one that hit an internal timeout, answers as if the request had been cancelled), with message + * {@code "Prompt exceeded maxPromptDuration of "} and data + * {@code {"maxPromptDuration": ""}}; or stop reason {@code cancelled} when the + * prompt is already being cancelled. Off by default: a prompt turn can legitimately run for a long + * time.
    • + *
    + * + * @param cancelGracePeriod how long a prompt handler has to answer after {@code session/cancel}; + * zero for no limit + * @param maxPromptDuration how long a prompt may run in all; zero for no limit + */ +public record PromptTimeouts(Duration cancelGracePeriod, Duration maxPromptDuration) { + + /** The default cancel grace period: 60 seconds. */ + public static final Duration DEFAULT_CANCEL_GRACE_PERIOD = Duration.ofSeconds(60); + + /** The defaults: a 60 second cancel grace period and no maximum prompt duration. */ + public static final PromptTimeouts DEFAULTS = new PromptTimeouts(DEFAULT_CANCEL_GRACE_PERIOD, Duration.ZERO); + + /** + * No limits: the SDK never answers a prompt itself, so a handler that never answers keeps its + * session busy. + */ + public static final PromptTimeouts NONE = new PromptTimeouts(Duration.ZERO, Duration.ZERO); + + /** + * Creates the limits. + * @param cancelGracePeriod how long a prompt handler has to answer after + * {@code session/cancel}; zero for no limit + * @param maxPromptDuration how long a prompt may run in all; zero for no limit + * @throws IllegalArgumentException if either is null or negative + */ + public PromptTimeouts { + Assert.notNull(cancelGracePeriod, "cancelGracePeriod must not be null"); + Assert.notNull(maxPromptDuration, "maxPromptDuration must not be null"); + Assert.isTrue(!cancelGracePeriod.isNegative(), "cancelGracePeriod must not be negative"); + Assert.isTrue(!maxPromptDuration.isNegative(), "maxPromptDuration must not be negative"); + } + + /** + * Returns a copy with another cancel grace period. + * @param cancelGracePeriod the grace period; zero for none + * @return the new limits + * @throws IllegalArgumentException if {@code cancelGracePeriod} is null or negative + */ + public PromptTimeouts withCancelGracePeriod(Duration cancelGracePeriod) { + return new PromptTimeouts(cancelGracePeriod, this.maxPromptDuration); + } + + /** + * Returns a copy with another maximum prompt duration. + * @param maxPromptDuration the maximum duration; zero for none + * @return the new limits + * @throws IllegalArgumentException if {@code maxPromptDuration} is null or negative + */ + public PromptTimeouts withMaxPromptDuration(Duration maxPromptDuration) { + return new PromptTimeouts(this.cancelGracePeriod, maxPromptDuration); + } + + boolean hasCancelGracePeriod() { + return !this.cancelGracePeriod.isZero(); + } + + boolean hasMaxPromptDuration() { + return !this.maxPromptDuration.isZero(); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequestCancellation.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequestCancellation.java new file mode 100644 index 00000000..9f644e75 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequestCancellation.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import org.reactivestreams.Publisher; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +/** + * Cancels a request the SDK sends while still waiting for the peer's answer: write + * {@link #cancelWhen} into the request's Reactor context, and when its trigger fires the SDK sends + * {@code $/cancel_request} for the request, which then ends with whatever the peer answers. Use it + * when the answer still matters after cancelling, for example to see whether the work finished + * first; to give up on a request at once, dispose its subscription instead. It works with the + * request methods of the asynchronous API: {@code AcpAsyncClient} on the client side, + * {@code AcpAsyncAgent} and {@code PromptContext} on the agent side. ACP v1 calls this graceful + * cancellation ("the calling side MAY implement graceful cancellation processing by waiting for the + * response"). + * + *

    When the trigger emits a value or completes while the request waits for its answer, the + * session sends {@code $/cancel_request} once the request has been written, and keeps waiting. The + * request then ends with the peer's answer: its result, or an {@link AcpError} with code + * {@code -32800} ({@code AcpErrorCodes.REQUEST_CANCELLED}). A Java peer cancels the handler and + * answers {@code -32800} unless the handler answered first; for a {@code session/prompt} that the + * agent is already cancelling under {@code session/cancel}, it answers stop reason + * {@code cancelled} instead. If the peer never answers, the request still ends at its timeout, if + * it has one. A trigger that fails is ignored and cancels nothing. Every request sent within the + * subscription that carries the context is cancelled on the trigger, and at most one + * {@code $/cancel_request} is sent per request, whichever way it is cancelled. + * + *

    Disposing a request's subscription, directly or through a timeout, also sends + * {@code $/cancel_request}, but stops listening: the peer's answer is discarded. To stop a prompt + * turn the way ACP intends, with {@code session/cancel}, pass a + * {@link com.agentclientprotocol.sdk.client.CancellationSignal} to the client's + * {@code prompt(request, stop)} (async or sync), or call {@code AcpAsyncClient.cancel(..)}; + * this class cancels any one request, in either direction. The sync API cannot carry a trigger: + * {@code AcpSyncClient.async()} gives the asynchronous client to use it with. + * + *

    {@code
    + * Sinks.Empty stop = Sinks.empty();
    + * Mono answer = client.prompt(request)
    + *     .contextWrite(RequestCancellation.cancelWhen(stop.asMono()));
    + * // when the user presses Stop:
    + * stop.tryEmitEmpty();
    + * }
    + */ +public final class RequestCancellation { + + /** The context key of the cancel trigger. */ + static final Object KEY = RequestCancellation.class; + + private RequestCancellation() { + } + + /** + * Returns a Reactor context that cancels every request sent under it, gracefully, when + * {@code trigger} emits its first value or completes. + * @param trigger the cancel trigger, any Reactive Streams publisher, such as a {@code Mono} or + * a {@code Sinks.Empty}'s {@code asMono()}; must not be null + * @return the context, for {@code contextWrite} + */ + public static ContextView cancelWhen(Publisher trigger) { + return Context.of(KEY, trigger); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequiredFields.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequiredFields.java new file mode 100644 index 00000000..898773c9 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/RequiredFields.java @@ -0,0 +1,92 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.RecordComponent; +import java.util.Collection; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +/** + * Finds a required field missing from inbound params or from a response's result. A schema + * record's component without {@code @Nullable} is one the ACP schema lists as + * {@code required}; Jackson reads an absent one as null, which a handler or caller would only + * find when it fails on it. Only the schema's records + * ({@link AcpSchema}) are checked: an application's own record, such as an extension method's + * params, has no schema behind it, and its nullness is its own business. + */ +final class RequiredFields { + + private RequiredFields() { + } + + /** + * The JSON path of the first required field that is null, looking into nested records + * and collections of records; null when none is. + */ + static @Nullable String firstMissing(@Nullable Object value) { + return firstMissing(value, ""); + } + + private static @Nullable String firstMissing(@Nullable Object value, String path) { + if (value instanceof Collection collection) { + return firstMissingInElements(collection, path); + } + if (value == null || !isSchemaRecord(value.getClass())) { + return null; + } + for (RecordComponent component : value.getClass().getRecordComponents()) { + String missing = firstMissingInComponent(component, value, path); + if (missing != null) { + return missing; + } + } + return null; + } + + private static boolean isSchemaRecord(Class type) { + return type.isRecord() && type.getName().startsWith(AcpSchema.class.getName() + "$"); + } + + private static @Nullable String firstMissingInElements(Collection collection, String path) { + int index = 0; + for (Object element : collection) { + String missing = firstMissing(element, path + "[" + index++ + "]"); + if (missing != null) { + return missing; + } + } + return null; + } + + private static @Nullable String firstMissingInComponent(RecordComponent component, Object record, String path) { + String name = path.isEmpty() ? jsonName(component) : path + "." + jsonName(component); + Object field = read(component, record); + if (field != null) { + return firstMissing(field, name); + } + boolean optional = component.getAnnotatedType().isAnnotationPresent(Nullable.class) + || component.getType().isPrimitive(); + return optional ? null : name; + } + + private static String jsonName(RecordComponent component) { + JsonProperty property = component.getAnnotation(JsonProperty.class); + return (property != null && !property.value().isEmpty()) ? property.value() : component.getName(); + } + + private static @Nullable Object read(RecordComponent component, Object record) { + try { + return component.getAccessor().invoke(record); + } + catch (IllegalAccessException | InvocationTargetException e) { + // A record nested in a type this package cannot reach: no check rather than a failure. + return null; + } + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseOrder.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseOrder.java new file mode 100644 index 00000000..706d4ab2 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseOrder.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import reactor.core.publisher.Mono; + +/** + * Where a session's responses take their place among the notifications it receives: a + * response can be held until the notifications that arrived before it have been handled. + */ +interface ResponseOrder { + + /** Responses complete their caller as they arrive. */ + ResponseOrder IMMEDIATE = new ResponseOrder() { + + @Override + public long position() { + return 0; + } + + @Override + public Mono after(long sentAt, R response) { + return Mono.just(response); + } + + }; + + /** + * How many notifications have arrived so far; read when a request is sent, and passed + * back to {@link #after} with its response. + */ + long position(); + + /** + * The response, once it may complete its caller. + * @param sentAt the {@link #position} when the request was sent + * @param response the response, which has just arrived + * @return a Mono emitting the response when its caller may have it + */ + Mono after(long sentAt, R response); + + /** + * Records that a request sent at {@code sentAt} is waiting for its response, so that what + * the peer asks meanwhile is not held behind a handler that may be the one waiting. + * @param sentAt the {@link #position} when the request was sent + * @return run once the request no longer waits (answered, failed or cancelled) + */ + default Runnable awaiting(long sentAt) { + return () -> { + }; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseResults.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseResults.java new file mode 100644 index 00000000..d4c73fb4 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/ResponseResults.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.SynchronousSink; + +/** + * Delivers the result of a successful JSON-RPC response to the caller of + * {@link AcpSession#sendRequest}, the same way for both session sides. + */ +final class ResponseResults { + + private ResponseResults() { + } + + /** + * Delivers a success response's result. A null or missing result reads as {@code {}} + * for a {@link AcpSchema.DefaultOnNull} response type, completes empty for an + * extension ({@code _}-prefixed) method, whose result may legally be null, and + * otherwise fails the request. A result that cannot be read as the type, or lacks a + * field the schema requires, fails the request too. Each failure is an {@link AcpError} + * with -32603 (internal error), as a peer's error response is: JSON-RPC 2.0 defines no + * code for an invalid response, and the failure is the caller's, not the peer's to be + * told about. + */ + static void deliver(String method, @Nullable Object result, TypeRef typeRef, AcpTransport transport, + SynchronousSink sink) { + if (result != null) { + T value; + try { + value = transport.unmarshalFrom(result, typeRef); + } + catch (IllegalArgumentException e) { + sink.error(AcpError.rejectedResponse(method, "unreadable-result", + "The response to " + method + " could not be read: " + firstLine(e.getMessage()), + Map.of())); + return; + } + String missing = RequiredFields.firstMissing(value); + if (missing != null) { + sink.error(AcpError.rejectedResponse(method, "missing-required-field", + "The response to " + method + " lacks the required field " + missing, + Map.of("field", missing))); + return; + } + sink.next(value); + } + else if (AcpSchema.DefaultOnNull.class.isAssignableFrom(rawClass(typeRef.getType()))) { + sink.next(transport.unmarshalFrom(Map.of(), typeRef)); + } + else if (ExtensionMethods.isExtension(method)) { + sink.complete(); + } + else { + sink.error(AcpError.rejectedResponse(method, "missing-result", + "The response to " + method + " carried no result", Map.of())); + } + } + + private static String firstLine(@Nullable String message) { + if (message == null) { + return "the result does not fit the method's result type"; + } + int end = message.indexOf('\n'); + return end < 0 ? message : message.substring(0, end); + } + + private static Class rawClass(Type type) { + if (type instanceof Class clazz) { + return clazz; + } + if (type instanceof ParameterizedType parameterized) { + return rawClass(parameterized.getRawType()); + } + return Object.class; + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/SyncCalls.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/SyncCalls.java new file mode 100644 index 00000000..7cfb9488 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/SyncCalls.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; + +import com.agentclientprotocol.sdk.error.AcpException; +import com.agentclientprotocol.sdk.error.AcpTimeoutException; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import org.jspecify.annotations.Nullable; +import reactor.core.Exceptions; +import reactor.core.publisher.Mono; + +/** + * How the SDK's sync API ({@code AcpSyncClient}, {@code AcpSyncAgent}, {@code SyncPromptContext}) + * blocks on a {@code Mono}: failures reach the caller as the SDK's documented exceptions, never as + * Reactor's internal wrapper. A timeout becomes an {@link AcpTimeoutException} whose cause is the + * {@link TimeoutException}; an interrupt of the waiting thread (the SDK interrupts a sync handler + * to cancel it) becomes a {@link CancellationException}, with the thread's interrupt flag set + * again; any other checked cause becomes an {@link AcpException}. Runtime failures pass through + * unchanged. + * Use it to block on the asynchronous API with the same rules. + */ +public final class SyncCalls { + + private SyncCalls() { + } + + /** + * Blocks until {@code mono} completes. + * @param the value type + * @param mono what to wait for + * @return the value, or null if it completed empty + */ + public static @Nullable T block(Mono mono) { + return block(mono, null); + } + + /** + * Blocks until {@code mono} completes, at most {@code timeout}; when that passes the + * {@code Mono} is cancelled and the call throws {@link AcpTimeoutException}. + * @param the value type + * @param mono what to wait for + * @param timeout the longest to wait, or null for no limit of its own + * @return the value, or null if it completed empty + */ + public static @Nullable T block(Mono mono, @Nullable Duration timeout) { + try { + return (timeout != null) ? AcpSchedulers.withTimeout(mono, timeout).block() : mono.block(); + } + catch (RuntimeException failure) { + throw translate(failure); + } + } + + private static RuntimeException translate(RuntimeException failure) { + Throwable cause = Exceptions.unwrap(failure); + if (cause instanceof TimeoutException timeout) { + return new AcpTimeoutException(timeout); + } + if (cause instanceof InterruptedException) { + Thread.currentThread().interrupt(); + CancellationException cancelled = new CancellationException( + "Interrupted while waiting for an answer"); + cancelled.initCause(cause); + return cancelled; + } + if (cause instanceof RuntimeException runtime) { + return runtime; + } + return new AcpException(cause.toString(), cause); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/package-info.java new file mode 100644 index 00000000..1338fa98 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/spec/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The ACP protocol: the schema records (AcpSchema), the JSON-RPC sessions, and the transport contracts. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.spec; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/AcpSchedulers.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/AcpSchedulers.java index 878bc5a5..b3dfddae 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/AcpSchedulers.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/AcpSchedulers.java @@ -7,6 +7,7 @@ import java.time.Duration; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeoutException; import reactor.core.publisher.Mono; @@ -17,11 +18,15 @@ * Library-owned schedulers shared by every session in the JVM. * *

    - * Timeouts only need a timer: one daemon thread serves every {@code AcpClientSession} and - * {@code AcpAgentSession}. Sessions used to create a scheduled pool each, which over a - * listener transport (one agent session per remote connection) meant one idle thread per - * connection. The shared scheduler is never disposed; its thread is a daemon and does not - * keep the JVM alive. + * Timeouts only need a timer: one daemon thread ({@code acp-timeout}) serves every + * {@code AcpClientSession} and {@code AcpAgentSession}, and the listener's keep-alive. + * Sessions used to create a scheduled pool each, which over a listener transport (one agent + * session per remote connection) meant one idle thread per connection. The shared scheduler is + * never disposed; its thread is a daemon and does not keep the JVM alive. It stays a platform + * thread even where virtual threads exist, so that handlers which pin every carrier thread + * cannot also stop the timeouts meant to end them. What a timeout triggers runs off the timer: + * on a virtual thread per task on JDK 21 and later, else on a cached pool of daemon threads + * ({@code acp-timeout-delivery}). *

    * * @author Mark Pollack @@ -34,13 +39,10 @@ private static final class TimeoutHolder { /** * Timeout errors are handed off the timer so a subscriber that blocks in its error - * handler cannot delay every other session's timeouts. + * handler cannot delay every other session's timeouts: a virtual thread per delivery + * where the JDK has them, else a cached pool of daemon threads. */ - static final Scheduler DELIVERY = Schedulers.fromExecutorService(Executors.newCachedThreadPool(r -> { - Thread t = new Thread(r, "acp-timeout-delivery"); - t.setDaemon(true); - return t; - }), "acp-timeout-delivery"); + static final Scheduler DELIVERY = deliveryScheduler(VirtualThreads.isSupported()); private static ScheduledThreadPoolExecutor timerExecutor() { ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, r -> { @@ -59,6 +61,22 @@ private static ScheduledThreadPoolExecutor timerExecutor() { private AcpSchedulers() { } + /** + * The delivery threads: a virtual thread per delivery, else a cached pool of daemon threads. + * Whether the JDK has virtual threads is given, so tests make the choice on any JDK. + */ + static Scheduler deliveryScheduler(boolean virtualThreads) { + if (virtualThreads) { + ThreadFactory virtual = VirtualThreads.factoryOrDaemon("acp-timeout-delivery"); + return Schedulers.fromExecutor(task -> virtual.newThread(task).start()); + } + return Schedulers.fromExecutorService(Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "acp-timeout-delivery"); + t.setDaemon(true); + return t; + }), "acp-timeout-delivery"); + } + /** * The shared scheduler for request timeouts. * @return a daemon-threaded scheduler that must not be disposed by callers @@ -67,9 +85,31 @@ public static Scheduler timeouts() { return TimeoutHolder.SCHEDULER; } + /** + * The threads timeouts are delivered on (virtual threads on JDK 21 and later, else daemon + * threads), for work a timeout triggers that must not + * run on (and so delay) the shared timer, such as telling the peer a request was given + * up on. + * @return a scheduler that must not be disposed by callers + */ + public static Scheduler timeoutDelivery() { + return TimeoutHolder.DELIVERY; + } + + /** + * Emits once after {@code delay}, timed on the shared timer and delivered on the same + * separate threads as a timeout, so what runs on it cannot delay other sessions' + * timeouts. Cancelling the subscription cancels the timer. + * @param delay how long to wait + * @return a Mono that emits {@code 0} after the delay + */ + public static Mono after(Duration delay) { + return Mono.delay(delay, TimeoutHolder.SCHEDULER).publishOn(TimeoutHolder.DELIVERY); + } + /** * Applies a timeout on the shared timer and delivers the resulting - * {@link TimeoutException} on a separate daemon thread. + * {@link TimeoutException} on a separate thread (see {@link #timeoutDelivery()}). * @param mono the source * @param timeout how long to wait for its first signal * @return the source with the timeout applied diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/Assert.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/Assert.java index e0bc0283..64c41426 100644 --- a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/Assert.java +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/Assert.java @@ -6,6 +6,8 @@ package com.agentclientprotocol.sdk.util; +import org.jspecify.annotations.Nullable; + /** * Assertion utility class that assists in validating arguments. Useful for identifying * programmer errors early and clearly at runtime. @@ -21,7 +23,7 @@ public abstract class Assert { * @param message the exception message to use if the assertion fails * @throws IllegalArgumentException if the object is {@code null} */ - public static void notNull(Object object, String message) { + public static void notNull(@Nullable Object object, String message) { if (object == null) { throw new IllegalArgumentException(message); } @@ -34,7 +36,7 @@ public static void notNull(Object object, String message) { * @param message the exception message to use if the assertion fails * @throws IllegalArgumentException if the text is empty */ - public static void hasText(String text, String message) { + public static void hasText(@Nullable String text, String message) { if (text == null || text.trim().isEmpty()) { throw new IllegalArgumentException(message); } diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/HandlerFailures.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/HandlerFailures.java new file mode 100644 index 00000000..8b8bbbcc --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/HandlerFailures.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.util.concurrent.Callable; +import java.util.function.Supplier; + +import reactor.core.publisher.Mono; + +/** + * Keeps a {@link Throwable} that is not an {@link Exception}, thrown by an application's + * request or notification handler, from escaping the SDK. + * + *

    + * Reactor rethrows what it calls JVM-fatal errors ({@link VirtualMachineError} and + * {@link LinkageError}, which includes the {@link NoSuchMethodError} of code compiled + * against another SDK version) instead of signalling them, so a handler that throws one + * left its request unanswered and the peer waiting out its timeout, and could unwind the + * thread that delivered the request. Every handler invocation is guarded here: an + * {@link Error} becomes a {@link HandlerError}, an ordinary exception that Reactor signals, + * which the session answers with {@code -32603} (Internal error) and logs at ERROR. + *

    + * + *

    + * A {@link VirtualMachineError} ({@link OutOfMemoryError}, {@link StackOverflowError}, + * {@link InternalError}) is not hidden: it is answered like any other error, as far as the + * JVM still can, and also handed to the uncaught-exception handler of the thread that ran + * the handler, which is where it would have gone had it escaped. JVM options that act when + * the error is thrown, such as {@code -XX:+ExitOnOutOfMemoryError}, are unaffected. + *

    + * + *

    + * What the SDK cannot reach: an asynchronous handler's {@code Mono} that throws a JVM-fatal + * error from inside one of its own operators after the handler returned. Reactor rethrows + * that on the thread that emitted, outside the handler call. + *

    + */ +public final class HandlerFailures { + + private HandlerFailures() { + } + + /** + * An {@link Error} a handler threw, carried as an exception so it is signalled like one. + * Its message is the error's type only: the error's own message can carry details the + * peer must not see, and the cause keeps it for the log. + */ + public static final class HandlerError extends RuntimeException { + + private static final long serialVersionUID = 1L; + + HandlerError(Throwable error) { + super(error.getClass().getName(), error); + } + + } + + /** + * Calls a handler that returns a {@code Mono}: what it throws, an {@link Error} included, + * becomes the returned {@code Mono}'s error instead of escaping. + * @param the result type + * @param handler the handler call + * @return the handler's {@code Mono}, or one failing with what it threw + */ + public static Mono invoke(Supplier> handler) { + return Mono.defer(() -> { + try { + return handler.get(); + } + catch (Throwable failure) { + return Mono.error(contain(failure)); + } + }); + } + + /** + * A blocking handler call that throws an {@link Error} as a {@link HandlerError}. + * @param the result type + * @param handler the handler call + * @return the guarded call + */ + public static Callable guard(Callable handler) { + return () -> { + try { + return handler.call(); + } + catch (Exception e) { + throw e; + } + catch (Throwable failure) { + throw (HandlerError) contain(failure); + } + }; + } + + /** + * A blocking handler call that throws an {@link Error} as a {@link HandlerError}. + * @param handler the handler call + * @return the guarded call + */ + public static Runnable guard(Runnable handler) { + return () -> { + try { + handler.run(); + } + catch (RuntimeException e) { + throw e; + } + catch (Throwable failure) { + throw (HandlerError) contain(failure); + } + }; + } + + /** + * A handler's failure as something Reactor signals: an exception as it is, any other + * throwable as a {@link HandlerError}. A {@link VirtualMachineError} is also handed to + * the current thread's uncaught-exception handler. + * @param failure what the handler threw or signalled + * @return the failure to signal + */ + public static Throwable contain(Throwable failure) { + if (failure instanceof Exception) { + return failure; + } + if (failure instanceof VirtualMachineError) { + Thread thread = Thread.currentThread(); + try { + thread.getUncaughtExceptionHandler().uncaughtException(thread, failure); + } + catch (RuntimeException ignored) { + // The handler's own failure must not stop the request being answered. + } + } + return new HandlerError(failure); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/OutboundSinks.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/OutboundSinks.java new file mode 100644 index 00000000..55db1ed2 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/OutboundSinks.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.time.Duration; +import java.util.function.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The outbound sink of a message transport: one sink that the transport's writer drains and + * that several threads emit on, the inbound thread with the replies to inbound requests and + * user threads with their own requests and notifications. + * + * @author Mark Pollack + */ +public final class OutboundSinks { + + private static final Logger logger = LoggerFactory.getLogger(OutboundSinks.class); + + /** How long an emission keeps retrying while another thread is emitting on the sink. */ + private static final Duration BUSY_LOOP = Duration.ofMillis(100); + + private OutboundSinks() { + } + + /** + * Emits a message on an outbound sink, retrying while another thread is emitting on it: + * a plain emission that collides with another fails ({@code FAIL_NON_SERIALIZED}) and + * the message is lost (#14). + * @param sink the outbound sink + * @param message the message + * @param the message type + * @throws Sinks.EmissionException if the sink still refuses the message after retrying, + * or is terminated ({@code FAIL_TERMINATED}: the transport is closed) or cancelled + */ + public static void emit(Sinks.Many sink, T message) { + // Not emitNext: on a terminated sink it drops the value silently instead of failing. + long deadline = System.nanoTime() + BUSY_LOOP.toNanos(); + while (true) { + Sinks.EmitResult result = sink.tryEmitNext(message); + if (result.isSuccess()) { + return; + } + if (result != Sinks.EmitResult.FAIL_NON_SERIALIZED || System.nanoTime() >= deadline) { + throw new Sinks.EmissionException(result, "Could not emit the message: " + result); + } + Thread.onSpinWait(); + } + } + + /** + * Whether an emission failed because the sink is terminated or cancelled: the transport + * was closed. + * @param failure the failure {@link #emit} threw + * @return {@code true} if the sink no longer accepts messages + */ + public static boolean isClosed(Sinks.EmissionException failure) { + return failure.getReason() == Sinks.EmitResult.FAIL_TERMINATED + || failure.getReason() == Sinks.EmitResult.FAIL_CANCELLED; + } + + /** + * Passes each inbound message to the session's handler and emits the handler's reply, + * when there is one, on the outbound sink. A reply the sink refuses is logged and + * dropped. When the inbound messages end, the outbound sink is completed and + * {@code afterTermination} runs. + * @param inbound the inbound sink + * @param handler the session's message handler + * @param outbound the outbound sink + * @param afterTermination what else the transport releases when the inbound side ends + * @param the message type + * @return the subscription + */ + public static Disposable replyThrough(Sinks.Many inbound, Function, Mono> handler, + Sinks.Many outbound, Runnable afterTermination) { + return inbound.asFlux() + .flatMap(message -> Mono.just(message).transform(handler)) + .doOnNext(reply -> { + try { + emit(outbound, reply); + } + catch (Sinks.EmissionException e) { + logger.error("Dropped a response: {}", e.getReason()); + logger.debug("Dropped response: {}", reply); + } + }) + .doOnTerminate(() -> { + outbound.tryEmitComplete(); + afterTermination.run(); + }) + .subscribe(ignored -> { + }, error -> logger.warn("Inbound message processing ended with an error", error)); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/PlatformThreads.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/PlatformThreads.java new file mode 100644 index 00000000..d9694b4d --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/PlatformThreads.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * The SDK's pools of platform threads, for where virtual threads are absent (JDK 17) or not + * wanted (an application that has not opted into them). + * + *

    Internal to the SDK; not part of its API. + */ +public final class PlatformThreads { + + private PlatformThreads() { + } + + /** + * Returns a new cached pool of daemon threads named {@code name}: a thread per task running + * at once, each ending after a minute idle, none keeping the JVM alive. + * @param name the name of every thread of the pool + * @return the pool + */ + public static ExecutorService newCachedPool(String name) { + return Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + }); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/SerialExecutor.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/SerialExecutor.java new file mode 100644 index 00000000..d8fa0b39 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/SerialExecutor.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Runs the tasks given to it one at a time, in the order given, on the threads of another + * executor, which may run tasks concurrently. A transport uses it in place of a thread of its + * own when the application supplies the executor: its writes stay in order, and a reader that + * loops until its stream ends holds one of the executor's threads, not one of the SDK's. The + * delegate is not shut down here: whoever created it owns it. + * + *

    Internal to the SDK; not part of its API. + */ +public final class SerialExecutor implements Executor { + + private static final Logger logger = LoggerFactory.getLogger(SerialExecutor.class); + + private final Executor delegate; + + private final Queue tasks = new ConcurrentLinkedQueue<>(); + + /** Tasks queued and not yet run; the one that raises it from zero starts the drain. */ + private final AtomicInteger pending = new AtomicInteger(); + + /** + * Creates a serial executor on {@code delegate}. + * @param delegate the executor whose threads run the tasks + */ + public SerialExecutor(Executor delegate) { + Assert.notNull(delegate, "The executor can not be null"); + this.delegate = delegate; + } + + /** + * Queues the task, and starts running the queue on the delegate unless it already runs. + * @throws RejectedExecutionException if the delegate refuses to run the queue; the task is + * then dropped + */ + @Override + public void execute(Runnable task) { + Assert.notNull(task, "The task can not be null"); + tasks.offer(task); + if (pending.getAndIncrement() == 0) { + try { + delegate.execute(this::drain); + } + catch (RejectedExecutionException e) { + tasks.clear(); + pending.set(0); + throw e; + } + } + } + + private void drain() { + do { + Runnable task = tasks.poll(); + if (task != null) { + try { + task.run(); + } + catch (Throwable error) { + // One failed task must not stop the ones queued behind it. + logger.warn("Task failed on serial executor", error); + } + } + } + while (pending.decrementAndGet() != 0); + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/VirtualThreads.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/VirtualThreads.java new file mode 100644 index 00000000..3c1e0311 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/VirtualThreads.java @@ -0,0 +1,165 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +import org.jspecify.annotations.Nullable; + +/** + * Virtual threads where the JDK has them (21 and later), without needing JDK 21 to compile: + * the SDK targets Java 17, so it reaches {@code Thread.ofVirtual()} and + * {@code Executors.newThreadPerTaskExecutor} reflectively. On JDK 21 to 23 a virtual thread + * that blocks inside a {@code synchronized} block pins its carrier thread; JDK 24 removed that + * (JEP 491). + * + *

    Internal to the SDK; not part of its API. + */ +public final class VirtualThreads { + + /** {@code Thread.ofVirtual().name(String).factory()}, or null before JDK 21. */ + private static final @Nullable MethodHandle NAMED_FACTORY = namedFactory(); + + private static final @Nullable MethodHandle IS_VIRTUAL = isVirtualHandle(); + + /** {@code Executors.newThreadPerTaskExecutor(ThreadFactory)}, or null before JDK 21. */ + private static final @Nullable MethodHandle PER_TASK = perTaskHandle(); + + private VirtualThreads() { + } + + /** + * Returns whether this JDK has virtual threads. + * @return true on JDK 21 and later + */ + public static boolean isSupported() { + return isSupported(NAMED_FACTORY); + } + + /* + * Each decision below takes the handles it decides on, so tests make both choices on any + * JDK: the coverage gate requires every branch of this class covered on JDK 17 and on 21. + */ + + static boolean isSupported(@Nullable MethodHandle namedFactory) { + return namedFactory != null; + } + + /** + * Returns whether {@code thread} is a virtual thread. + * @param thread the thread + * @return true for a virtual thread; always false before JDK 21 + */ + public static boolean isVirtual(Thread thread) { + return isVirtual(IS_VIRTUAL, thread); + } + + static boolean isVirtual(@Nullable MethodHandle isVirtual, Thread thread) { + if (isVirtual == null) { + return false; + } + try { + return (boolean) isVirtual.invokeExact(thread); + } + catch (Throwable e) { + return false; + } + } + + /** + * Returns a factory of virtual threads named {@code name} on JDK 21 and later, else of + * daemon platform threads named {@code name}. Either kind keeps no JVM alive. + * @param name the name of every thread the factory makes + * @return the factory + */ + public static ThreadFactory factoryOrDaemon(String name) { + return factoryOrDaemon(NAMED_FACTORY, name); + } + + static ThreadFactory factoryOrDaemon(@Nullable MethodHandle namedFactory, String name) { + if (namedFactory != null) { + try { + return (ThreadFactory) namedFactory.invoke(name); + } + catch (Throwable e) { + // Fall through to platform threads. + } + } + return runnable -> { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + }; + } + + /** + * Returns an executor that starts a virtual thread named {@code name} for each task, as + * {@code Executors.newVirtualThreadPerTaskExecutor()} does, on JDK 21 and later; null + * before. Shutting it down interrupts its running tasks on {@code shutdownNow()}. + * @param name the name of every thread it starts + * @return the executor, or null when the JDK has no virtual threads + */ + public static @Nullable ExecutorService newPerTaskExecutor(String name) { + return newPerTaskExecutor(PER_TASK, NAMED_FACTORY, name); + } + + static @Nullable ExecutorService newPerTaskExecutor(@Nullable MethodHandle perTask, + @Nullable MethodHandle namedFactory, String name) { + if (perTask == null || namedFactory == null) { + return null; + } + try { + return (ExecutorService) perTask.invoke(factoryOrDaemon(namedFactory, name)); + } + catch (Throwable e) { + return null; + } + } + + private static @Nullable MethodHandle perTaskHandle() { + try { + return MethodHandles.publicLookup() + .findStatic(Executors.class, "newThreadPerTaskExecutor", + MethodType.methodType(ExecutorService.class, ThreadFactory.class)); + } + catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + + private static @Nullable MethodHandle namedFactory() { + try { + MethodHandles.Lookup lookup = MethodHandles.publicLookup(); + Class builder = Class.forName("java.lang.Thread$Builder"); + Class ofVirtual = Class.forName("java.lang.Thread$Builder$OfVirtual"); + MethodHandle create = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(ofVirtual)); + MethodHandle name = lookup.findVirtual(ofVirtual, "name", MethodType.methodType(ofVirtual, String.class)); + MethodHandle factory = lookup.findVirtual(builder, "factory", MethodType.methodType(ThreadFactory.class)); + // name -> Thread.ofVirtual().name(name).factory() + MethodHandle named = MethodHandles.collectArguments(name, 0, create); + return MethodHandles.filterReturnValue(named, + factory.asType(MethodType.methodType(ThreadFactory.class, ofVirtual))); + } + catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + + private static @Nullable MethodHandle isVirtualHandle() { + try { + return MethodHandles.publicLookup() + .findVirtual(Thread.class, "isVirtual", MethodType.methodType(boolean.class)); + } + catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + +} diff --git a/acp-core/src/main/java/com/agentclientprotocol/sdk/util/package-info.java b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/package-info.java new file mode 100644 index 00000000..f574d2e6 --- /dev/null +++ b/acp-core/src/main/java/com/agentclientprotocol/sdk/util/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Internal utilities: argument assertions and the SDK's Reactor schedulers. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.util; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/AcpTestFixtures.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/AcpTestFixtures.java index 62b0a280..27a70df1 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/AcpTestFixtures.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/AcpTestFixtures.java @@ -111,10 +111,8 @@ public static AcpSchema.InitializeResponse createInitializeResponse() { * Creates a NewSessionResponse with test session ID. * @return NewSessionResponse with default test session */ - @SuppressWarnings("removal") public static AcpSchema.NewSessionResponse createNewSessionResponse() { - return new AcpSchema.NewSessionResponse("test-session-id", createSessionModeState(), - createSessionModelState()); + return new AcpSchema.NewSessionResponse("test-session-id", createSessionModeState()); } /** @@ -122,9 +120,8 @@ public static AcpSchema.NewSessionResponse createNewSessionResponse() { * @param sessionId the session ID * @return NewSessionResponse with specified session ID */ - @SuppressWarnings("removal") public static AcpSchema.NewSessionResponse createNewSessionResponse(String sessionId) { - return new AcpSchema.NewSessionResponse(sessionId, createSessionModeState(), createSessionModelState()); + return new AcpSchema.NewSessionResponse(sessionId, createSessionModeState()); } /** @@ -139,9 +136,8 @@ public static AcpSchema.PromptResponse createPromptResponse() { * Creates a LoadSessionResponse with default session state. * @return LoadSessionResponse with test data */ - @SuppressWarnings("removal") public static AcpSchema.LoadSessionResponse createLoadSessionResponse() { - return new AcpSchema.LoadSessionResponse(createSessionModeState(), createSessionModelState()); + return new AcpSchema.LoadSessionResponse(createSessionModeState()); } /** @@ -238,15 +234,6 @@ public static AcpSchema.SessionModeState createSessionModeState() { return new AcpSchema.SessionModeState("code", List.of(mode)); } - /** - * Creates a SessionModelState with test model. - * @return SessionModelState with default model - */ - @SuppressWarnings("removal") - public static AcpSchema.SessionModelState createSessionModelState() { - AcpSchema.ModelInfo model = new AcpSchema.ModelInfo("test-model", "Test Model", "Test model description"); - return new AcpSchema.SessionModelState("test-model", List.of(model)); - } // --------------------------- // JSON-RPC Factories diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/ArchitectureTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/ArchitectureTest.java new file mode 100644 index 00000000..dd4c0312 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/ArchitectureTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.dependencies.SliceAssignment; +import com.tngtech.archunit.library.dependencies.SliceIdentifier; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The package structure of acp-core, as it is and as it must stay. + * + *
    + *   client, client.transport     agent, agent.transport
    + *                  \                /
    + *                   capabilities
    + *                        |
    + *            protocol: spec + error
    + *                   /          \
    + *                json          util
    + * 
    + * + *

    + * {@code spec} and {@code error} form one layer, the protocol vocabulary, but dependencies + * between them run one way: {@code spec} uses {@code error} (sessions raise protocol + * exceptions; {@code JSONRPCError} converts to and from them), never the reverse. + *

    + * + *

    + * The rules see acp-core's main classes only: {@link ThisModule} imports the directory or + * jar that holds them, so a module that scans this test (the JSON modules do) still checks + * acp-core and not itself. + *

    + */ +@AnalyzeClasses(locations = ArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + + @ArchTest + static final ArchRule isFrameworkNeutral = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("org.springframework..", "io.quarkus..", "io.micronaut..", + "io.vertx..", "io.smallrye..", "jakarta.enterprise..", "jakarta.inject..", + "com.agentclientprotocol.sdk.spring..", "com.agentclientprotocol.sdk.micronaut..", + "com.agentclientprotocol.sdk.quarkus..", + "com.agentclientprotocol.sdk.integration..") + .as("a framework or a framework integration")) + .because("framework integrations build on the SDK, never the reverse"); + @ArchTest + static void importsAcpCoreAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(AcpSchema.class)).as("acp-core's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from acp-core's main classes"); + } + + @ArchTest + static final ArchRule layers = layeredArchitecture().consideringOnlyDependenciesInLayers() + .layer("Util").definedBy("com.agentclientprotocol.sdk.util..") + .layer("Json").definedBy("com.agentclientprotocol.sdk.json..") + .layer("Protocol").definedBy("com.agentclientprotocol.sdk.spec..", "com.agentclientprotocol.sdk.error..") + .layer("Capabilities").definedBy("com.agentclientprotocol.sdk.capabilities..") + .layer("Client").definedBy("com.agentclientprotocol.sdk.client..") + .layer("Agent").definedBy("com.agentclientprotocol.sdk.agent..") + .whereLayer("Client").mayNotBeAccessedByAnyLayer() + .whereLayer("Agent").mayNotBeAccessedByAnyLayer() + .whereLayer("Capabilities").mayOnlyBeAccessedByLayers("Client", "Agent") + .whereLayer("Protocol").mayOnlyBeAccessedByLayers("Capabilities", "Client", "Agent") + .whereLayer("Json").mayOnlyBeAccessedByLayers("Protocol", "Capabilities", "Client", "Agent") + .because("clients and agents share the protocol layer but never each other; the JSON SPI and " + + "utilities sit underneath everything"); + + @ArchTest + static final ArchRule noPackageCycles = slices().matching("com.agentclientprotocol.sdk.(*)..") + .should() + .beFreeOfCycles(); + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule coreUsesOnlyJacksonAnnotations = noClasses().that() + .resideInAPackage("com.agentclientprotocol.sdk..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("com.fasterxml.jackson.databind..", "com.fasterxml.jackson.core..", "tools.jackson..") + .because("acp-core carries only Jackson annotations; a JSON implementation lives in acp-json-jackson2 " + + "or acp-json-jackson3 behind AcpJsonMapper (#12)"); + + /** The session implementations: request correlation, handler dispatch, timeouts. */ + static final DescribedPredicate SESSION_IMPLEMENTATIONS = belongToAnyOf(AcpClientSession.class, + AcpAgentSession.class) + .as("the session implementations"); + + /** + * A client transport moves JSON-RPC messages for an {@code AcpClientSession}; it is built + * from the {@code spec} transport interface and the schema, and knows nothing of the client + * or agent runtime, nor of the session that drives it. + */ + @ArchTest + static final ArchRule clientTransportsSeeOnlyTheProtocol = noClasses().that() + .resideInAPackage("com.agentclientprotocol.sdk.client.transport..") + .should() + .dependOnClassesThat(resideInAnyPackage("com.agentclientprotocol.sdk.client", + "com.agentclientprotocol.sdk.agent..", "com.agentclientprotocol.sdk.capabilities..") + .as("client or agent runtime classes") + .or(SESSION_IMPLEMENTATIONS)) + .because("a transport sits beneath the session and the runtime that use it"); + + static final DescribedPredicate AGENT_RUNTIME_BEYOND_THE_CONNECTION_SEAM = resideInAPackage( + "com.agentclientprotocol.sdk.agent") + .and(not(belongToAnyOf(AcpAgentFactory.class, AcpAsyncAgent.class))) + .or(resideInAnyPackage("com.agentclientprotocol.sdk.client..", "com.agentclientprotocol.sdk.capabilities..")) + .as("client or agent runtime classes other than AcpAgentFactory and AcpAsyncAgent"); + + /** + * An agent transport moves JSON-RPC messages for an {@code AcpAgentSession}. A listener-backed + * transport also creates one agent per accepted connection, so it may use the public + * per-connection seam ({@link AcpAgentFactory} and the {@link AcpAsyncAgent} it returns), + * and nothing else of the agent runtime: not the {@code Default*} implementations, not the + * sessions, not the client. + */ + @ArchTest + static final ArchRule agentTransportsSeeOnlyTheProtocolAndTheConnectionSeam = noClasses().that() + .resideInAPackage("com.agentclientprotocol.sdk.agent.transport..") + .should() + .dependOnClassesThat(AGENT_RUNTIME_BEYOND_THE_CONNECTION_SEAM.or(SESSION_IMPLEMENTATIONS)) + .because("a transport sits beneath the session and the runtime that use it"); + + /** + * Inside each package no two top-level classes depend on each other, directly or through + * others (nested classes count as their top-level class). The one package left out is + * {@code json}: {@code AcpJsonMapper.createDefault()} finds the default mapper through + * {@code AcpJsonMapperSelector} and the {@code AcpJsonMapperSupplier} SPI, which supplies + * {@code AcpJsonMapper}s, a cycle that is the public default-lookup API itself. + */ + @ArchTest + static final ArchRule noClassCycles = slices() + .assignedFrom(topLevelClassesIn("com.agentclientprotocol.sdk.spec", "com.agentclientprotocol.sdk.error", + "com.agentclientprotocol.sdk.util", "com.agentclientprotocol.sdk.capabilities", + "com.agentclientprotocol.sdk.client", "com.agentclientprotocol.sdk.client.transport", + "com.agentclientprotocol.sdk.agent", "com.agentclientprotocol.sdk.agent.transport")) + .should() + .beFreeOfCycles(); + + static SliceAssignment topLevelClassesIn(String... packages) { + List names = Arrays.asList(packages); + return new SliceAssignment() { + + @Override + public SliceIdentifier getIdentifierOf(JavaClass javaClass) { + if (!names.contains(javaClass.getPackageName())) { + return SliceIdentifier.ignore(); + } + JavaClass topLevel = javaClass; + while (topLevel.getEnclosingClass().isPresent()) { + topLevel = topLevel.getEnclosingClass().get(); + } + return SliceIdentifier.of(topLevel.getName()); + } + + @Override + public String getDescription() { + return "top-level classes of " + names; + } + + }; + } + + /** Imports the directory or jar holding acp-core's main classes. */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = AcpSchema.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/CapturedLogs.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/CapturedLogs.java new file mode 100644 index 00000000..8f7a2bfd --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/CapturedLogs.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.util.List; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import org.jspecify.annotations.Nullable; +import org.slf4j.LoggerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Captures every event the SDK's loggers emit while open, to check what a log at INFO or + * above would carry: the formatted message and each exception's message, causes included. + */ +public final class CapturedLogs implements AutoCloseable { + + private final Logger logger = (Logger) LoggerFactory.getLogger("com.agentclientprotocol.sdk"); + + private final ListAppender appender = new ListAppender<>(); + + private CapturedLogs() { + this.appender.start(); + this.logger.addAppender(this.appender); + } + + public static CapturedLogs open() { + return new CapturedLogs(); + } + + public List events() { + return List.copyOf(this.appender.list); + } + + /** Asserts that no event at INFO or above, nor any exception logged with one, contains {@code text}. */ + public void assertNoneAtInfoOrAboveContains(String text) { + assertThat(events()).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .allSatisfy(event -> { + assertThat(event.getFormattedMessage()).doesNotContain(text); + for (IThrowableProxy proxy = event.getThrowableProxy(); proxy != null; proxy = proxy.getCause()) { + assertThat(messageOf(proxy)).as("exception logged with: %s", event.getFormattedMessage()) + .doesNotContain(text); + } + }); + } + + private static String messageOf(IThrowableProxy proxy) { + @Nullable String message = proxy.getMessage(); + return message == null ? "" : message; + } + + @Override + public void close() { + this.logger.detachAppender(this.appender); + this.appender.stop(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/CloseFailureLoggingTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/CloseFailureLoggingTest.java new file mode 100644 index 00000000..d04367c6 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/CloseFailureLoggingTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpTransport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A close that fails is reported as the failure it is: not as a timeout that never happened, + * and not through Reactor's "Operator called default onErrorDropped" ERROR. + */ +class CloseFailureLoggingTest { + + private final ListAppender logs = new ListAppender<>(); + + private final List loggers = List.of((Logger) LoggerFactory.getLogger(AcpSyncClient.class), + (Logger) LoggerFactory.getLogger(AcpTransport.class)); + + private final List dropped = new CopyOnWriteArrayList<>(); + + @BeforeEach + void capture() { + this.logs.start(); + this.loggers.forEach(logger -> logger.addAppender(this.logs)); + Hooks.onErrorDropped(this.dropped::add); + } + + @AfterEach + void release() { + Hooks.resetOnErrorDropped(); + this.loggers.forEach(logger -> logger.detachAppender(this.logs)); + this.logs.stop(); + } + + @Test + void aSyncClientWhoseCloseFailsLogsTheFailureNotATimeout() { + AcpSyncClient client = AcpClient.sync(new FailingCloseTransport()).build(); + + assertThat(client.closeGracefully()).isFalse(); + + assertThat(warnings()).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).doesNotContain("timeout").contains("Output closed"); + }); + } + + @Test + void aTransportCloseThatFailsIsLoggedNotDropped() { + AcpTransport transport = new FailingCloseTransport(); + + transport.close(); + + assertThat(this.dropped).isEmpty(); + assertThat(warnings()).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("Output closed"); + }); + } + + private List warnings() { + return this.logs.list.stream().filter(event -> event.getLevel().isGreaterOrEqual(Level.WARN)).toList(); + } + + /** A transport whose graceful close fails at once, as a WebSocket closed twice did. */ + private static final class FailingCloseTransport extends MockAcpClientTransport { + + @Override + public Mono closeGracefully() { + return Mono.error(new IOException("Output closed")); + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/EntryPointFieldsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/EntryPointFieldsTest.java new file mode 100644 index 00000000..fc8d4463 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/EntryPointFieldsTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.lang.reflect.Field; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.client.AcpClient; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The entry-point interfaces expose no logging API: a field of an interface is public, so a + * {@code Logger} there was API (and a hard SLF4J type on the SDK's front door). + */ +class EntryPointFieldsTest { + + @Test + void theEntryPointInterfacesHaveNoLoggerField() { + for (Class type : List.of(AcpAgent.class, AcpClient.class)) { + for (Field field : type.getDeclaredFields()) { + assertThat(field.getType().getName()).as(type.getSimpleName() + "." + field.getName()) + .isNotEqualTo("org.slf4j.Logger"); + } + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/GeminiCliIT.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/GeminiCliIT.java index 1d5911ca..431047fe 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/GeminiCliIT.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/GeminiCliIT.java @@ -61,10 +61,10 @@ void testGeminiAgentWithHighLevelClient() throws Exception { // Create transport StdioAcpClientTransport transport = new StdioAcpClientTransport(params, jsonMapper); - // Build client with session update consumer + // Build client with session update handler AcpAsyncClient client = AcpClient.async(transport) .requestTimeout(Duration.ofSeconds(30)) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { logger.info("Received session update: sessionId={}, updateType={}", notification.sessionId(), notification.update().getClass().getSimpleName()); return Mono.empty(); @@ -76,7 +76,7 @@ void testGeminiAgentWithHighLevelClient() throws Exception { // Initialize AcpSchema.InitializeResponse initResponse = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) + .initialize() .block(); assertThat(initResponse).isNotNull(); @@ -135,8 +135,12 @@ void testToolCallWithFileRead() throws Exception { // Build client with handlers for session updates and permissions AcpAsyncClient client = AcpClient.async(transport) + .clientCapabilities(new AcpSchema.ClientCapabilities( + new AcpSchema.FileSystemCapability(true, false), // read only + true // permissions + )) .requestTimeout(Duration.ofSeconds(60)) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { AcpSchema.SessionUpdate update = notification.update(); String updateType = update.getClass().getSimpleName(); updateTypeCounts.computeIfAbsent(updateType, k -> new AtomicInteger(0)).incrementAndGet(); @@ -167,7 +171,7 @@ else if (update instanceof AcpSchema.AgentMessageChunk chunk) { // Find the allow_once option or use first option String optionId = request.options().stream() - .filter(opt -> opt.kind() == AcpSchema.PermissionOptionKind.ALLOW_ONCE) + .filter(opt -> AcpSchema.PermissionOptionKind.ALLOW_ONCE.equals(opt.kind())) .findFirst() .map(AcpSchema.PermissionOption::optionId) .orElse(request.options().get(0).optionId()); @@ -192,10 +196,7 @@ else if (update instanceof AcpSchema.AgentMessageChunk chunk) { try { // Initialize AcpSchema.InitializeResponse initResponse = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(true, false), // read only - true // permissions - ))) + .initialize() .block(); assertThat(initResponse).isNotNull(); @@ -260,7 +261,7 @@ void testMultiplePromptsInSameSession() throws Exception { AcpAsyncClient client = AcpClient.async(transport) .requestTimeout(Duration.ofSeconds(30)) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { allUpdates.add(notification.update()); return Mono.empty(); }) @@ -269,7 +270,7 @@ void testMultiplePromptsInSameSession() throws Exception { try { // Initialize AcpSchema.InitializeResponse initResponse = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) + .initialize() .block(); assertThat(initResponse).isNotNull(); @@ -339,7 +340,7 @@ void testCancelDuringPrompt() throws Exception { AcpAsyncClient client = AcpClient.async(transport) .requestTimeout(Duration.ofSeconds(60)) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { updateCount.incrementAndGet(); logger.info("Update received: {}", notification.update().getClass().getSimpleName()); return Mono.empty(); @@ -348,7 +349,7 @@ void testCancelDuringPrompt() throws Exception { try { // Initialize - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(); + client.initialize().block(); // Create session AcpSchema.NewSessionResponse sessionResponse = client @@ -419,8 +420,12 @@ void testFileWriteRequest() throws Exception { logger.info("Using temp directory: {}", tempDir); AcpAsyncClient client = AcpClient.async(transport) + .clientCapabilities(new AcpSchema.ClientCapabilities( + new AcpSchema.FileSystemCapability(true, true), // read and write + true // permissions + )) .requestTimeout(Duration.ofSeconds(60)) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { logger.info("Update: {}", notification.update().getClass().getSimpleName()); return Mono.empty(); }) @@ -428,7 +433,7 @@ void testFileWriteRequest() throws Exception { .requestPermissionHandler((AcpSchema.RequestPermissionRequest request) -> { logger.info("Permission request: {}", request.toolCall().title()); String optionId = request.options().stream() - .filter(opt -> opt.kind() == AcpSchema.PermissionOptionKind.ALLOW_ONCE) + .filter(opt -> AcpSchema.PermissionOptionKind.ALLOW_ONCE.equals(opt.kind())) .findFirst() .map(AcpSchema.PermissionOption::optionId) .orElse(request.options().get(0).optionId()); @@ -472,10 +477,7 @@ void testFileWriteRequest() throws Exception { try { // Initialize with write capability - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(true, true), // read and write - true // permissions - ))).block(); + client.initialize().block(); // Create session in temp directory AcpSchema.NewSessionResponse sessionResponse = client diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/MockAcpClientTransport.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/MockAcpClientTransport.java index d27227c1..4470fdfb 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/MockAcpClientTransport.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/MockAcpClientTransport.java @@ -37,7 +37,7 @@ public class MockAcpClientTransport implements AcpClientTransport { private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); - private final List sent = new ArrayList<>(); + private final List sent = new java.util.concurrent.CopyOnWriteArrayList<>(); private final BiConsumer interceptor; diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/PinnedVirtualThreads.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/PinnedVirtualThreads.java new file mode 100644 index 00000000..408c4a98 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/PinnedVirtualThreads.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.util.VirtualThreads; +import jdk.jfr.Recording; +import jdk.jfr.consumer.RecordedEvent; +import jdk.jfr.consumer.RecordedFrame; +import jdk.jfr.consumer.RecordingFile; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Runs a task on a virtual thread under a JFR recording and returns the + * {@code jdk.VirtualThreadPinned} events it caused: each is a park of the virtual thread + * that held its carrier, as a park inside a {@code synchronized} block does on JDK 21 to + * 23. A park that pins every carrier while the lock it waits for belongs to an unmounted + * virtual thread deadlocks the scheduler, so code that runs on virtual threads must not + * park inside a monitor. Skips the calling test where the JDK has no virtual threads. + */ +public final class PinnedVirtualThreads { + + private PinnedVirtualThreads() { + } + + /** + * Runs {@code task} on a virtual thread and returns one line per pinned park, the + * event's top frames. + * @param task the code under test; it should park (sleep, wait for a lock) where the + * code under test may + * @return the pinned parks, empty when the task never pinned its carrier + * @throws Exception if the task fails or the recording cannot be read + */ + public static List pinnedParks(ThrowingRunnable task) throws Exception { + assumeTrue(VirtualThreads.isSupported(), "virtual threads need JDK 21 or later"); + Path file = Files.createTempFile("pinned", ".jfr"); + try (Recording recording = new Recording()) { + recording.enable("jdk.VirtualThreadPinned").withThreshold(Duration.ZERO).withStackTrace(); + recording.start(); + ExecutorService executor = VirtualThreads.newPerTaskExecutor("pinning-probe"); + try { + Future run = executor.submit(() -> { + task.run(); + return null; + }); + run.get(30, TimeUnit.SECONDS); + } + finally { + executor.shutdownNow(); + } + recording.stop(); + recording.dump(file); + return RecordingFile.readAllEvents(file) + .stream() + .filter(event -> event.getEventType().getName().equals("jdk.VirtualThreadPinned")) + .map(PinnedVirtualThreads::describe) + .collect(Collectors.toList()); + } + finally { + Files.deleteIfExists(file); + } + } + + private static String describe(RecordedEvent event) { + if (event.getStackTrace() == null) { + return "pinned park (no stack)"; + } + return event.getStackTrace() + .getFrames() + .stream() + .limit(12) + .map(PinnedVirtualThreads::frame) + .collect(Collectors.joining(" <- ")); + } + + private static String frame(RecordedFrame frame) { + return frame.getMethod().getType().getName() + "." + frame.getMethod().getName(); + } + + /** A task that may throw. */ + @FunctionalInterface + public interface ThrowingRunnable { + + /** + * Runs the task. + * @throws Exception on failure + */ + void run() throws Exception; + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/QuietLoggers.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/QuietLoggers.java new file mode 100644 index 00000000..af373bc4 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/QuietLoggers.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk; + +import java.util.LinkedHashMap; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import org.jspecify.annotations.Nullable; +import org.slf4j.LoggerFactory; + +/** + * Turns the loggers of the classes under a Lincheck test off for its duration. The test + * configuration logs this SDK at DEBUG, and Lincheck model checks every instruction a + * thread runs, the appender's included: a log line inside an operation multiplies the + * interleavings to explore until the run looks hung. + */ +public final class QuietLoggers implements AutoCloseable { + + private final Map previous = new LinkedHashMap<>(); + + private QuietLoggers(Class... classes) { + for (Class type : classes) { + Logger logger = (Logger) LoggerFactory.getLogger(type); + previous.put(logger, logger.getLevel()); + logger.setLevel(Level.OFF); + } + } + + public static QuietLoggers of(Class... classes) { + return new QuietLoggers(classes); + } + + @Override + public void close() { + previous.forEach(Logger::setLevel); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/SchedulerBestPracticesTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/SchedulerBestPracticesTest.java index bb80d18e..6c47859f 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/SchedulerBestPracticesTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/SchedulerBestPracticesTest.java @@ -217,34 +217,19 @@ void executorCreationUsesDaemonThreads() throws IOException { } /** - * Verifies that AcpClient defines a library-owned scheduler with daemon threads. - * - *

    This positive test ensures the SYNC_HANDLER_SCHEDULER is properly configured. + * Verifies that the sync client's default handler pool is library-owned and uses daemon + * threads. */ @Test void acpClientHasDaemonScheduler() throws IOException { - Path acpClientPath = SOURCE_ROOT.resolve( - "com/agentclientprotocol/sdk/client/AcpClient.java"); - - assertThat(acpClientPath) - .describedAs("AcpClient.java should exist") - .exists(); - - String content = Files.readString(acpClientPath); + Path holder = SOURCE_ROOT.resolve("com/agentclientprotocol/sdk/client/SyncHandlerScheduler.java"); - // Verify SYNC_HANDLER_SCHEDULER is defined - assertThat(content) - .describedAs("AcpClient should define SYNC_HANDLER_SCHEDULER") - .contains("SYNC_HANDLER_SCHEDULER"); + assertThat(holder).describedAs("client/SyncHandlerScheduler.java should exist").exists(); - // Verify daemon thread configuration - assertThat(content) - .describedAs("SYNC_HANDLER_SCHEDULER should use daemon threads") - .contains("setDaemon(true)"); + String content = Files.readString(holder); - // Verify it uses fromExecutorService (library-owned) - assertThat(content) - .describedAs("SYNC_HANDLER_SCHEDULER should use Schedulers.fromExecutorService") + assertThat(content).describedAs("the default pool should use daemon threads").contains("setDaemon(true)"); + assertThat(content).describedAs("the default pool should use Schedulers.fromExecutorService") .contains("Schedulers.fromExecutorService"); } @@ -382,7 +367,7 @@ void noImplicitSchedulerTimeOperators() throws IOException { *

  • Configure the underlying API with a custom executor (e.g., HttpClient.newBuilder().executor(...))
  • * * - *

    Note: WebSocket transports are excluded because they configure HttpClient with a custom + *

    Note: the WebSocket client transport is excluded because it configures HttpClient with a custom * executor (acp-ws-client daemon threads), so their CompletableFutures don't use ForkJoinPool. */ @Test @@ -393,10 +378,9 @@ void noMonoFromFutureWithJdkCompletableFutures() throws IOException { Pattern pattern = Pattern.compile("Mono\\.fromFuture\\("); // Files that are known to use properly configured executors (not ForkJoinPool.commonPool) - // WebSocket transports configure HttpClient with custom executor, so they're safe + // The WebSocket client transport configures HttpClient with a custom executor, so it's safe List excludedFiles = List.of( - "WebSocketAcpClientTransport.java", - "WebSocketAcpAgentTransport.java" + "WebSocketAcpClientTransport.java" ); try (Stream paths = productionSources()) { diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java index fb91fc2c..b1c0ba3c 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentFactoryTest.java @@ -18,6 +18,7 @@ void asyncFactoryReturnsFreshAgentRuntime() { AcpAgentFactory factory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("session", null, null))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build()); AcpAsyncAgent first = factory.create(InMemoryTransportPair.create().agentTransport()); @@ -31,6 +32,7 @@ void syncFactoryAdaptsToAsyncRuntime() { AcpAgentFactory factory = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport) .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session", null, null)) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) .build()); AcpAsyncAgent agent = factory.create(InMemoryTransportPair.create().agentTransport()); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentTest.java index 8f1f33e8..6d1715a0 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AcpAgentTest.java @@ -16,6 +16,7 @@ import reactor.core.publisher.Mono; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Unit tests for {@link AcpAgent} factory and the high-level agent API. @@ -48,6 +49,33 @@ void asyncBuilderCreatesAgent() { } } + @Test + void callsToTheClientBeforeStartFailWithIllegalState() { + var transportPair = InMemoryTransportPair.create(); + try { + AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + List> calls = List.of( + agent.sendSessionUpdate("s", new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("hi"))), + agent.readTextFile(new AcpSchema.ReadTextFileRequest("s", "/f", null, null)), + agent.writeTextFile(new AcpSchema.WriteTextFileRequest("s", "/f", "x")), + agent.createTerminal(new AcpSchema.CreateTerminalRequest("s", "ls", null, null, null, null)), + agent.getTerminalOutput(new AcpSchema.TerminalOutputRequest("s", "t")), + agent.releaseTerminal(new AcpSchema.ReleaseTerminalRequest("s", "t")), + agent.waitForTerminalExit(new AcpSchema.WaitForTerminalExitRequest("s", "t")), + agent.killTerminal(new AcpSchema.KillTerminalCommandRequest("s", "t")), + agent.completeElicitation(new AcpSchema.CompleteElicitationNotification("e"))); + for (Mono call : calls) { + assertThatThrownBy(() -> call.block(TIMEOUT)).isInstanceOf(IllegalStateException.class) + .hasMessage("Agent not started"); + } + agent.closeGracefully().block(TIMEOUT); + agent.close(); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + @Test void syncBuilderCreatesAgent() { var transportPair = InMemoryTransportPair.create(); @@ -83,7 +111,7 @@ void agentHandlesInitializeRequest() throws Exception { return Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(true, null, null), List.of())); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); agent.start().block(TIMEOUT); Thread.sleep(100); @@ -130,7 +158,7 @@ void agentHandlesNewSessionRequest() throws Exception { receivedRequest.set(request); return Mono.just(new AcpSchema.NewSessionResponse("session-123", null, null)); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); agent.start().block(TIMEOUT); Thread.sleep(100); @@ -227,7 +255,7 @@ void agentHandlesCancelNotification() throws Exception { notificationLatch.countDown(); return Mono.empty(); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); agent.start().block(TIMEOUT); Thread.sleep(100); @@ -266,7 +294,7 @@ void syncAgentDelegatesAsyncOperations() { receivedRequest.set(request); return new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()); }) - .build(); + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()).build(); // Verify async() returns the underlying agent assertThat(agent.async()).isNotNull(); @@ -282,7 +310,7 @@ void syncAgentDelegatesAsyncOperations() { void agentCloseGracefullyCompletesWithoutError() throws Exception { var transportPair = InMemoryTransportPair.create(); - AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()).build(); + AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); agent.start().block(TIMEOUT); Thread.sleep(100); @@ -299,6 +327,7 @@ void syncAgentGetClientCapabilitiesReturnsNullBeforeInitialization() { AcpSyncAgent agent = AcpAgent.sync(transportPair.agentTransport()) .initializeHandler( request -> new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of())) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) .build(); // Before initialization, capabilities should be null @@ -318,6 +347,7 @@ void asyncAgentGetClientCapabilitiesReturnsNullBeforeInitialization() { AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()) .initializeHandler(request -> Mono .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build(); // Before initialization, capabilities should be null diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentAwareHandlerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentAwareHandlerTest.java new file mode 100644 index 00000000..a3e64d95 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentAwareHandlerTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A builder handler takes the agent it serves as a second parameter, so it can send session + * updates (a {@code session/load} replay, an update after a config change) without capturing the + * built agent in an {@code AtomicReference}. + */ +class AgentAwareHandlerTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + private final List updates = new CopyOnWriteArrayList<>(); + + @AfterEach + void close() { + pair.closeGracefully().block(TIMEOUT); + } + + private static String text(AcpSchema.SessionNotification notification) { + return (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) ? text.text() : "?"; + } + + private AcpSyncClient client() { + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(n -> updates.add(n.sessionId() + ":" + text(n))) + .build(); + client.initialize(); + return client; + } + + @Test + void asyncHandlerReceivesTheBuiltAgent() { + AtomicReference received = new AtomicReference<>(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .setSessionConfigOptionHandler((request, self) -> { + received.set(self); + return self + .sendSessionUpdate(request.sessionId(), + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("changed"))) + .thenReturn(new AcpSchema.SetSessionConfigOptionResponse(List.of())); + }) + .build(); + agent.start().block(TIMEOUT); + try (AcpSyncClient client = client()) { + client.setSessionConfigOption(AcpSchema.SetSessionConfigOptionRequest.select("s1", "model", "fast")); + + assertThat(received.get()).isSameAs(agent); + assertThat(updates).containsExactly("s1:changed"); + } + finally { + agent.close(); + } + } + + @Test + void syncHandlerReceivesTheBuiltAgentAndItsReplayPrecedesTheAnswer() { + AtomicReference received = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .loadSessionHandler((request, self) -> { + received.set(self); + self.sendSessionUpdate(request.sessionId(), + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("first"))); + self.sendSessionUpdate(request.sessionId(), + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("second"))); + return new AcpSchema.LoadSessionResponse(null); + }) + .build(); + agent.start(); + try (AcpSyncClient client = client()) { + client.loadSession(new AcpSchema.LoadSessionRequest("s2", "/w", List.of())); + + assertThat(received.get()).isSameAs(agent); + assertThat(updates).containsExactly("s2:first", "s2:second"); + } + finally { + agent.close(); + } + } + + /** + * The example on {@link AgentAwareHandler}, as written there: the built agent is assigned to + * {@code agent}, so the handler's agent parameter is {@code self}. Named {@code agent} it would + * not compile ("variable agent is already defined"). + */ + @Test + void asyncJavadocExampleAssignsTheAgentAndNamesTheParameterSelf() { + List options = List.of(); + AcpAgentTransport transport = pair.agentTransport(); + AcpAsyncAgent agent = AcpAgent.async(transport) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .setSessionConfigOptionHandler((request, self) -> self + .sendSessionUpdate(request.sessionId(), new AcpSchema.ConfigOptionUpdate(options)) + .thenReturn(new AcpSchema.SetSessionConfigOptionResponse(options))) + .build(); + agent.start().block(TIMEOUT); + try (AcpSyncClient client = client()) { + assertThat(client.setSessionConfigOption(AcpSchema.SetSessionConfigOptionRequest.select("s4", "model", "fast")) + .configOptions()).isEmpty(); + } + finally { + agent.close(); + } + } + + /** + * The example on {@link SyncAgentAwareHandler}, as written there: the built agent is assigned + * to {@code agent}, so the handler's agent parameter is {@code self}. + */ + @Test + void syncJavadocExampleAssignsTheAgentAndNamesTheParameterSelf() { + Map> history = Map.of("s5", + List.of(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("replayed")))); + AcpAgentTransport transport = pair.agentTransport(); + AcpSyncAgent agent = AcpAgent.sync(transport) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .loadSessionHandler((request, self) -> { + for (AcpSchema.SessionUpdate update : history.get(request.sessionId())) { + self.sendSessionUpdate(request.sessionId(), update); // the replay + } + return new AcpSchema.LoadSessionResponse(null, null); + }) + .build(); + agent.start(); + try (AcpSyncClient client = client()) { + client.loadSession(new AcpSchema.LoadSessionRequest("s5", "/w", List.of())); + + assertThat(updates).containsExactly("s5:replayed"); + } + finally { + agent.close(); + } + } + + @Test + void oneArgumentHandlersStillPickTheirOwnInterface() { + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("s3", null, null)) + .build(); + agent.start(); + try (AcpSyncClient client = client()) { + assertThat(client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).sessionId()).isEqualTo("s3"); + } + finally { + agent.close(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentBuilderRegistrationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentBuilderRegistrationTest.java new file mode 100644 index 00000000..962db4df --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/AgentBuilderRegistrationTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The agent builders fail fast on a handler that cannot work: a null handler at the setter + * (it used to be accepted and every request for that method was answered {@code -32603}), and a + * second handler for the same method (it used to replace the first silently). + */ +class AgentBuilderRegistrationTest { + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + /** Every typed {@code xxxHandler(handler)} setter of a builder: one functional parameter. */ + private static List typedSetters(Class builder) { + List setters = new ArrayList<>(); + for (Method method : builder.getMethods()) { + if (method.getName().endsWith("Handler") && method.getParameterCount() == 1 + && method.getReturnType() == builder) { + setters.add(method); + } + } + return setters; + } + + private static Throwable invokeWithNull(Object builder, Method setter) { + try { + setter.invoke(builder, new Object[] { null }); + return null; + } + catch (InvocationTargetException e) { + return e.getCause(); + } + catch (IllegalAccessException e) { + throw new IllegalStateException(e); + } + } + + @Test + void everyTypedSetterOfTheAsyncBuilderRejectsNull() { + List setters = typedSetters(AcpAgent.AsyncAgentBuilder.class); + assertThat(setters).hasSizeGreaterThan(15); + for (Method setter : setters) { + assertThat(invokeWithNull(AcpAgent.async(this.pair.agentTransport()), setter)).as(setter.getName()) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void everyTypedSetterOfTheSyncBuilderRejectsNull() { + List setters = typedSetters(AcpAgent.SyncAgentBuilder.class); + assertThat(setters).hasSizeGreaterThan(15); + for (Method setter : setters) { + assertThat(invokeWithNull(AcpAgent.sync(this.pair.agentTransport()), setter)).as(setter.getName()) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void aHandlerSetTwiceFailsNamingTheSetter() { + AcpAgent.AsyncAgentBuilder async = AcpAgent.async(this.pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())); + assertThatThrownBy( + () -> async.promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promptHandler") + .hasMessageContaining(AcpSchema.METHOD_SESSION_PROMPT); + + AcpAgent.SyncAgentBuilder sync = AcpAgent.sync(this.pair.agentTransport()) + .cancelHandler(notification -> { + }); + assertThatThrownBy(() -> sync.cancelHandler(notification -> { + })).isInstanceOf(IllegalStateException.class).hasMessageContaining("cancelHandler"); + } + + @Test + void anExtensionHandlerSetTwiceFailsNamingTheMethod() { + AcpAgent.SyncAgentBuilder sync = AcpAgent.sync(this.pair.agentTransport()) + .extRequestHandler("_x/ping", params -> Map.of()); + assertThatThrownBy(() -> sync.extRequestHandler("_x/ping", params -> Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("extRequestHandler") + .hasMessageContaining("_x/ping"); + // A different extension method is fine. + sync.extRequestHandler("_x/pong", params -> Map.of()); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/BuilderDefaultsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/BuilderDefaultsTest.java new file mode 100644 index 00000000..50153125 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/BuilderDefaultsTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; +import java.util.List; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A builder agent is complete by construction: it needs a prompt handler, and without an + * initialize handler it answers {@code initialize} with what its handlers imply, as an annotated + * agent does, instead of "Method not found". Without a new-session handler it answers + * {@code session/new} with a fresh session id, as an annotated agent does. + */ +class BuilderDefaultsTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + void aPromptOnlyAgentAnswersInitialize() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .build(); + InitializeResponse response = initialize(pair, agent); + + assertThat(response.protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + assertThat(response.agentCapabilities().loadSession()).isFalse(); + assertThat(response.agentCapabilities().sessionCapabilities()).isNull(); + } + + @Test + void theDefaultInitializeAdvertisesTheRegisteredHandlers() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(PromptResponse.endTurn())) + .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null))) + .listSessionsHandler(request -> Mono.just(new AcpSchema.ListSessionsResponse(List.of()))) + .closeSessionHandler(request -> Mono.just(new AcpSchema.CloseSessionResponse(null))) + .logoutHandler(request -> Mono.just(new AcpSchema.LogoutResponse(null))) + .build(); + InitializeResponse response = initialize(pair, new AcpSyncAgent(agent)); + + AcpSchema.AgentCapabilities capabilities = response.agentCapabilities(); + assertThat(capabilities.loadSession()).isTrue(); + assertThat(capabilities.sessionCapabilities().list()).isNotNull(); + assertThat(capabilities.sessionCapabilities().close()).isNotNull(); + assertThat(capabilities.sessionCapabilities().resume()).isNull(); + assertThat(capabilities.auth().logout()).isNotNull(); + } + + /** Any of the three provider methods advertises providers, as for an annotated agent. */ + @Test + void anyProviderHandlerAdvertisesProviders() { + InMemoryTransportPair setOnly = InMemoryTransportPair.create(); + AcpSyncAgent setter = AcpAgent.sync(setOnly.agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .setProviderHandler(request -> new AcpSchema.SetProviderResponse()) + .build(); + assertThat(initialize(setOnly, setter).agentCapabilities().providers()).isNotNull(); + + InMemoryTransportPair disableOnly = InMemoryTransportPair.create(); + AcpSyncAgent disabler = AcpAgent.sync(disableOnly.agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .disableProviderHandler(request -> new AcpSchema.DisableProviderResponse()) + .build(); + assertThat(initialize(disableOnly, disabler).agentCapabilities().providers()).isNotNull(); + } + + @Test + void theDefaultInitializeSendsTheBuildersAgentInfo() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .agentInfo(new AcpSchema.Implementation("builder-agent", "3.1")) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .build(); + + assertThat(initialize(pair, agent).agentInfo()).isEqualTo(new AcpSchema.Implementation("builder-agent", "3.1")); + } + + // ACP spec 7628b153: initialization.mdx:245, "As a baseline, all Agents MUST support + // session/new, session/prompt, session/cancel, and session/update" + @Test + void aBuilderAgentWithoutANewSessionHandlerAnswersSessionNewWithAFreshId() { + InMemoryTransportPair syncPair = InMemoryTransportPair.create(); + AcpSyncAgent syncAgent = AcpAgent.sync(syncPair.agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .build(); + NewSessionResponse first = newSession(syncPair, syncAgent); + + InMemoryTransportPair asyncPair = InMemoryTransportPair.create(); + AcpAsyncAgent asyncAgent = AcpAgent.async(asyncPair.agentTransport()) + .promptHandler((request, context) -> Mono.just(PromptResponse.endTurn())) + .build(); + NewSessionResponse second = newSession(asyncPair, new AcpSyncAgent(asyncAgent)); + + assertThat(first.sessionId()).isNotBlank(); + assertThat(second.sessionId()).isNotBlank().isNotEqualTo(first.sessionId()); + assertThat(first.modes()).isNull(); + assertThat(first.configOptions()).isNull(); + } + + @Test + void aNewSessionHandlerReplacesTheDefault() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .promptHandler((request, context) -> PromptResponse.endTurn()) + .newSessionHandler(request -> new NewSessionResponse("mine")) + .build(); + + assertThat(newSession(pair, agent).sessionId()).isEqualTo("mine"); + } + + @Test + void anAgentWithoutAPromptHandlerIsRefused() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + assertThatThrownBy(() -> AcpAgent.sync(pair.agentTransport()).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promptHandler"); + assertThatThrownBy(() -> AcpAgent.async(pair.agentTransport()).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("promptHandler"); + } + + private static NewSessionResponse newSession(InMemoryTransportPair pair, AcpSyncAgent agent) { + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + return client.newSession(new AcpSchema.NewSessionRequest("/tmp")).block(TIMEOUT); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + } + + private static InitializeResponse initialize(InMemoryTransportPair pair, AcpSyncAgent agent) { + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + return client.initialize().block(TIMEOUT); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ConvenienceApiTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ConvenienceApiTest.java index 3beb7308..db1dbcb3 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ConvenienceApiTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ConvenienceApiTest.java @@ -4,6 +4,7 @@ package com.agentclientprotocol.sdk.agent; +import java.lang.reflect.RecordComponent; import java.time.Duration; import java.util.List; import java.util.Map; @@ -21,10 +22,11 @@ import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; import com.agentclientprotocol.sdk.spec.AcpSchema.CreateTerminalResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.FileSystemCapability; -import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.KillTerminalCommandResponse; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PermissionCancelled; import com.agentclientprotocol.sdk.spec.AcpSchema.PermissionSelected; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; @@ -93,7 +95,7 @@ void getSessionIdReturnsCorrectValue() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("test-session-123", List.of(new TextContent("test")))).block(TIMEOUT); @@ -124,7 +126,7 @@ void sendMessageSendsAgentMessageChunk() throws Exception { AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { receivedUpdates.add(notification); latch.countDown(); return Mono.empty(); @@ -134,7 +136,7 @@ void sendMessageSendsAgentMessageChunk() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("msg-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -165,7 +167,7 @@ void sendMessageWithMessageIdSetsMessageId() throws Exception { AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { receivedUpdates.add(notification); latch.countDown(); return Mono.empty(); @@ -175,7 +177,7 @@ void sendMessageWithMessageIdSetsMessageId() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("msgid-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -207,7 +209,7 @@ void sendThoughtSendsAgentThoughtChunk() throws Exception { AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { receivedUpdates.add(notification); latch.countDown(); return Mono.empty(); @@ -217,7 +219,7 @@ void sendThoughtSendsAgentThoughtChunk() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("thought-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -250,7 +252,10 @@ void readFileReturnsFileContent() throws Exception { }) .build(); + FileSystemCapability fsCaps = new FileSystemCapability(true, false); + ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .readTextFileHandler(req -> { assertThat(req.path()).isEqualTo("/path/to/file.txt"); @@ -261,9 +266,7 @@ void readFileReturnsFileContent() throws Exception { agent.start(); Thread.sleep(100); - FileSystemCapability fsCaps = new FileSystemCapability(true, false); - ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("file-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -288,7 +291,10 @@ void writeFileWritesContent() throws Exception { }) .build(); + FileSystemCapability fsCaps = new FileSystemCapability(false, true); + ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .writeTextFileHandler(req -> { writtenPath.set(req.path()); @@ -300,9 +306,7 @@ void writeFileWritesContent() throws Exception { agent.start(); Thread.sleep(100); - FileSystemCapability fsCaps = new FileSystemCapability(false, true); - ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("write-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -328,7 +332,10 @@ void tryReadFileReturnsEmptyOnError() throws Exception { }) .build(); + FileSystemCapability fsCaps = new FileSystemCapability(true, false); + ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .readTextFileHandler(req -> Mono.error(new RuntimeException("File not found"))) .build(); @@ -336,9 +343,7 @@ void tryReadFileReturnsEmptyOnError() throws Exception { agent.start(); Thread.sleep(100); - FileSystemCapability fsCaps = new FileSystemCapability(true, false); - ClientCapabilities clientCaps = new ClientCapabilities(fsCaps, false); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("try-read-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -378,7 +383,7 @@ void askPermissionReturnsTrueWhenAllowed() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("perm-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -411,7 +416,7 @@ void askPermissionReturnsFalseWhenDenied() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("deny-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -430,7 +435,7 @@ void askChoiceReturnsSelectedOption() throws Exception { .initializeHandler(req -> InitializeResponse.ok()) .newSessionHandler(req -> new NewSessionResponse("choice-session", null, null)) .promptHandler((request, context) -> { - String choice = context.askChoice("How to proceed?", "Overwrite", "Skip", "Cancel"); + String choice = context.askChoice("How to proceed?", "Overwrite", "Skip", "Cancel").orElseThrow(); choiceResult.set(choice); return PromptResponse.endTurn(); }) @@ -452,7 +457,7 @@ void askChoiceReturnsSelectedOption() throws Exception { agent.start(); Thread.sleep(100); - client.initialize(new InitializeRequest(1, new ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("choice-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -462,6 +467,40 @@ void askChoiceReturnsSelectedOption() throws Exception { agent.closeGracefully(); } + @Test + void askChoiceIsEmptyWhenCancelled() throws Exception { + // A cancelled choice used to fail the prompt with a NullPointerException: the + // async askChoice mapped the cancellation to null inside Mono.map. + AtomicReference choiceResult = new AtomicReference<>(); + + AcpSyncAgent agent = AcpAgent.sync(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("cancel-choice-session", null, null)) + .promptHandler((request, context) -> { + choiceResult.set(context.askChoice("How to proceed?", "Overwrite", "Skip")); + return PromptResponse.endTurn(); + }) + .build(); + + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .requestPermissionHandler(req -> Mono.just(new RequestPermissionResponse(new PermissionCancelled()))) + .build(); + + agent.start(); + Thread.sleep(100); + + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + client.prompt(new PromptRequest("cancel-choice-session", List.of(new TextContent("test")))).block(TIMEOUT); + + assertThat(choiceResult.get()).isEqualTo(java.util.Optional.empty()); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + // ======================================================================== // execute() tests // ======================================================================== @@ -481,7 +520,9 @@ void executeRunsCommandAndReturnsResult() throws Exception { }) .build(); + ClientCapabilities clientCaps = new ClientCapabilities(null, true); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .createTerminalHandler(req -> { assertThat(req.command()).isEqualTo("echo"); @@ -497,13 +538,13 @@ void executeRunsCommandAndReturnsResult() throws Exception { return Mono.just(new TerminalOutputResponse("hello\n", false, null)); }) .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) .build(); agent.start(); Thread.sleep(100); - ClientCapabilities clientCaps = new ClientCapabilities(null, true); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("exec-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -516,6 +557,49 @@ void executeRunsCommandAndReturnsResult() throws Exception { agent.closeGracefully(); } + @Test + void executeReportsACommandKilledBySignal() throws Exception { + // A command killed by a signal has no exit code; execute() used to unbox the + // absent exit code and fail the prompt with a NullPointerException. + AtomicReference cmdResult = new AtomicReference<>(); + + AcpSyncAgent agent = AcpAgent.sync(transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("signal-session", null, null)) + .promptHandler((request, context) -> { + cmdResult.set(context.execute("sleep", "100")); + return PromptResponse.endTurn(); + }) + .build(); + + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(new ClientCapabilities(null, true)) + .requestTimeout(TIMEOUT) + .createTerminalHandler(req -> Mono.just(new CreateTerminalResponse("term-killed"))) + .waitForTerminalExitHandler(req -> Mono.just(new WaitForTerminalExitResponse(null, "SIGKILL"))) + .terminalOutputHandler(req -> Mono.just(new TerminalOutputResponse("", false, null))) + .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) + .build(); + + agent.start(); + Thread.sleep(100); + + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + client.prompt(new PromptRequest("signal-session", List.of(new TextContent("test")))).block(TIMEOUT); + + assertThat(cmdResult.get()).isInstanceOf(CommandResult.class); + CommandResult result = (CommandResult) cmdResult.get(); + assertThat(result.exitCode()).isNull(); + assertThat(result.signal()).isEqualTo("SIGKILL"); + assertThat(result.success()).isFalse(); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + @Test void executeWithCommandBuilderWorks() throws Exception { AtomicReference capturedCwd = new AtomicReference<>(); @@ -531,7 +615,9 @@ void executeWithCommandBuilderWorks() throws Exception { }) .build(); + ClientCapabilities clientCaps = new ClientCapabilities(null, true); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .createTerminalHandler(req -> { capturedCwd.set(req.cwd()); @@ -540,13 +626,13 @@ void executeWithCommandBuilderWorks() throws Exception { .waitForTerminalExitHandler(req -> Mono.just(new WaitForTerminalExitResponse(0, null))) .terminalOutputHandler(req -> Mono.just(new TerminalOutputResponse("", false, null))) .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) .build(); agent.start(); Thread.sleep(100); - ClientCapabilities clientCaps = new ClientCapabilities(null, true); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("cmd-session", List.of(new TextContent("test")))).block(TIMEOUT); @@ -575,7 +661,9 @@ void executeReleasesTerminalBeforeReturning() throws Exception { }) .build(); + ClientCapabilities clientCaps = new ClientCapabilities(null, true); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .createTerminalHandler(req -> Mono.just(new CreateTerminalResponse("term-rel"))) .waitForTerminalExitHandler(req -> Mono.just(new WaitForTerminalExitResponse(0, null))) @@ -587,13 +675,13 @@ void executeReleasesTerminalBeforeReturning() throws Exception { } return Mono.just(new ReleaseTerminalResponse()); }) + .killTerminalHandler(req -> Mono.just(new KillTerminalCommandResponse())) .build(); agent.start(); Thread.sleep(100); - ClientCapabilities clientCaps = new ClientCapabilities(null, true); - client.initialize(new InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new PromptRequest("release-test", List.of(new TextContent("test")))).block(TIMEOUT); @@ -616,12 +704,6 @@ void promptResponseEndTurnCreatesCorrectResponse() { assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); } - @Test - void promptResponseTextCreatesEndTurnResponse() { - PromptResponse response = PromptResponse.text("Hello"); - assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); - } - @Test void promptResponseRefusalCreatesRefusalResponse() { PromptResponse response = PromptResponse.refusal(); @@ -653,7 +735,7 @@ void commandOfCreatesCorrectCommand() { assertThat(cmd.executable()).isEqualTo("git"); assertThat(cmd.args()).containsExactly("status", "-s"); assertThat(cmd.cwd()).isNull(); - assertThat(cmd.env()).isNull(); + assertThat(cmd.env()).isEmpty(); } @Test @@ -668,18 +750,26 @@ void commandBuildersReturnNewInstances() { assertThat(withEnv).isNotSameAs(original); assertThat(withEnv.env()).containsEntry("DEBUG", "true"); - assertThat(original.env()).isNull(); + assertThat(original.env()).isEmpty(); } @Test void commandResultSuccessReturnsCorrectly() { CommandResult success = new CommandResult("output", 0); CommandResult failure = new CommandResult("error", 1); - CommandResult timedOut = new CommandResult("partial", 0, true); + CommandResult killed = new CommandResult("partial", null, "SIGKILL"); assertThat(success.success()).isTrue(); assertThat(failure.success()).isFalse(); - assertThat(timedOut.success()).isFalse(); + assertThat(killed.success()).isFalse(); + } + + @Test + void commandResultCarriesOnlyWhatTheTerminalReports() { + // execute() has no timeout (neither Command nor terminal/wait_for_exit has one), so a + // timedOut flag could never be set; the result carries what the client reports. + assertThat(CommandResult.class.getRecordComponents()).extracting(RecordComponent::getName) + .containsExactly("output", "exitCode", "signal", "truncated"); } } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/EmptyResultOnTheWireTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/EmptyResultOnTheWireTest.java new file mode 100644 index 00000000..6a3d6d55 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/EmptyResultOnTheWireTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A handler that returns an empty response type is answered with {@code "result": {}}, the + * exact bytes on the stdio wire. Java must never send {@code "result": null} (which peers + * without a default-on-null rule reject) nor drop the result. + */ +class EmptyResultOnTheWireTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + void emptyResponsesAreWrittenAsAnEmptyObjectResult() throws Exception { + AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + PipedOutputStream clientOut = new PipedOutputStream(); + PipedInputStream agentIn = new PipedInputStream(clientOut, 65536); + PipedOutputStream agentOut = new PipedOutputStream(); + PipedInputStream clientIn = new PipedInputStream(agentOut, 65536); + + AcpAsyncAgent agent = AcpAgent.async(new StdioAcpAgentTransport(mapper, agentIn, agentOut)) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .setSessionModeHandler(request -> Mono.just(new AcpSchema.SetSessionModeResponse())) + .closeSessionHandler(request -> Mono.just(new AcpSchema.CloseSessionResponse())) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + + // Read the agent's output lines by id until the three responses are in. + CompletableFuture> lines = CompletableFuture.supplyAsync(() -> { + Map byId = new HashMap<>(); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + String line; + while (byId.size() < 3 && (line = reader.readLine()) != null) { + Map message = mapper.readValue(line, Map.class); + if (message != null && message.containsKey("id")) { + byId.put(message.get("id"), line); + } + } + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return byId; + }); + + try { + for (String request : List.of( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/set_mode\",\"params\":{\"sessionId\":\"s\",\"modeId\":\"m\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/close\",\"params\":{\"sessionId\":\"s\"}}")) { + clientOut.write((request + "\n").getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + } + + Map responses = lines.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(responses.get(2)).isEqualTo("{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}"); + assertThat(responses.get(3)).isEqualTo("{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}"); + } + finally { + agent.closeGracefully().block(TIMEOUT); + clientOut.close(); + clientIn.close(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ExtensionResultOnTheWireTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ExtensionResultOnTheWireTest.java new file mode 100644 index 00000000..c50796df --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/ExtensionResultOnTheWireTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import reactor.core.publisher.Mono; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An extension request on the stdio wire: the agent writes the method and params as given, + * and a peer's {@code "result": null}, which the spec allows for an extension method, + * completes a typed or raw async send empty and makes a sync send return null. + */ +class ExtensionResultOnTheWireTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + record Pong(String text) { + } + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private PipedOutputStream clientOut; + + private BufferedReader clientIn; + + private AcpAsyncAgent agent; + + @BeforeEach + void setUp() throws Exception { + clientOut = new PipedOutputStream(); + PipedInputStream agentIn = new PipedInputStream(clientOut, 65536); + PipedOutputStream agentOut = new PipedOutputStream(); + clientIn = new BufferedReader( + new InputStreamReader(new PipedInputStream(agentOut, 65536), StandardCharsets.UTF_8)); + agent = AcpAgent.async(new StdioAcpAgentTransport(mapper, agentIn, agentOut)).requestTimeout(TIMEOUT).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + agent.start().block(TIMEOUT); + } + + @AfterEach + void tearDown() throws Exception { + agent.closeGracefully().block(TIMEOUT); + clientOut.close(); + clientIn.close(); + } + + /** Reads the agent's next request, checks its method and params, and answers it with the given result JSON. */ + private void answerNext(String expectedMethod, String resultJson) throws Exception { + Map request = mapper.readValue(clientIn.readLine(), Map.class); + assertThat(request.get("method")).isEqualTo(expectedMethod); + assertThat(request.get("params")).isEqualTo(Map.of("q", 1)); + String response = "{\"jsonrpc\":\"2.0\",\"id\":" + mapper.writeValueAsString(request.get("id")) + ",\"result\":" + + resultJson + "}\n"; + clientOut.write(response.getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + } + + @Test + void nullResultCompletesARawSendEmpty() throws Exception { + CompletableFuture completedEmpty = agent.sendExtRequest("_test/nothing", Map.of("q", 1)) + .hasElement() + .map(hasElement -> !hasElement) + .toFuture(); + answerNext("_test/nothing", "null"); + assertThat(completedEmpty.get(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void nullResultCompletesATypedSendEmpty() throws Exception { + CompletableFuture completedEmpty = agent + .sendExtRequest("_test/nothing", Map.of("q", 1), new TypeRef() { + }) + .hasElement() + .map(hasElement -> !hasElement) + .toFuture(); + answerNext("_test/nothing", "null"); + assertThat(completedEmpty.get(5, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void nullResultIsNullForASyncSend() throws Exception { + AcpSyncAgent syncAgent = new AcpSyncAgent(agent, TIMEOUT); + CompletableFuture result = CompletableFuture + .supplyAsync(() -> syncAgent.sendExtRequest("_test/nothing", Map.of("q", 1), new TypeRef() { + })); + answerNext("_test/nothing", "null"); + assertThat(result.get(5, TimeUnit.SECONDS)).isNull(); + + CompletableFuture raw = CompletableFuture + .supplyAsync(() -> syncAgent.sendExtRequest("_test/nothing", Map.of("q", 1))); + answerNext("_test/nothing", "null"); + assertThat(raw.get(5, TimeUnit.SECONDS)).isNull(); + } + + @Test + void scalarResultIsDeliveredRawAndTyped() throws Exception { + CompletableFuture raw = agent.sendExtRequest("_test/count", Map.of("q", 1)).toFuture(); + answerNext("_test/count", "42"); + assertThat(raw.get(5, TimeUnit.SECONDS)).isEqualTo(42); + + CompletableFuture typed = agent.sendExtRequest("_test/pong", Map.of("q", 1), new TypeRef() { + }).toFuture(); + answerNext("_test/pong", "{\"text\":\"hi\"}"); + assertThat(typed.get(5, TimeUnit.SECONDS)).isEqualTo(new Pong("hi")); + } + + @Test + void notificationIsWrittenWithoutAnId() throws Exception { + agent.sendExtNotification("_test/note", Map.of("q", 1)).block(TIMEOUT); + Map notification = mapper.readValue(clientIn.readLine(), Map.class); + assertThat(notification.containsKey("id")).isFalse(); + assertThat(notification.get("method")).isEqualTo("_test/note"); + assertThat(notification.get("params")).isEqualTo(Map.of("q", 1)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/JsonRpcErrorsOnTheWireTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/JsonRpcErrorsOnTheWireTest.java new file mode 100644 index 00000000..9d7ddcd1 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/JsonRpcErrorsOnTheWireTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The exact JSON-RPC errors a stdio agent writes, line by line, for requests a handler + * refuses or that are not valid requests at all. + */ +class JsonRpcErrorsOnTheWireTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String INITIALIZE = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}"; + + /** + * JSON-RPC 2.0 section 5.1: {@code message} is a short description and {@code code} the + * number. The SDK used to write {@code "[-32602] unknown directive: #nope"}, the Java + * exception's log form, as the message. + */ + @Test + void anErrorMessageIsWrittenWithoutItsCode() throws Exception { + List lines = exchange( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\",\"prompt\":[]}}"); + + assertThat(lines).contains( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32602,\"message\":\"unknown directive: #nope\"}}"); + } + + /** + * JSON-RPC 2.0 section 4: a request's method is a string, its id a string, number or + * null, and its jsonrpc exactly "2.0"; anything else is -32600 Invalid Request (section + * 5.1), answered with the request's id when that id is readable, else null. Before, a + * numeric method was answered -32601 and the other two were executed. + */ + @Test + void anInvalidRequestIsAnsweredInvalidRequest() throws Exception { + List lines = exchange("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":5}", + "{\"jsonrpc\":\"2.0\",\"id\":{\"a\":1},\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\",\"prompt\":[]}}", + "{\"jsonrpc\":\"1.0\",\"id\":4,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\",\"prompt\":[]}}"); + + assertThat(lines).contains( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":4,\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}"); + } + + /** Params the method cannot read are -32602 Invalid params (section 5.1), not -32603. */ + @Test + void paramsOfTheWrongTypeAreInvalidParams() throws Exception { + List lines = exchange( + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\",\"prompt\":\"not a list\"}}"); + + assertThat(lines).anySatisfy(line -> assertThat(line) + .startsWith("{\"jsonrpc\":\"2.0\",\"id\":5,\"error\":{\"code\":-32602,\"message\":\"Invalid params\"")); + } + + /** + * A required field missing (session/new without cwd) or of the wrong JSON type (cwd: 42) + * is -32602 too. Before, the first reached the handler as null and the second was + * coerced to the string "42", and both succeeded. + */ + @Test + void aMissingOrMistypedRequiredFieldIsInvalidParams() throws Exception { + List lines = exchange("{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"session/new\",\"params\":{\"mcpServers\":[]}}", + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"session/new\",\"params\":{\"cwd\":42,\"mcpServers\":[]}}", + "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"session/new\",\"params\":{\"cwd\":\"/w\",\"mcpServers\":[]}}"); + + assertThat(lines).contains( + "{\"jsonrpc\":\"2.0\",\"id\":6,\"error\":{\"code\":-32602,\"message\":\"Invalid params\",\"data\":\"missing required field: cwd\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":8,\"result\":{\"sessionId\":\"s\"}}"); + assertThat(lines).anySatisfy(line -> assertThat(line) + .startsWith("{\"jsonrpc\":\"2.0\",\"id\":7,\"error\":{\"code\":-32602,\"message\":\"Invalid params\"")); + } + + /** + * A notification whose params cannot be read gets no answer (it has no id) and must not + * end the connection: the request after it is still answered. Before, the handler's + * error ended the agent's inbound stream. + */ + @Test + void aNotificationWithUnreadableParamsIsSkipped() throws Exception { + List lines = exchange( + "{\"jsonrpc\":\"2.0\",\"method\":\"session/cancel\",\"params\":{\"sessionId\":42}}", + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"session/new\",\"params\":{\"cwd\":\"/w\",\"mcpServers\":[]}}"); + + assertThat(lines).contains("{\"jsonrpc\":\"2.0\",\"id\":9,\"result\":{\"sessionId\":\"s\"}}"); + } + + /** Sends {@code initialize} and then each line, and returns every line the agent wrote, one per request. */ + static List exchange(String... requests) throws Exception { + AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + PipedOutputStream clientOut = new PipedOutputStream(); + PipedInputStream agentIn = new PipedInputStream(clientOut, 65536); + PipedOutputStream agentOut = new PipedOutputStream(); + PipedInputStream clientIn = new PipedInputStream(agentOut, 65536); + + AcpAsyncAgent agent = AcpAgent.async(new StdioAcpAgentTransport(mapper, agentIn, agentOut)) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s", null))) + .cancelHandler(notification -> Mono.empty()) + .promptHandler((request, context) -> Mono + .error(new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, "unknown directive: #nope"))) + .build(); + agent.start().block(TIMEOUT); + + // One answer per request; a notification gets none. + int expected = 1 + (int) java.util.Arrays.stream(requests).filter(r -> r.contains("\"id\"")).count(); + CompletableFuture> lines = CompletableFuture.supplyAsync(() -> { + List read = new ArrayList<>(); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + String line; + while (read.size() < expected && (line = reader.readLine()) != null) { + read.add(line); + } + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + return read; + }); + + try { + List all = new ArrayList<>(); + all.add(INITIALIZE); + all.addAll(List.of(requests)); + for (String request : all) { + clientOut.write((request + "\n").getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + } + return lines.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + finally { + agent.closeGracefully().block(TIMEOUT); + clientOut.close(); + clientIn.close(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextHelpersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextHelpersTest.java new file mode 100644 index 00000000..03776907 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextHelpersTest.java @@ -0,0 +1,466 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.CapturedLogs; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.ClientCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema.CreateTerminalResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.ReleaseTerminalResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The prompt context's helpers ({@code execute}, {@code askChoice}, {@code askPermission}, + * {@code tryReadFile}) against a real client over an in-memory transport. + */ +class PromptContextHelpersTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + @AfterEach + void tearDown() { + this.transportPair.closeGracefully().block(TIMEOUT); + } + + private void connect(AcpAsyncClient client) { + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + } + + private static PromptRequest prompt() { + return new PromptRequest("s1", List.of(new TextContent("go"))); + } + + /** + * ACP terminals.mdx: "The Agent MUST release the terminal using terminal/release when it's no + * longer needed". A prompt cancelled while {@code execute} waits for the command to exit still + * releases the terminal it created. + */ + @Test + void executeCancelledWhileWaitingForExitReleasesTheTerminal_terminalsMdxMustRelease() throws Exception { + CountDownLatch created = new CountDownLatch(1); + CountDownLatch released = new CountDownLatch(1); + + AcpAsyncAgent agent = AcpAgent.async(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(req -> Mono.just(new NewSessionResponse("s1", null, null))) + .promptHandler((request, context) -> context.execute("sleep", "600").thenReturn(PromptResponse.endTurn())) + .build(); + + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new ClientCapabilities(null, true)) + .createTerminalHandler(req -> { + created.countDown(); + return Mono.just(new CreateTerminalResponse("term-1")); + }) + .waitForTerminalExitHandler(req -> Mono.never()) + .terminalOutputHandler(req -> Mono.never()) + .releaseTerminalHandler(req -> { + released.countDown(); + return Mono.just(new ReleaseTerminalResponse()); + }) + .killTerminalHandler(req -> Mono.just(new AcpSchema.KillTerminalCommandResponse())) + .build(); + + agent.start().block(TIMEOUT); + connect(client); + + Disposable turn = client.prompt(prompt()).subscribe(r -> { + }, e -> { + }); + assertThat(created.await(5, TimeUnit.SECONDS)).as("terminal/create").isTrue(); + // Give terminal/wait_for_exit time to be sent, then give up on the prompt: the client sends + // $/cancel_request and the agent cancels the prompt handler. + Thread.sleep(200); + turn.dispose(); + + assertThat(released.await(5, TimeUnit.SECONDS)).as("terminal/release after the cancel").isTrue(); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + /** + * A release that the client answers with an error is logged at WARN by its code and the + * SDK's description of the code: the client's message and data are its own text and stay + * at DEBUG. + */ + @Test + void aFailedReleaseOfACancelledCommandIsLoggedWithoutTheClientsMessageOrData() throws Exception { + CountDownLatch created = new CountDownLatch(1); + + AcpAsyncAgent agent = AcpAgent.async(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(req -> Mono.just(new NewSessionResponse("s1", null, null))) + .promptHandler((request, context) -> context.execute("sleep", "600").thenReturn(PromptResponse.endTurn())) + .build(); + + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new ClientCapabilities(null, true)) + .createTerminalHandler(req -> { + created.countDown(); + return Mono.just(new CreateTerminalResponse("term-1")); + }) + .waitForTerminalExitHandler(req -> Mono.never()) + .terminalOutputHandler(req -> Mono.never()) + .releaseTerminalHandler(req -> Mono.error(new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND, + "MESSAGE-SECRET", Map.of("details", "DATA-SECRET")))) + .killTerminalHandler(req -> Mono.just(new AcpSchema.KillTerminalCommandResponse())) + .build(); + + try (CapturedLogs logs = CapturedLogs.open()) { + agent.start().block(TIMEOUT); + connect(client); + + Disposable turn = client.prompt(prompt()).subscribe(r -> { + }, e -> { + }); + assertThat(created.await(5, TimeUnit.SECONDS)).as("terminal/create").isTrue(); + Thread.sleep(200); + turn.dispose(); + + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (logs.events() + .stream() + .noneMatch(e -> e.getFormattedMessage().contains("of a cancelled command")) + && System.nanoTime() < deadline) { + Thread.sleep(20); + } + assertThat(logs.events()).filteredOn(e -> e.getLevel() == ch.qos.logback.classic.Level.WARN) + .filteredOn(e -> e.getFormattedMessage().contains("Could not release terminal")) + .singleElement() + .satisfies(e -> assertThat(e.getFormattedMessage()).contains("-32002").contains("Resource not found")); + logs.assertNoneAtInfoOrAboveContains("MESSAGE-SECRET"); + logs.assertNoneAtInfoOrAboveContains("DATA-SECRET"); + } + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + /** The same for a sync handler: cancelling the prompt interrupts it in {@code execute}. */ + @Test + void syncExecuteCancelledWhileWaitingForExitReleasesTheTerminal_terminalsMdxMustRelease() throws Exception { + CountDownLatch created = new CountDownLatch(1); + CountDownLatch released = new CountDownLatch(1); + + AcpSyncAgent agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + context.execute("sleep", "600"); + return PromptResponse.endTurn(); + }) + .build(); + + AcpAsyncClient client = terminalClient(created, released); + + agent.start(); + connect(client); + + Disposable turn = client.prompt(prompt()).subscribe(r -> { + }, e -> { + }); + assertThat(created.await(5, TimeUnit.SECONDS)).as("terminal/create").isTrue(); + Thread.sleep(200); + turn.dispose(); + + assertThat(released.await(5, TimeUnit.SECONDS)).as("terminal/release after the cancel").isTrue(); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + + /** + * A client that answers {@code askChoice} with an option ID it was not offered fails the call + * with an {@link AcpError}, as any rejected answer does, not a {@code NumberFormatException} or {@code ArrayIndexOutOfBoundsException}. + */ + @Test + void askChoiceAnsweredWithAnOptionThatWasNotOfferedFailsWithAcpError() { + for (String answer : List.of("7", "-1", "yes")) { + AtomicReference failure = new AtomicReference<>(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(req -> Mono.just(new NewSessionResponse("s1", null, null))) + .promptHandler((request, context) -> context.askChoice("Which?", "red", "green") + .doOnError(failure::set) + .onErrorResume(e -> Mono.empty()) + .thenReturn(PromptResponse.endTurn())) + .build(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .requestPermissionHandler(req -> Mono.just( + new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected(answer)))) + .build(); + try { + agent.start().block(TIMEOUT); + connect(client); + client.prompt(prompt()).block(TIMEOUT); + + assertThat(failure.get()).as("answer %s", answer) + .isInstanceOfSatisfying(AcpError.class, error -> { + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getData()).isEqualTo(Map.of("reason", "unoffered-option", "method", + AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, "optionId", answer)); + }) + .hasMessageContaining(answer); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + } + + @Test + void askChoiceReturnsTheChosenOption() { + AcpAsyncAgent agent = AcpAgent.async(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(req -> Mono.just(new NewSessionResponse("s1", null, null))) + .promptHandler((request, context) -> context.askChoice("Which?", "red", "green") + .flatMap(context::sendMessage) + .thenReturn(PromptResponse.endTurn())) + .build(); + List messages = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .requestPermissionHandler(req -> Mono + .just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("1")))) + .sessionUpdateHandler(n -> Mono.fromRunnable(() -> { + if (n.update() instanceof AcpSchema.AgentMessageChunk chunk) { + messages.add(((TextContent) chunk.content()).text()); + } + })) + .build(); + agent.start().block(TIMEOUT); + connect(client); + client.prompt(prompt()).block(TIMEOUT); + assertThat(messages).containsExactly("green"); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + /** + * {@code tryReadFile} gives empty for a file it cannot read, but not for the interrupt the SDK + * uses to cancel a sync handler: that propagates, with the thread's interrupt flag still set, + * so a cancelled handler stops instead of carrying on as if the file were missing. + */ + @Test + void tryReadFileDoesNotSwallowTheCancellingInterrupt() throws Exception { + CountDownLatch reading = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(1); + AtomicReference outcome = new AtomicReference<>(); + + AcpSyncAgent agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + try { + var content = context.tryReadFile("/slow"); + outcome.set("returned " + content + ", interrupted=" + Thread.currentThread().isInterrupted()); + } + catch (RuntimeException e) { + outcome.set(e.getClass().getSimpleName() + ", interrupted=" + Thread.currentThread().isInterrupted()); + throw e; + } + finally { + done.countDown(); + } + return PromptResponse.endTurn(); + }) + .build(); + + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new ClientCapabilities(new AcpSchema.FileSystemCapability(true, false), null)) + .readTextFileHandler(req -> { + reading.countDown(); + return Mono.never(); + }) + .build(); + + agent.start(); + connect(client); + + Disposable turn = client.prompt(prompt()).subscribe(r -> { + }, e -> { + }); + assertThat(reading.await(5, TimeUnit.SECONDS)).isTrue(); + turn.dispose(); + + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(outcome.get()).isEqualTo("CancellationException, interrupted=true"); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + + /** + * {@code askPermission} asks about a tool call the client knows: it announces it with a + * {@code tool_call} update (kind {@code other}, not {@code edit} for every action) before the + * permission request names it, and settles it once the user answered. + */ + @Test + void askPermissionAnnouncesTheToolCallItAsksAbout() { + List updates = new CopyOnWriteArrayList<>(); + List knownWhenAsked = new CopyOnWriteArrayList<>(); + AtomicReference asked = new AtomicReference<>(); + + AcpAsyncAgent agent = AcpAgent.async(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(req -> Mono.just(new NewSessionResponse("s1", null, null))) + .promptHandler((request, context) -> context.askPermission("Run the tests") + .thenReturn(PromptResponse.endTurn())) + .build(); + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(n -> Mono.fromRunnable(() -> updates.add(n.update()))) + .requestPermissionHandler(req -> { + asked.set(req.toolCall()); + updates.stream() + .filter(u -> u instanceof AcpSchema.ToolCall call && call.toolCallId().equals(req.toolCall().toolCallId())) + .forEach(u -> knownWhenAsked.add(((AcpSchema.ToolCall) u).toolCallId())); + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))); + }) + .build(); + + agent.start().block(TIMEOUT); + connect(client); + client.prompt(prompt()).block(TIMEOUT); + + assertThat(asked.get()).isNotNull(); + assertThat(knownWhenAsked).as("tool call announced before the permission request") + .containsExactly(asked.get().toolCallId()); + AcpSchema.ToolCall announced = (AcpSchema.ToolCall) updates.get(0); + assertThat(announced.title()).isEqualTo("Run the tests"); + assertThat(announced.kind()).isEqualTo(AcpSchema.ToolKind.OTHER); + assertThat(announced.status()).isEqualTo(AcpSchema.ToolCallStatus.PENDING); + assertThat(asked.get().kind()).isNotEqualTo(AcpSchema.ToolKind.EDIT); + assertThat(updates).last() + .isInstanceOfSatisfying(AcpSchema.ToolCallUpdateNotification.class, done -> { + assertThat(done.toolCallId()).isEqualTo(asked.get().toolCallId()); + assertThat(done.status()).isEqualTo(AcpSchema.ToolCallStatus.COMPLETED); + }); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + /** {@code execute} carries the {@code truncated} flag of the client's terminal output. */ + @Test + void executeCarriesTheTruncatedFlag() { + AtomicReference result = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + result.set(context.execute("make", "build")); + return PromptResponse.endTurn(); + }) + .build(); + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new ClientCapabilities(null, true)) + .createTerminalHandler(req -> Mono.just(new CreateTerminalResponse("term-t"))) + .waitForTerminalExitHandler(req -> Mono.just(new AcpSchema.WaitForTerminalExitResponse(0, null))) + .terminalOutputHandler(req -> Mono.just(new AcpSchema.TerminalOutputResponse("...tail", true, null))) + .releaseTerminalHandler(req -> Mono.just(new ReleaseTerminalResponse())) + .killTerminalHandler(req -> Mono.just(new AcpSchema.KillTerminalCommandResponse())) + .build(); + agent.start(); + connect(client); + client.prompt(prompt()).block(TIMEOUT); + + assertThat(result.get().output()).isEqualTo("...tail"); + assertThat(result.get().truncated()).isTrue(); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + + /** {@code sendSessionUpdate(update)} sends the update for the context's own session. */ + @Test + void sendUpdateUsesTheContextsSession() { + List sessions = new CopyOnWriteArrayList<>(); + AcpSyncAgent agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(req -> InitializeResponse.ok()) + .newSessionHandler(req -> new NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + context.sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new TextContent("sync"))); + context.async().sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new TextContent("async"))).block(TIMEOUT); + return PromptResponse.endTurn(); + }) + .build(); + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(n -> Mono.fromRunnable(() -> sessions.add(n.sessionId()))) + .build(); + agent.start(); + connect(client); + client.prompt(prompt()).block(TIMEOUT); + + assertThat(sessions).containsExactly("s1", "s1"); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + + private AcpAsyncClient terminalClient(CountDownLatch created, CountDownLatch released) { + return AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new ClientCapabilities(null, true)) + .createTerminalHandler(req -> { + created.countDown(); + return Mono.just(new CreateTerminalResponse("term-1")); + }) + .waitForTerminalExitHandler(req -> Mono.never()) + .terminalOutputHandler(req -> Mono.never()) + .releaseTerminalHandler(req -> { + released.countDown(); + return Mono.just(new ReleaseTerminalResponse()); + }) + .killTerminalHandler(req -> Mono.just(new AcpSchema.KillTerminalCommandResponse())) + .build(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextLayersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextLayersTest.java new file mode 100644 index 00000000..c92dc650 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/PromptContextLayersTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The prompt context holds the convenience layer; the raw ACP requests to the client are one step + * down, on {@code context.client()}, so {@code readFile} no longer sits beside {@code readTextFile} + * in the completion menu. + */ +class PromptContextLayersTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final Set PROTOCOL_CALLS = Set.of("readTextFile", "writeTextFile", "requestPermission", + "createTerminal", "getTerminalOutput", "releaseTerminal", "waitForTerminalExit", "killTerminal", + "createElicitation", "completeElicitation", "sendExtRequest", "sendExtNotification"); + + private static Set methods(Class type) { + return Arrays.stream(type.getMethods()).map(Method::getName).collect(Collectors.toSet()); + } + + @Test + void theProtocolCallsLiveOnTheClientNotOnTheContext() { + for (Class context : List.of(PromptContext.class, SyncPromptContext.class)) { + assertThat(methods(context)).as(context.getSimpleName()) + .doesNotContainAnyElementsOf(PROTOCOL_CALLS) + .contains("client", "readFile", "writeFile", "askPermission", "askChoice", "execute", "sendMessage", + "sendThought", "sendSessionUpdate", "getSessionId", "isCancelled", "getClientCapabilities"); + } + assertThat(methods(SessionClient.class)).isEqualTo(PROTOCOL_CALLS); + assertThat(methods(SyncSessionClient.class)).isEqualTo(PROTOCOL_CALLS); + } + + @Test + void syncAndAsyncContextsReachTheClientThroughClient() { + InMemoryTransportPair asyncPair = InMemoryTransportPair.create(); + InMemoryTransportPair syncPair = InMemoryTransportPair.create(); + AtomicReference asyncRead = new AtomicReference<>(); + AtomicReference syncRead = new AtomicReference<>(); + AcpAsyncAgent asyncAgent = AcpAgent.async(asyncPair.agentTransport()) + .promptHandler((request, context) -> context.client() + .readTextFile(new AcpSchema.ReadTextFileRequest(context.getSessionId(), "/a.txt", 2, 1)) + .doOnNext(response -> asyncRead.set(response.content())) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build(); + AcpSyncAgent syncAgent = AcpAgent.sync(syncPair.agentTransport()) + .promptHandler((request, context) -> { + syncRead.set(context.client() + .readTextFile(new AcpSchema.ReadTextFileRequest(context.getSessionId(), "/b.txt", null, null)) + .content()); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + asyncAgent.start().block(TIMEOUT); + syncAgent.start(); + try (AcpSyncClient asyncClient = client(asyncPair); AcpSyncClient syncClient = client(syncPair)) { + prompt(asyncClient); + prompt(syncClient); + + assertThat(asyncRead.get()).isEqualTo("/a.txt@2+1"); + assertThat(syncRead.get()).isEqualTo("/b.txt@null+null"); + } + finally { + asyncAgent.close(); + syncAgent.close(); + asyncPair.closeGracefully().block(TIMEOUT); + syncPair.closeGracefully().block(TIMEOUT); + } + } + + private static AcpSyncClient client(InMemoryTransportPair pair) { + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(AcpSchema.ClientCapabilities.builder().readTextFile().build()) + .readTextFileHandler(request -> new AcpSchema.ReadTextFileResponse( + request.path() + "@" + request.line() + "+" + request.limit())) + .build(); + client.initialize(); + return client; + } + + private static void prompt(AcpSyncClient client) { + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).sessionId(); + client.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("go")))); + } + + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SessionCloseCancelsPromptTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SessionCloseCancelsPromptTest.java new file mode 100644 index 00000000..fa239389 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SessionCloseCancelsPromptTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code session/close} cancels the session's ongoing work as if {@code session/cancel} had been + * called, and then closes it (ACP v1 schema, {@code CloseSessionRequest}: "the agent + * must cancel any ongoing work related to the session (treat it as if + * {@code session/cancel} was called) and then free up any resources associated with the + * session"). + */ +class SessionCloseCancelsPromptTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + private final List events = new CopyOnWriteArrayList<>(); + + private AcpAsyncAgent agent; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (this.client != null) { + this.client.close(); + } + if (this.agent != null) { + this.agent.close(); + } + } + + @Test + void closingASessionWithAPromptInFlightCancelsThePromptThenCloses() { + Sinks.Empty promptStarted = Sinks.empty(); + Sinks.Empty cancelled = Sinks.empty(); + this.agent = AcpAgent.async(this.pair.agentTransport()) + .requestTimeout(TIMEOUT) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s-1", null))) + .promptHandler((request, context) -> { + promptStarted.tryEmitEmpty(); + // Works until cancelled, as an agent's prompt handler does. + return cancelled.asMono().then(Mono.fromCallable(() -> { + this.events.add("prompt answered"); + return new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED); + })); + }) + .cancelHandler(notification -> Mono.fromRunnable(() -> { + this.events.add("cancel " + notification.sessionId()); + cancelled.tryEmitEmpty(); + })) + .closeSessionHandler(request -> Mono.fromCallable(() -> { + this.events.add("close " + request.sessionId()); + return new AcpSchema.CloseSessionResponse(); + })) + .build(); + this.agent.start().block(TIMEOUT); + this.client = AcpClient.async(this.pair.clientTransport()).requestTimeout(TIMEOUT).build(); + this.client.initialize().block(TIMEOUT); + this.client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + Mono prompt = this.client + .prompt(new AcpSchema.PromptRequest("s-1", List.of(new AcpSchema.TextContent("work")))) + .cache(); + prompt.subscribe(response -> { + }, error -> { + }); + promptStarted.asMono().block(TIMEOUT); + + this.client.closeSession(new AcpSchema.CloseSessionRequest("s-1")).block(TIMEOUT); + + assertThat(prompt.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(this.events).containsExactly("cancel s-1", "prompt answered", "close s-1"); + } + + @Test + void closingAnIdleSessionClosesItAndTellsTheCancelHandler() { + this.agent = AcpAgent.async(this.pair.agentTransport()) + .requestTimeout(TIMEOUT) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s-1", null))) + .cancelHandler(notification -> Mono.fromRunnable(() -> this.events.add("cancel " + notification.sessionId()))) + .closeSessionHandler(request -> Mono.fromCallable(() -> { + this.events.add("close " + request.sessionId()); + return new AcpSchema.CloseSessionResponse(); + })) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + this.agent.start().block(TIMEOUT); + this.client = AcpClient.async(this.pair.clientTransport()).requestTimeout(TIMEOUT).build(); + this.client.initialize().block(TIMEOUT); + + this.client.closeSession(new AcpSchema.CloseSessionRequest("s-1")).block(TIMEOUT); + + assertThat(this.events).containsExactly("cancel s-1", "close s-1"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncHandlerSchedulerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncHandlerSchedulerTest.java new file mode 100644 index 00000000..67e82b58 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncHandlerSchedulerTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The default handler executor on either JDK, whichever JDK runs the test: the virtual-thread + * executor where there is one (JDK 21 and later), else a cached pool of named daemon threads. + */ +class SyncHandlerSchedulerTest { + + @Test + void theVirtualThreadExecutorIsUsedWhenThereIsOne() { + ExecutorService virtual = Executors.newSingleThreadExecutor(); + try { + assertThat(SyncHandlerScheduler.executor(virtual)).isSameAs(virtual); + } + finally { + virtual.shutdownNow(); + } + } + + @Test + void withoutOneACachedPoolOfNamedDaemonThreads() throws Exception { + ExecutorService pool = SyncHandlerScheduler.executor(null); + try { + Thread thread = pool.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("acp-agent-sync-handler"); + assertThat(thread.isDaemon()).isTrue(); + assertThat(VirtualThreads.isVirtual(thread)).isFalse(); + } + finally { + pool.shutdownNow(); + } + } + + @Test + void theDefaultIsVirtualExactlyWhereTheJdkHasVirtualThreads() throws Exception { + CompletableFuture ran = new CompletableFuture<>(); + SyncHandlerScheduler.DEFAULT.schedule(() -> ran.complete(Thread.currentThread())); + Thread thread = ran.get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("acp-agent-sync-handler"); + assertThat(VirtualThreads.isVirtual(thread)).isEqualTo(VirtualThreads.isSupported()); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncLifecycleTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncLifecycleTest.java new file mode 100644 index 00000000..f8d9c42d --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/SyncLifecycleTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The sync facades' lifecycle names line up: {@code awaitTermination()} (not {@code await()}), + * {@code AutoCloseable} with a graceful, bounded, then forced {@code close()}, and + * {@code AcpSyncClient.async()} for the asynchronous client behind it. + */ +class SyncLifecycleTest { + + /** An asynchronous agent whose graceful close never completes; records the calls. */ + private static AcpAsyncAgent stuckAgent(List calls) { + return (AcpAsyncAgent) Proxy.newProxyInstance(AcpAsyncAgent.class.getClassLoader(), + new Class[] { AcpAsyncAgent.class }, (proxy, method, args) -> { + calls.add(method.getName()); + return switch (method.getName()) { + case "closeGracefully" -> Mono.never(); + case "awaitTermination" -> Mono.empty(); + default -> null; + }; + }); + } + + @Test + void aSyncAgentIsAutoCloseableAndClosesGracefullyThenForcibly() throws Exception { + List calls = new CopyOnWriteArrayList<>(); + try (AcpSyncAgent agent = new AcpSyncAgent(stuckAgent(calls), Duration.ofMillis(200))) { + agent.awaitTermination(); + } + assertThat(calls).containsExactly("awaitTermination", "closeGracefully", "close"); + } + + @Test + void aSyncClientGivesItsAsynchronousClient() { + AcpAsyncClient asyncClient = AcpClient.async(new MockAcpClientTransport()).build(); + AcpSyncClient client = new AcpSyncClient(asyncClient); + assertThat(client.async()).isSameAs(asyncClient); + asyncClient.close(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/TerminalCapabilityTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/TerminalCapabilityTest.java new file mode 100644 index 00000000..64ca4953 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/TerminalCapabilityTest.java @@ -0,0 +1,116 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * An agent sends no terminal method to a client that did not advertise {@code terminal}: each + * of the five fails locally with {@link AcpCapabilityException}, from the agent and from a + * prompt's context. + * + *

    + * ACP spec 7628b153 (agentclientprotocol/agent-client-protocol), docs/protocol/v1/terminals.mdx:26, + * "If terminal is false or not present, the Agent MUST NOT attempt to call any terminal + * methods." + *

    + */ +class TerminalCapabilityTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "s1"; + + private static final String TERMINAL = "t-from-elsewhere"; + + // ACP spec 7628b153: terminals.mdx:26, "the Agent MUST NOT attempt to call any terminal methods" + @Test + void theAgentRefusesEveryTerminalMethodWithoutTheCapability() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + + for (Function> call : terminalCalls()) { + assertThatThrownBy(() -> call.apply(agent).block(TIMEOUT)).isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("terminal"); + } + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + } + + // ACP spec 7628b153: terminals.mdx:26, "the Agent MUST NOT attempt to call any terminal methods" + @Test + void aPromptContextRefusesEveryTerminalMethodWithoutTheCapability() { + List failures = new CopyOnWriteArrayList<>(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()).promptHandler((request, context) -> { + List calls = List.of( + () -> context.client().createTerminal(new AcpSchema.CreateTerminalRequest(SESSION, "ls", List.of(), null, + List.of(), null)), + () -> context.client().getTerminalOutput(new AcpSchema.TerminalOutputRequest(SESSION, TERMINAL)), + () -> context.client().waitForTerminalExit(new AcpSchema.WaitForTerminalExitRequest(SESSION, TERMINAL)), + () -> context.client().killTerminal(new AcpSchema.KillTerminalCommandRequest(SESSION, TERMINAL)), + () -> context.client().releaseTerminal(new AcpSchema.ReleaseTerminalRequest(SESSION, TERMINAL))); + for (Runnable call : calls) { + try { + call.run(); + } + catch (Throwable failure) { + failures.add(failure); + } + } + return PromptResponse.endTurn(); + }).build(); + agent.start(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + client.prompt(new AcpSchema.PromptRequest(SESSION, List.of(new AcpSchema.TextContent("go")))) + .block(TIMEOUT); + + assertThat(failures).hasSize(5).allSatisfy(failure -> assertThat(failure) + .isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("terminal")); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + } + } + + private static List>> terminalCalls() { + return List.of( + a -> a.createTerminal( + new AcpSchema.CreateTerminalRequest(SESSION, "ls", List.of(), null, List.of(), null)), + a -> a.getTerminalOutput(new AcpSchema.TerminalOutputRequest(SESSION, TERMINAL)), + a -> a.waitForTerminalExit(new AcpSchema.WaitForTerminalExitRequest(SESSION, TERMINAL)), + a -> a.killTerminal(new AcpSchema.KillTerminalCommandRequest(SESSION, TERMINAL)), + a -> a.releaseTerminal(new AcpSchema.ReleaseTerminalRequest(SESSION, TERMINAL))); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/UnstableApiMarkerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/UnstableApiMarkerTest.java new file mode 100644 index 00000000..f85e502d --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/UnstableApiMarkerTest.java @@ -0,0 +1,438 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaMember; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * {@link UnstableAcpApi} marks exactly the public API that serves a protocol method or field + * the stable schema does not define. The unstable set is derived from the stable schema copy + * ({@code schema/v1/schema.json}): an {@code AcpSchema.METHOD_*} constant whose method the + * schema lacks is unstable, and so is everything that serves it: the agent builders' handler + * setter and handler interface for it, its request and response types, and every public method + * whose signature names an unstable type (the client methods among them). A record component + * the schema's definition of the record lacks is unstable, and so are the + * {@code NegotiatedCapabilities} members named for it. When a method is promoted into the + * stable schema, the markers on it must go: a stable method's constant and setter fail here + * while marked. + * + *

    The marker has class retention, so it is read from the bytecode (ArchUnit), not by + * reflection. + */ +class UnstableApiMarkerTest { + + private static final JavaClasses CLASSES = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages("com.agentclientprotocol.sdk"); + + private static final JsonNode SCHEMA = readSchema(); + + /** The protocol methods the stable schema defines. */ + private static final Set STABLE_METHODS = stableMethods(); + + /** The {@code AcpSchema.METHOD_*} constants, by field name. */ + private static final Map METHOD_CONSTANTS = methodConstants(); + + private static final Set UNSTABLE_METHODS = unstableMethods(); + + @Test + void theSchemaCopyLeavesMethodsUnstable() { + // The derivation is not vacuous: session/fork and providers/* are not in schema 1.9.1. + assertThat(UNSTABLE_METHODS).contains(AcpSchema.METHOD_SESSION_FORK, AcpSchema.METHOD_PROVIDERS_LIST); + assertThat(STABLE_METHODS).contains(AcpSchema.METHOD_SESSION_PROMPT); + } + + @Test + void methodConstantsAreMarkedExactlyWhenTheMethodIsUnstable() { + List violations = new ArrayList<>(); + JavaClass schema = CLASSES.get(AcpSchema.class); + METHOD_CONSTANTS.forEach((field, method) -> { + boolean marked = schema.getField(field).isAnnotatedWith(UnstableAcpApi.class); + if (marked != UNSTABLE_METHODS.contains(method)) { + violations.add("AcpSchema." + field + " (" + method + ")" + (marked ? " is marked" : " is not marked")); + } + }); + assertThat(violations).isEmpty(); + } + + @Test + void builderHandlersAreMarkedExactlyWhenTheirMethodIsUnstable() throws Exception { + List violations = new ArrayList<>(); + for (Class builderType : List.of(AcpAgent.AsyncAgentBuilder.class, AcpAgent.SyncAgentBuilder.class)) { + for (Method setter : handlerSetters(builderType)) { + String method = registeredMethod(builderType, setter); + boolean unstable = UNSTABLE_METHODS.contains(method); + String where = builderType.getSimpleName() + "." + setter.getName() + " (" + method + ")"; + if (isMarked(member(setter)) != unstable) { + violations.add(where + (unstable ? " is not marked" : " is marked")); + } + Class handlerType = setter.getParameterTypes()[0]; + // The agent-aware handlers are one generic interface shared by every method; their + // request and response types are the setter's type arguments. + boolean shared = handlerType.getTypeParameters().length > 0; + if (!shared && isMarked(CLASSES.get(handlerType)) != unstable) { + violations.add(where + ": " + handlerType.getSimpleName() + (unstable ? " is not marked" : " is marked")); + } + if (unstable) { + for (Class type : shared ? typeArguments(setter) : requestAndResponseTypes(handlerType)) { + if (!isMarked(CLASSES.get(type))) { + violations.add(where + ": " + type.getSimpleName() + " is not marked"); + } + } + } + } + } + assertThat(violations).isEmpty(); + } + + @Test + void publicMethodsNamingAnUnstableTypeAreMarked() { + Set unstableTypes = new HashSet<>(); + CLASSES.stream().filter(UnstableApiMarkerTest::isMarked).forEach(type -> unstableTypes.add(type.getName())); + List violations = new ArrayList<>(); + for (JavaClass javaClass : CLASSES) { + if (isMarked(javaClass) || !isPublicApi(javaClass)) { + continue; + } + Class type = javaClass.reflect(); + for (Method method : type.getDeclaredMethods()) { + if (!Modifier.isPublic(method.getModifiers()) || method.isSynthetic() || method.isBridge()) { + continue; + } + boolean namesUnstable = Stream + .concat(Stream.of(method.getGenericReturnType()), Arrays.stream(method.getGenericParameterTypes())) + .anyMatch(t -> names(t, unstableTypes)); + if (namesUnstable && !isMarked(member(method))) { + violations.add(type.getName() + "." + method.getName()); + } + } + } + assertThat(violations).isEmpty(); + } + + @Test + void recordComponentsTheSchemaLacksAreMarked() { + List violations = new ArrayList<>(); + for (String component : unstableComponents()) { + String[] parts = component.split("#"); + Class record = recordNamed(parts[0]); + JavaMember accessor = CLASSES.get(record).getMethod(parts[1]); + if (!isMarked(accessor)) { + violations.add(component); + } + } + assertThat(violations).isEmpty(); + } + + /** + * A convenience constructor that sets an unstable component is unstable too, although the + * record is not. Which parameter feeds which component is found by building the record with + * a value in one parameter and nulls elsewhere. + */ + @Test + void constructorsSettingAnUnstableComponentAreMarked() throws Exception { + List violations = new ArrayList<>(); + for (String component : unstableComponents()) { + String[] parts = component.split("#"); + Class record = recordNamed(parts[0]); + Method accessor = record.getMethod(parts[1]); + Class[] canonical = Arrays.stream(record.getRecordComponents()) + .map(RecordComponent::getType) + .toArray(Class[]::new); + for (java.lang.reflect.Constructor constructor : record.getConstructors()) { + if (Arrays.equals(constructor.getParameterTypes(), canonical)) { + continue; + } + for (int i = 0; i < constructor.getParameterCount(); i++) { + if (!constructor.getParameterTypes()[i].isAssignableFrom(Map.class)) { + continue; + } + Object[] args = new Object[constructor.getParameterCount()]; + args[i] = Map.of(); + Object built; + try { + built = constructor.newInstance(args); + } + catch (ReflectiveOperationException | RuntimeException ex) { + continue; + } + JavaMember member = CLASSES.get(record).getConstructor(constructor.getParameterTypes()); + if (accessor.invoke(built) != null && !isMarked(member)) { + violations.add(constructor + " sets " + component); + } + } + } + } + assertThat(violations).isEmpty(); + } + + @Test + void negotiatedCapabilitiesForUnstableComponentsAreMarked() { + Set unstableNames = new TreeSet<>(); + for (String component : unstableComponents()) { + if (component.startsWith("AgentCapabilities#") || component.startsWith("SessionCapabilities#")) { + unstableNames.add(component.substring(component.indexOf('#') + 1).toLowerCase()); + } + } + assertThat(unstableNames).contains("fork", "providers"); + List violations = new ArrayList<>(); + for (Class type : List.of(NegotiatedCapabilities.class, NegotiatedCapabilities.Builder.class)) { + for (Method method : type.getDeclaredMethods()) { + String name = method.getName().toLowerCase(); + if (Modifier.isPublic(method.getModifiers()) + && unstableNames.stream().anyMatch(name::contains) && !isMarked(member(method))) { + violations.add(type.getSimpleName() + "." + method.getName()); + } + } + } + assertThat(violations).isEmpty(); + } + + // --------------------------------------------------------------------------------------- + + private static boolean isMarked(JavaClass type) { + if (type.isAnnotatedWith(UnstableAcpApi.class)) { + return true; + } + return type.getEnclosingClass().map(UnstableApiMarkerTest::isMarked).orElse(false); + } + + /** A member is unstable when it, or a type enclosing it, is marked. */ + private static boolean isMarked(JavaMember member) { + return member.isAnnotatedWith(UnstableAcpApi.class) || isMarked(member.getOwner()); + } + + private static JavaMember member(Method method) { + return CLASSES.get(method.getDeclaringClass()).getMethod(method.getName(), method.getParameterTypes()); + } + + private static boolean isPublicApi(JavaClass type) { + Class reflected = type.reflect(); + for (Class c = reflected; c != null; c = c.getEnclosingClass()) { + if (!Modifier.isPublic(c.getModifiers()) && !(c.isMemberClass() && c.getEnclosingClass().isInterface())) { + return false; + } + } + return !reflected.isAnonymousClass() && !reflected.isLocalClass() && !reflected.isSynthetic(); + } + + private static boolean names(Type type, Set typeNames) { + if (type instanceof Class c) { + return c.isArray() ? names(c.getComponentType(), typeNames) : typeNames.contains(c.getName()); + } + if (type instanceof ParameterizedType p) { + return names(p.getRawType(), typeNames) + || Arrays.stream(p.getActualTypeArguments()).anyMatch(t -> names(t, typeNames)); + } + if (type instanceof WildcardType w) { + return Stream.concat(Arrays.stream(w.getUpperBounds()), Arrays.stream(w.getLowerBounds())) + .anyMatch(t -> names(t, typeNames)); + } + if (type instanceof GenericArrayType a) { + return names(a.getGenericComponentType(), typeNames); + } + return false; + } + + /** The builder's {@code xxxHandler(handler)} setters, without the per-name extension ones. */ + /** The builder's handler setters, with those it inherits from its agent-aware setters class. */ + private static List handlerSetters(Class builderType) { + return Stream.concat(Arrays.stream(builderType.getDeclaredMethods()), + Arrays.stream(builderType.getSuperclass().getDeclaredMethods())) + .filter(method -> !method.isSynthetic() && !method.isBridge()) + .filter(method -> Modifier.isPublic(method.getModifiers()) && method.getName().endsWith("Handler") + && method.getParameterCount() == 1 && method.getParameterTypes()[0].isInterface()) + .toList(); + } + + /** The ACP method a setter registers its handler for, read from a fresh builder. */ + private static String registeredMethod(Class builderType, Method setter) throws Exception { + AcpAgentTransport transport = mock(AcpAgentTransport.class); + Object builder = builderType == AcpAgent.AsyncAgentBuilder.class ? AcpAgent.async(transport) + : AcpAgent.sync(transport); + Class handlerType = setter.getParameterTypes()[0]; + Object handler = Proxy.newProxyInstance(handlerType.getClassLoader(), new Class[] { handlerType }, + (proxy, method, args) -> null); + setter.invoke(builder, handler); + AgentHandlers handlers = agentHandlers(builder); + List methods = new ArrayList<>(); + handlers.requests().forEach(request -> methods.add(request.method())); + handlers.notifications().forEach(notification -> methods.add(notification.method())); + assertThat(methods).as(setter.getName()).hasSize(1); + return methods.get(0); + } + + private static AgentHandlers agentHandlers(Object builder) throws ReflectiveOperationException { + Object asyncBuilder = builder; + if (builder instanceof AcpAgent.SyncAgentBuilder) { + Field field = AcpAgent.SyncAgentBuilder.class.getDeclaredField("asyncBuilder"); + field.setAccessible(true); + asyncBuilder = field.get(builder); + } + Field field = AcpAgent.AsyncAgentBuilder.class.getDeclaredField("handlers"); + field.setAccessible(true); + return (AgentHandlers) field.get(asyncBuilder); + } + + /** The request type a handler interface takes and the response type it returns. */ + private static List> typeArguments(Method setter) { + ParameterizedType parameter = (ParameterizedType) setter.getGenericParameterTypes()[0]; + return Arrays.stream(parameter.getActualTypeArguments()).>map(type -> (Class) type).toList(); + } + + private static List> requestAndResponseTypes(Class handlerType) { + Method handle = Arrays.stream(handlerType.getMethods()) + .filter(method -> Modifier.isAbstract(method.getModifiers())) + .findFirst() + .orElseThrow(); + List> types = new ArrayList<>(); + types.add(handle.getParameterTypes()[0]); + Type returnType = handle.getGenericReturnType(); + if (returnType instanceof ParameterizedType p) { + returnType = p.getActualTypeArguments()[0]; + } + if (returnType instanceof Class c && c != void.class && c != Void.class) { + types.add(c); + } + return types; + } + + /** + * {@code Record#component} for every component of an {@code AcpSchema} capability record + * whose JSON property the schema's definition of that record lacks: an agent advertises an + * unstable method in its capabilities, so those components serve one. + */ + private static Set unstableComponents() { + Set components = new TreeSet<>(); + JsonNode defs = SCHEMA.get("$defs"); + for (Class nested : AcpSchema.class.getDeclaredClasses()) { + if (!nested.isRecord() || !nested.getSimpleName().endsWith("Capabilities") + || !defs.has(nested.getSimpleName())) { + continue; + } + Set properties = properties(defs.get(nested.getSimpleName())); + for (RecordComponent component : nested.getRecordComponents()) { + JsonProperty json = field(nested, component.getName()).getAnnotation(JsonProperty.class); + String name = json != null ? json.value() : component.getName(); + if (!properties.contains(name)) { + components.add(nested.getSimpleName() + "#" + component.getName()); + } + } + } + return components; + } + + /** A definition's property names, with those of the definitions it composes. */ + private static Set properties(JsonNode definition) { + Set names = new TreeSet<>(); + JsonNode properties = definition.get("properties"); + if (properties != null) { + properties.fieldNames().forEachRemaining(names::add); + } + JsonNode ref = definition.get("$ref"); + if (ref != null) { + names.addAll(properties(SCHEMA.get("$defs").get(ref.asText().substring("#/$defs/".length())))); + } + for (String composition : List.of("allOf", "anyOf", "oneOf")) { + JsonNode parts = definition.get(composition); + if (parts != null) { + parts.forEach(part -> names.addAll(properties(part))); + } + } + return names; + } + + private static Field field(Class record, String name) { + try { + return record.getDeclaredField(name); + } + catch (NoSuchFieldException ex) { + throw new IllegalStateException(ex); + } + } + + private static Class recordNamed(String simpleName) { + return Arrays.stream(AcpSchema.class.getDeclaredClasses()) + .filter(c -> c.getSimpleName().equals(simpleName)) + .findFirst() + .orElseThrow(); + } + + private static JsonNode readSchema() { + try (InputStream in = UnstableApiMarkerTest.class.getResourceAsStream("/schema/v1/schema.json")) { + return new ObjectMapper().readTree(in); + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + private static Set stableMethods() { + Set methods = new TreeSet<>(); + SCHEMA.findValues("x-method").forEach(node -> methods.add(node.asText())); + return methods; + } + + private static Map methodConstants() { + Map constants = new LinkedHashMap<>(); + for (Field field : AcpSchema.class.getDeclaredFields()) { + int modifiers = field.getModifiers(); + if (field.getName().startsWith("METHOD_") && Modifier.isPublic(modifiers) && Modifier.isStatic(modifiers) + && field.getType() == String.class) { + try { + constants.put(field.getName(), (String) field.get(null)); + } + catch (IllegalAccessException ex) { + throw new IllegalStateException(ex); + } + } + } + return constants; + } + + private static Set unstableMethods() { + Set methods = new TreeSet<>(METHOD_CONSTANTS.values()); + methods.removeAll(STABLE_METHODS); + return methods; + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/EndOfInputAgent.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/EndOfInputAgent.java new file mode 100644 index 00000000..5ded7ace --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/EndOfInputAgent.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import reactor.core.publisher.Mono; + +/** + * A stdio agent run as a child process by {@link StdioAgentEndOfInputProcessTest}, written + * as the README documents: start, await termination, exit. A prompt answers after a delay, so + * that the client's input has ended by then; the prompt "ask" first asks the client for + * permission, which it can no longer give, and reports how that ended. + */ +public final class EndOfInputAgent { + + static final Duration PROMPT_DELAY = Duration.ofMillis(300); + + private EndOfInputAgent() { + } + + public static void main(String[] args) { + AcpAsyncAgent agent = AcpAgent.async(new StdioAcpAgentTransport()) + .initializeHandler(request -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new NewSessionResponse("s-1", null))) + .promptHandler((request, context) -> { + Mono text = "ask".equals(request.text()) + ? context.askPermission("run the tests") + .map(granted -> "permission " + granted) + .onErrorResume(e -> Mono.just("permission failed: " + e.getMessage())) + : Mono.just("hello"); + return Mono.delay(PROMPT_DELAY) + .then(text) + .flatMap(context::sendMessage) + .then(Mono.just(PromptResponse.endTurn())); + }) + .build(); + agent.start().then(agent.awaitTermination()).block(); + System.exit(0); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnectionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnectionTest.java new file mode 100644 index 00000000..6fd31aee --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/RemoteAcpConnectionTest.java @@ -0,0 +1,354 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.PinnedVirtualThreads; +import com.agentclientprotocol.sdk.QuietLoggers; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCResponse; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Tests for {@link RemoteAcpConnection}, the per-connection core that the Streamable HTTP + * and WebSocket agent transports share, driven directly without a wire adapter. + */ +class RemoteAcpConnectionTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + private final List outbound = new CopyOnWriteArrayList<>(); + + private final CountDownLatch promptStarted = new CountDownLatch(1); + + private final CountDownLatch promptCancelled = new CountDownLatch(1); + + private final AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("session-1", null, null))) + .promptHandler((request, context) -> Mono.never() + .doOnSubscribe(subscription -> promptStarted.countDown()) + .doOnCancel(promptCancelled::countDown)) + .build()); + + @Test + void constructorValidatesArguments() { + assertThatThrownBy(() -> new RemoteAcpConnection("", jsonMapper, outbound::add)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("id"); + assertThatThrownBy(() -> new RemoteAcpConnection("c1", null, outbound::add)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("jsonMapper"); + assertThatThrownBy(() -> new RemoteAcpConnection("c1", jsonMapper, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("outboundConsumer"); + assertThat(new RemoteAcpConnection("c1", jsonMapper, outbound::add).id()).isEqualTo("c1"); + } + + @Test + void inboundRequestIsAnsweredThroughTheOutboundConsumer() { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(agentFactory).block(TIMEOUT); + + connection.acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1))); + + JSONRPCResponse response = awaitResponse(1); + assertThat(response.error()).isNull(); + assertThat(response.result()).isNotNull(); + connection.closeGracefully().block(TIMEOUT); + } + + /** + * A listener on virtual threads (Jetty's VirtualThreadPool on JDK 21+) hands each POST to + * {@link RemoteAcpConnection#acceptInbound}, which runs the agent's handler chain on the + * caller's thread. A park there (a Jackson cache lock, a blocking handler) must not pin + * the carrier: with every carrier pinned on a lock whose owner waits for a carrier, the + * listener deadlocks (load-50 hung on JDK 21 this way). + */ + @Test + void acceptInboundDoesNotPinTheVirtualThreadItRunsOn() throws Exception { + AcpAgentFactory blocking = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.fromCallable(() -> { + Thread.sleep(50); + return AcpSchema.InitializeResponse.ok(); + })) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build()); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(blocking).block(TIMEOUT); + + List pinned = PinnedVirtualThreads.pinnedParks(() -> connection + .acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1)))); + + assertThat(awaitResponse(1).error()).isNull(); + assertThat(pinned).isEmpty(); + connection.closeGracefully().block(TIMEOUT); + } + + @Test + void startIsRefusedTheSecondTime() { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(agentFactory).block(TIMEOUT); + + // The refusal is also reported to the transport's exception handler, which logs it + try (QuietLoggers quiet = QuietLoggers.of(RemoteAcpConnection.class)) { + assertThatThrownBy(() -> connection.start(agentFactory).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Already started"); + } + connection.close(); + } + + @Test + void anUnsubscribedStartDoesNotConsumeTheStart() { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(agentFactory); // never subscribed + + connection.start(agentFactory).block(TIMEOUT); + + connection.acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1))); + assertThat(awaitResponse(1).error()).isNull(); + connection.close(); + } + + @Test + void inboundMessagesAreRefusedOnceClosing() { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(agentFactory).block(TIMEOUT); + connection.close(); + + assertThatThrownBy(() -> connection + .acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1)))) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("closing"); + } + + @Test + void closeCancelsAnInFlightHandler() throws InterruptedException { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(agentFactory).block(TIMEOUT); + connection.acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1))); + awaitResponse(1); + connection.acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_SESSION_NEW, 2, + Map.of("cwd", "/workspace", "mcpServers", List.of()))); + awaitResponse(2); + connection.acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_SESSION_PROMPT, 3, + Map.of("sessionId", "session-1", "prompt", List.of(Map.of("type", "text", "text", "hi"))))); + assertThat(promptStarted.await(5, TimeUnit.SECONDS)).isTrue(); + + connection.closeGracefully().block(TIMEOUT); + + assertThat(promptCancelled.await(5, TimeUnit.SECONDS)).as("the running prompt is cancelled").isTrue(); + } + + @Test + void closeIsIdempotentAndClosesTheAgentOnce() { + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(Mono.empty()); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(transport -> agent).block(TIMEOUT); + + connection.close(); + connection.close(); + connection.closeGracefully().block(TIMEOUT); + + verify(agent).close(); + verify(agent, never()).closeGracefully(); + } + + @Test + void closeAfterAGracefulCloseThatDoesNotFinishClosesTheAgentAtOnce() { + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(Mono.empty()); + when(agent.closeGracefully()).thenReturn(Mono.never()); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(transport -> agent).block(TIMEOUT); + + connection.closeGracefully().subscribe(); + connection.close(); + connection.close(); + + verify(agent).close(); + } + + @Test + void closeGracefullyBeforeStartClosesTheTransport() { + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + + connection.closeGracefully().block(TIMEOUT); + + assertThatThrownBy(() -> connection + .acceptInbound(new JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, 1, Map.of("protocolVersion", 1)))) + .isInstanceOf(AcpConnectionException.class); + } + + /** + * A listener that shuts down while a connection is being opened closes the connection + * before its agent exists; the start that follows must not create an agent that nothing + * will close. + */ + @Test + void startAfterAGracefulCloseCreatesNoAgent() { + AtomicInteger created = new AtomicInteger(); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add, error -> { + }); + connection.closeGracefully().block(TIMEOUT); + + assertThatThrownBy(() -> connection.start(transport -> { + created.incrementAndGet(); + return mock(AcpAsyncAgent.class); + }).block(TIMEOUT)).isInstanceOf(AcpConnectionException.class); + assertThat(created).hasValue(0); + } + + @Test + void startAfterACloseCreatesNoAgent() { + AtomicInteger created = new AtomicInteger(); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add, error -> { + }); + connection.close(); + + assertThatThrownBy(() -> connection.start(transport -> { + created.incrementAndGet(); + return mock(AcpAsyncAgent.class); + }).block(TIMEOUT)).isInstanceOf(AcpConnectionException.class); + assertThat(created).hasValue(0); + } + + /** + * A graceful close that arrives while the agent is starting closes it once it has started: + * closed before, an agent such as the default one has nothing to close yet and its start + * would then open a session that nothing closes. + */ + @Test + void aGracefulCloseDuringTheAgentStartClosesTheAgentAfterItStarted() { + List events = new CopyOnWriteArrayList<>(); + Sinks.Empty startGate = Sinks.empty(); + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(startGate.asMono().doOnSuccess(v -> events.add("started"))); + when(agent.closeGracefully()).thenReturn(Mono.fromRunnable(() -> events.add("closed"))); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(transport -> agent).subscribe(); + + CompletableFuture closed = connection.closeGracefully().toFuture(); + startGate.tryEmitEmpty(); + + closed.join(); + assertThat(events).containsExactly("started", "closed"); + } + + @Test + void aFailingAgentCloseIsReportedAndTheTransportStillCloses() { + AtomicReference transportRef = new AtomicReference<>(); + AtomicReference reported = new AtomicReference<>(); + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(Mono.empty()); + when(agent.closeGracefully()).thenReturn(Mono.error(new IllegalStateException("close failed"))); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(transport -> { + transportRef.set(transport); + transport.setExceptionHandler(reported::set); + return agent; + }).block(TIMEOUT); + + connection.closeGracefully().block(TIMEOUT); + + assertThat(reported.get()).hasMessage("close failed"); + transportRef.get().awaitTermination().block(TIMEOUT); + assertThatThrownBy(() -> transportRef.get().sendMessage(new JSONRPCRequest("x", 9, null)).block(TIMEOUT)) + .isInstanceOf(AcpConnectionException.class); + } + + /** + * The handler the host gives the connection receives its transport errors when the + * agent runtime installs none, as no runtime does: before, they were only logged. + */ + @Test + void transportExceptionsReachTheHandlerTheHostGave() { + AtomicReference reported = new AtomicReference<>(); + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(Mono.empty()); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add, reported::set); + connection.start(transport -> agent).block(TIMEOUT); + + connection.signalException(new IllegalStateException("wire broke")); + + assertThat(reported.get()).hasMessage("wire broke"); + } + + @Test + void agentMessagesReachTheOutboundConsumerAndTransportExceptionsTheHandler() { + AtomicReference transportRef = new AtomicReference<>(); + AtomicReference reported = new AtomicReference<>(); + AcpAsyncAgent agent = mock(AcpAsyncAgent.class); + when(agent.start()).thenReturn(Mono.empty()); + RemoteAcpConnection connection = new RemoteAcpConnection("c1", jsonMapper, outbound::add); + connection.start(transport -> { + transportRef.set(transport); + transport.setExceptionHandler(reported::set); + return agent; + }).block(TIMEOUT); + AcpAgentTransport transport = transportRef.get(); + + transport.sendMessage(new JSONRPCRequest("session/request_permission", 7, null)).block(TIMEOUT); + connection.signalException(new IllegalStateException("wire broke")); + + assertThat(outbound).singleElement() + .isInstanceOfSatisfying(JSONRPCRequest.class, request -> assertThat(request.id()).isEqualTo(7)); + assertThat(reported.get()).hasMessage("wire broke"); + assertThat(transport.unmarshalFrom(Map.of("protocolVersion", 1), + new TypeRef() { + }).protocolVersion()).isEqualTo(1); + // The mock agent never started the transport: the first start succeeds, the second is refused + transport.start(message -> message).block(TIMEOUT); + assertThatThrownBy(() -> transport.start(message -> message).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Already started"); + connection.close(); + } + + private JSONRPCResponse awaitResponse(Object id) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + for (JSONRPCMessage message : outbound) { + if (message instanceof JSONRPCResponse response && id.equals(response.id())) { + return response; + } + } + Thread.onSpinWait(); + } + throw new AssertionError("No response with id " + id + " in " + outbound); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransportTest.java index fea45ccc..ce613a40 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransportTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAcpAgentTransportTest.java @@ -4,23 +4,38 @@ package com.agentclientprotocol.sdk.agent.transport; +import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import com.agentclientprotocol.sdk.AcpTestFixtures; +import com.agentclientprotocol.sdk.CapturedLogs; +import com.agentclientprotocol.sdk.QuietLoggers; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Schedulers; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Unit tests for {@link StdioAcpAgentTransport}. @@ -55,10 +70,101 @@ void startTwiceFails() { @Test void protocolVersionsReturnsLatest() { - StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper); + // Never System.in in a test: under Surefire it carries the fork's command stream. + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, + new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream()); assertThat(transport.protocolVersions()).contains(AcpSchema.LATEST_PROTOCOL_VERSION); } + /** + * A read on System.in cannot be interrupted, so closing cannot end it: the reader stays + * blocked until the next line arrives. That line, and anything after it, is not handed + * on and not answered, and the reader thread ends at once without reading again. + */ + @Test + void aReaderThatWakesAfterCloseHandsNothingOnAndEnds() throws Exception { + String line = jsonMapper.writeValueAsString(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, + "1", AcpTestFixtures.createInitializeRequest())) + "\n"; + UninterruptibleInput in = new UninterruptibleInput(line.getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, in, out); + List handed = new CopyOnWriteArrayList<>(); + transport.start(messages -> messages.doOnNext(handed::add).then(Mono.empty())).block(TIMEOUT); + Thread reader = in.awaitReader(); + + transport.closeGracefully().block(TIMEOUT); + assertThat(reader.isAlive()).as("a blocked read outlives the close").isTrue(); + in.release(); + + reader.join(TIMEOUT.toMillis()); + assertThat(reader.isAlive()).as("the reader ends once its read returns").isFalse(); + assertThat(in.reads()).as("the reader does not read again").isEqualTo(1); + assertThat(handed).isEmpty(); + assertThat(out.toString(StandardCharsets.UTF_8)).isEmpty(); + } + + /** Blocks its first read, ignoring interrupts as a read on System.in does, until released. */ + private static final class UninterruptibleInput extends java.io.InputStream { + + private final byte[] line; + + private final CountDownLatch released = new CountDownLatch(1); + + private final java.util.concurrent.CompletableFuture reader = new java.util.concurrent.CompletableFuture<>(); + + private final java.util.concurrent.atomic.AtomicInteger reads = new java.util.concurrent.atomic.AtomicInteger(); + + UninterruptibleInput(byte[] line) { + this.line = line; + } + + @Override + public int read() { + throw new UnsupportedOperationException(); + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (reads.incrementAndGet() > 1) { + awaitUninterruptibly(new CountDownLatch(1)); + } + reader.complete(Thread.currentThread()); + awaitUninterruptibly(released); + int count = Math.min(length, line.length); + System.arraycopy(line, 0, buffer, offset, count); + return count; + } + + Thread awaitReader() throws Exception { + return reader.get(5, TimeUnit.SECONDS); + } + + void release() { + released.countDown(); + } + + int reads() { + return reads.get(); + } + + private static void awaitUninterruptibly(CountDownLatch latch) { + boolean interrupted = false; + while (true) { + try { + latch.await(); + break; + } + catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + } + + } + @Test void receivesMessageFromInputStream() throws Exception { // Create a message to send @@ -147,4 +253,283 @@ void closeGracefullyCompletesWithoutError() { // Test passes if no exception is thrown } + /** + * A line that is not a JSON-RPC message is reported, answered as JSON-RPC 2.0 says + * (-32700 for text that is not JSON, -32600 for JSON that is no message, both with a + * null id), and skipped: the message after it still arrives. A blank line is skipped + * silently. Before, the first such line ended the inbound stream for good. + */ + @Test + void aMalformedLineIsReportedAnsweredAndSkipped() throws Exception { + PipedOutputStream clientOut = new PipedOutputStream(); + PipedInputStream agentIn = new PipedInputStream(clientOut, 65536); + PipedOutputStream agentOut = new PipedOutputStream(); + PipedInputStream clientIn = new PipedInputStream(agentOut, 65536); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, agentIn, agentOut); + List handled = new CopyOnWriteArrayList<>(); + List reported = new CopyOnWriteArrayList<>(); + transport.setExceptionHandler(reported::add); + transport.start(message -> message.map(m -> { + AcpSchema.JSONRPCRequest request = (AcpSchema.JSONRPCRequest) m; + handled.add(request.id()); + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), "ok", null); + })).block(TIMEOUT); + + String first = jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/one", "1", null)); + String second = jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/two", "2", null)); + List written = new CopyOnWriteArrayList<>(); + try (QuietLoggers quiet = QuietLoggers.of(StdioAcpAgentTransport.class)) { + clientOut.write((first + "\n{not json\n\n42\n" + second + "\n").getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + BufferedReader reader = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + Mono.fromCallable(() -> { + for (int i = 0; i < 4; i++) { + written.add(reader.readLine()); + } + return written; + }).subscribeOn(Schedulers.boundedElastic()).block(TIMEOUT); + } + finally { + transport.closeGracefully().block(TIMEOUT); + } + + assertThat(handled).containsExactly("1", "2"); + assertThat(reported).hasSize(2); + assertThat(written).filteredOn(line -> line.contains("\"id\":null")) + .extracting(line -> ((AcpSchema.JSONRPCResponse) AcpSchema.deserializeJsonRpcMessage(jsonMapper, line)) + .error() + .code()) + .containsExactly(-32700, -32600); + assertThat(written).filteredOn(line -> line.contains("\"result\":\"ok\"")).hasSize(2); + } + + /** + * An invalid request is the client's payload and can carry the user's prompt: it is + * answered -32600, and no log at INFO or above, the default exception handler's included, + * quotes it. + */ + @Test + void anInvalidRequestIsNotLoggedAboveDebug() throws Exception { + String invalid = "{\"id\":1,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\"," + + "\"prompt\":[{\"type\":\"text\",\"text\":\"SECRET-123\"}]}}\n"; + PipedOutputStream agentOut = new PipedOutputStream(); + PipedInputStream clientIn = new PipedInputStream(agentOut, 65536); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, + new ByteArrayInputStream(invalid.getBytes(StandardCharsets.UTF_8)), agentOut); + String answer; + try (CapturedLogs logs = CapturedLogs.open()) { + transport.start(message -> message).block(TIMEOUT); + BufferedReader reader = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + answer = Mono.fromCallable(reader::readLine).subscribeOn(Schedulers.boundedElastic()).block(TIMEOUT); + transport.closeGracefully().block(TIMEOUT); + + assertThat(logs.events()).as("the refusal is logged").isNotEmpty(); + logs.assertNoneAtInfoOrAboveContains("SECRET-123"); + } + assertThat(answer).contains("-32600"); + } + + /** + * A client that writes its requests and closes the agent's standard input still reads + * standard output: every request received before the end is answered, after the + * notifications its handler sends, in order; only then does the transport terminate, with + * standard output closed. Before, the end of input completed awaitTermination() at once + * (the documented pattern then exits) and every reply still to come was dropped. + */ + @Test + void everyRequestReceivedBeforeTheInputEndsIsAnsweredBeforeTermination() throws Exception { + int requests = 10; + StringBuilder input = new StringBuilder(); + for (int i = 1; i <= requests; i++) { + input.append(jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/echo", i, null))) + .append('\n'); + } + LineCapture out = new LineCapture(); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, + new ByteArrayInputStream(input.toString().getBytes(StandardCharsets.UTF_8)), out); + List> gates = new ArrayList<>(); + for (int i = 0; i <= requests; i++) { + gates.add(Sinks.one()); + } + CountDownLatch allReceived = new CountDownLatch(requests); + transport.start(message -> message.flatMap(m -> { + AcpSchema.JSONRPCRequest request = (AcpSchema.JSONRPCRequest) m; + int id = ((Number) request.id()).intValue(); + allReceived.countDown(); + return gates.get(id) + .asMono() + .then(transport.sendMessage(new AcpSchema.JSONRPCNotification("test/progress", Map.of("id", id)))) + .then(Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), "done", null))); + })).block(TIMEOUT); + AtomicBoolean terminated = new AtomicBoolean(); + transport.awaitTermination().subscribe(null, null, () -> terminated.set(true)); + + assertThat(allReceived.await(5, TimeUnit.SECONDS)).isTrue(); + Thread.sleep(300); + assertThat(terminated).as("terminated with %d requests unanswered", requests).isFalse(); + + for (int i = 1; i <= requests; i++) { + gates.get(i).tryEmitEmpty(); + } + transport.awaitTermination().block(TIMEOUT); + + List expected = new ArrayList<>(); + for (int i = 1; i <= requests; i++) { + expected.add("test/progress " + i); + expected.add("reply " + i); + } + assertThat(out.lines()).extracting(this::describe).containsExactlyElementsOf(expected); + assertThat(out.closed).as("standard output closed").isTrue(); + } + + /** + * A request still unanswered when the drain timeout passes is answered with -32800 + * (request cancelled), and the transport terminates without waiting for it. + */ + @Test + void aRequestUnansweredAfterTheDrainTimeoutIsCancelled() throws Exception { + String input = jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/stuck", 1, null)) + "\n" + + jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/quick", 2, null)) + "\n"; + LineCapture out = new LineCapture(); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, + new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), out, Duration.ofMillis(200)); + transport.start(message -> message.flatMap(m -> { + AcpSchema.JSONRPCRequest request = (AcpSchema.JSONRPCRequest) m; + if ("test/stuck".equals(request.method())) { + return Mono.never(); + } + return Mono.just(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), "done", null)); + })).block(TIMEOUT); + + try (QuietLoggers quiet = QuietLoggers.of(StdioAcpAgentTransport.class)) { + transport.awaitTermination().block(TIMEOUT); + } + + assertThat(out.lines()).extracting(this::describe).containsExactly("reply 2", "error 1 -32800"); + assertThat(out.closed).isTrue(); + } + + /** + * A request the agent sent to the client that is still unanswered when the client closes + * its input fails at once: the session receives an error response for it, which lets a + * handler waiting on it answer its own request before the drain timeout. + */ + @Test + void aRequestToTheClientFailsWhenTheInputEnds() throws Exception { + PipedOutputStream clientOut = new PipedOutputStream(); + PipedInputStream agentIn = new PipedInputStream(clientOut, 65536); + LineCapture out = new LineCapture(); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, agentIn, out); + Sinks.One answer = Sinks.one(); + transport.start(message -> message.flatMap(m -> { + if (m instanceof AcpSchema.JSONRPCResponse response) { + answer.tryEmitValue(response); + return Mono.empty(); + } + AcpSchema.JSONRPCRequest request = (AcpSchema.JSONRPCRequest) m; + return transport.sendMessage(new AcpSchema.JSONRPCRequest("client/ask", "agent-1", null)) + .then(answer.asMono()) + .map(response -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), + "client answered " + response.error().code(), null)); + })).block(TIMEOUT); + + clientOut.write((jsonMapper.writeValueAsString(new AcpSchema.JSONRPCRequest("test/ask", 1, null)) + "\n") + .getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + assertThat(out.next()).contains("client/ask"); + clientOut.close(); + + transport.awaitTermination().block(TIMEOUT); + assertThat(out.lines()).extracting(this::describe).containsExactly("client/ask agent-1", "reply 1"); + assertThat(answer.asMono().block(TIMEOUT).error().message()) + .isEqualTo(StdioAcpAgentTransport.CLIENT_INPUT_ENDED); + assertThat(out.lines().get(1)).contains("client answered -32603"); + } + + @Test + void drainTimeoutMustBePositive() { + ByteArrayInputStream in = new ByteArrayInputStream(new byte[0]); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThatThrownBy(() -> new StdioAcpAgentTransport(jsonMapper, in, out, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new StdioAcpAgentTransport(jsonMapper, in, out, Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + + /** A written line in brief: a notification's method and id, a reply's id, an error's id and code. */ + private String describe(String line) { + AcpSchema.JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, line); + } + catch (java.io.IOException e) { + throw new java.io.UncheckedIOException(e); + } + if (message instanceof AcpSchema.JSONRPCNotification notification) { + return notification.method() + " " + ((Map) notification.params()).get("id"); + } + if (message instanceof AcpSchema.JSONRPCRequest request) { + return request.method() + " " + request.id(); + } + AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) message; + return response.error() != null ? "error " + response.id() + " " + response.error().code() + : "reply " + response.id(); + } + + /** Standard output in memory: the lines written, as they are written, and whether it was closed. */ + static final class LineCapture extends OutputStream { + + private final ByteArrayOutputStream line = new ByteArrayOutputStream(); + + private final List lines = new CopyOnWriteArrayList<>(); + + private final BlockingQueue unread = new LinkedBlockingQueue<>(); + + volatile boolean closed; + + @Override + public synchronized void write(int b) { + if (b == '\n') { + String text = line.toString(StandardCharsets.UTF_8); + line.reset(); + lines.add(text); + unread.add(text); + } + else { + line.write(b); + } + } + + @Override + public void close() { + closed = true; + } + + List lines() { + return lines; + } + + String next() throws InterruptedException { + String next = unread.poll(5, TimeUnit.SECONDS); + assertThat(next).as("a line written within 5 seconds").isNotNull(); + return next; + } + + } + + /** A message sent once the transport is closed fails; it used to be dropped silently. */ + @Test + void aMessageSentAfterCloseFails() { + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, new java.io.PipedInputStream(), + new ByteArrayOutputStream()); + transport.start(msg -> msg).block(TIMEOUT); + transport.closeGracefully().block(TIMEOUT); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> transport + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "session/update", null)) + .block(TIMEOUT)) + .isInstanceOf(com.agentclientprotocol.sdk.error.AcpConnectionException.class); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentBlockedOutputTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentBlockedOutputTest.java new file mode 100644 index 00000000..34768adf --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentBlockedOutputTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.Pipe; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A client that stops reading the agent's standard output fills the pipe and blocks the + * transport's writer. Messages that several threads send meanwhile (user threads, and the + * inbound thread answering requests) must all be written, in each sender's order, once the + * client reads again: none is lost, none fails. + */ +class StdioAgentBlockedOutputTest { + + private static final int SENDERS = 8; + + private static final int PER_SENDER = 200; + + private static final int REQUESTS = 200; + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + @Test + void messagesSentWhileTheClientDoesNotReadAreAllWrittenOnceItDoes() throws Exception { + Pipe toClient = Pipe.open(); + Pipe toAgent = Pipe.open(); + OutputStream agentOut = Channels.newOutputStream(toClient.sink()); + InputStream clientIn = Channels.newInputStream(toClient.source()); + InputStream agentIn = Channels.newInputStream(toAgent.source()); + OutputStream clientOut = Channels.newOutputStream(toAgent.sink()); + + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(jsonMapper, agentIn, agentOut); + transport.start(request -> request.map(message -> { + AcpSchema.JSONRPCRequest r = (AcpSchema.JSONRPCRequest) message; + return (AcpSchema.JSONRPCMessage) new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, r.id(), + Map.of("n", r.id()), null); + })).block(Duration.ofSeconds(5)); + + // Fill the pipe: the client is not reading, so the writer blocks in write(). + String filler = "x".repeat(16 * 1024); + // On a separate thread: were the writer to write on the sending thread, this send would block. + started(() -> { + for (int i = 0; i < 16; i++) { + transport.sendMessage(notification("filler", -1, i, filler)).block(Duration.ofSeconds(5)); + } + }); + Thread.sleep(300); + + ConcurrentLinkedQueue failures = new ConcurrentLinkedQueue<>(); + CountDownLatch go = new CountDownLatch(1); + List threads = new ArrayList<>(); + for (int s = 0; s < SENDERS; s++) { + int sender = s; + threads.add(started(() -> { + try { + go.await(); + for (int i = 0; i < PER_SENDER; i++) { + transport.sendMessage(notification("session/update", sender, i, "")) + .block(Duration.ofSeconds(5)); + } + } + catch (Throwable t) { + failures.add(t); + } + })); + } + // Inbound requests, answered on the inbound thread while the users send. + threads.add(started(() -> { + try { + go.await(); + for (int i = 0; i < REQUESTS; i++) { + String line = jsonMapper.writeValueAsString( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, i, "session/prompt", Map.of())) + + "\n"; + clientOut.write(line.getBytes(StandardCharsets.UTF_8)); + clientOut.flush(); + } + } + catch (Throwable t) { + failures.add(t); + } + })); + go.countDown(); + for (Thread t : threads) { + t.join(30_000); + } + // The writer stays blocked well past the emitters' 100 ms busy loop. + Thread.sleep(500); + assertThat(failures).isEmpty(); + + // The client reads again. + BufferedReader reader = new BufferedReader(new InputStreamReader(clientIn, StandardCharsets.UTF_8)); + Map next = new HashMap<>(); + int notifications = 0; + boolean[] answered = new boolean[REQUESTS]; + int responses = 0; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while ((notifications < SENDERS * PER_SENDER || responses < REQUESTS) && System.nanoTime() < deadline) { + String line = reader.readLine(); + AcpSchema.JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, line); + if (message instanceof AcpSchema.JSONRPCNotification n && n.method().equals("session/update")) { + Map params = (Map) n.params(); + int sender = ((Number) params.get("sender")).intValue(); + int seq = ((Number) params.get("seq")).intValue(); + int expected = next.getOrDefault(sender, 0); + assertThat(seq).as("sender %d order", sender).isEqualTo(expected); + next.put(sender, expected + 1); + notifications++; + } + else if (message instanceof AcpSchema.JSONRPCResponse r) { + int id = ((Number) r.id()).intValue(); + assertThat(answered[id]).as("response %d written once", id).isFalse(); + answered[id] = true; + responses++; + } + } + assertThat(notifications).isEqualTo(SENDERS * PER_SENDER); + assertThat(responses).isEqualTo(REQUESTS); + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + + private static Thread started(Runnable task) { + Thread thread = new Thread(task); + thread.start(); + return thread; + } + + private static AcpSchema.JSONRPCNotification notification(String method, int sender, int seq, String pad) { + return new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, method, + Map.of("sender", sender, "seq", seq, "pad", pad)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentEndOfInputProcessTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentEndOfInputProcessTest.java new file mode 100644 index 00000000..b1a0b8c4 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StdioAgentEndOfInputProcessTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A real agent process, started as the README shows, whose client writes its requests and + * closes the agent's standard input at once, then reads standard output to its end: every + * request is answered, and the process exits. Before, the process exited as soon as its input + * ended, with the replies still to come unwritten. + */ +class StdioAgentEndOfInputProcessTest { + + private static final String INITIALIZE = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1}}"; + + private static final String NEW_SESSION = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/new\",\"params\":{\"cwd\":\"/tmp\",\"mcpServers\":[]}}"; + + private static String prompt(String text) { + return "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s-1\"," + + "\"prompt\":[{\"type\":\"text\",\"text\":\"" + text + "\"}]}}"; + } + + @Test + void everyRequestWrittenBeforeClosingStandardInputIsAnswered() throws Exception { + List output = runAgent(INITIALIZE, NEW_SESSION, prompt("hi")); + + assertThat(output).filteredOn(line -> line.contains("\"id\":1")).singleElement().asString().contains("\"result\""); + assertThat(output).filteredOn(line -> line.contains("\"id\":2")).singleElement().asString().contains("\"s-1\""); + assertThat(output).filteredOn(line -> line.contains("session/update")).singleElement().asString().contains("hello"); + assertThat(output).last().asString().contains("\"id\":3").contains("end_turn"); + } + + /** + * A prompt that asks the client for permission after the client closed its input: the + * request fails at once instead of waiting out the request timeout, and the prompt is + * still answered. + */ + @Test + void aRequestToTheClientAfterItsInputEndedFailsAndThePromptIsAnswered() throws Exception { + List output = runAgent(INITIALIZE, NEW_SESSION, prompt("ask")); + + // askPermission announces its tool call first (a tool_call update), then asks. + assertThat(output).filteredOn(line -> line.contains("session/update") && line.contains("agent_message_chunk")) + .singleElement() + .asString() + .contains("permission failed") + .contains("closed its input"); + assertThat(output).last().asString().contains("\"id\":3").contains("end_turn"); + } + + private static List runAgent(String... requests) throws IOException, InterruptedException { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + Process process = new ProcessBuilder(java, "-Dlogback.configurationFile=logback-stdio-agent.xml", "-cp", + System.getProperty("java.class.path"), EndOfInputAgent.class.getName()) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + try { + try (OutputStream stdin = process.getOutputStream()) { + for (String request : requests) { + stdin.write((request + "\n").getBytes(StandardCharsets.UTF_8)); + } + } + List output = new ArrayList<>(); + try (BufferedReader stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + for (String line = stdout.readLine(); line != null; line = stdout.readLine()) { + output.add(line); + } + } + assertThat(process.waitFor(30, TimeUnit.SECONDS)).as("agent exited").isTrue(); + assertThat(process.exitValue()).isZero(); + return output; + } + finally { + process.destroyForcibly(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsLincheckTest.java new file mode 100644 index 00000000..ac7ab51c --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsLincheckTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.ThreadIdGen; +import org.jetbrains.lincheck.datastructures.Validate; +import org.junit.jupiter.api.Test; + +/** + * Model checks {@link UnansweredRequests}: senders recording requests while the reader ends + * the input. Once the input has ended, every request sent is failed exactly once, either by + * its sender (refused) or by the reader (claimed), never by both and never by neither: a + * request failed by neither would wait out the request timeout for an answer that cannot + * come, and one failed by both would be failed twice. + */ +class UnansweredRequestsLincheckTest { + + /** Multiplies the number of scenarios explored; CI's lincheck job raises it with -Dlincheck.scale. */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static final int THREADS = 3; + + /** Lincheck numbers its threads from 1. */ + private static final int IDS = THREADS + 1; + + @Test + void everyRequestSentIsFailedOnceAfterTheEnd() { + new ModelCheckingOptions().iterations(10 * SCALE) + .invocationsPerIteration(200) + .threads(THREADS) + .actorsPerThread(2) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(RequestsSpec.class) + .check(Requests.class); + } + + public static class Requests { + + private final UnansweredRequests requests = new UnansweredRequests(); + + /** Requests each thread sent; each slot is touched only by its own thread. */ + private final int[] sent = new int[IDS]; + + private final Queue refused = new ConcurrentLinkedQueue<>(); + + private final Queue claimed = new ConcurrentLinkedQueue<>(); + + private volatile boolean ended; + + @Operation + public void send(@Param(gen = ThreadIdGen.class) int thread) { + String id = thread + ":" + sent[thread]++; + if (!requests.sent(id)) { + refused.add(id); + } + } + + @Operation + public void end() { + claimed.addAll(requests.end()); + ended = true; + } + + @Validate + public void failedOnceAfterTheEnd() { + List failed = new ArrayList<>(refused); + failed.addAll(claimed); + Set distinct = new HashSet<>(failed); + if (distinct.size() != failed.size()) { + throw new IllegalStateException("failed twice: refused " + refused + ", claimed " + claimed); + } + if (!ended) { + return; + } + for (int thread = 1; thread < IDS; thread++) { + for (int i = 0; i < sent[thread]; i++) { + if (!distinct.contains(thread + ":" + i)) { + throw new IllegalStateException(thread + ":" + i + " never failed: refused " + refused + + ", claimed " + claimed); + } + } + } + } + + } + + public static class RequestsSpec { + + public void send(int thread) { + } + + public void end() { + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsTest.java new file mode 100644 index 00000000..4a050fec --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/UnansweredRequestsTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnansweredRequestsTest { + + private final UnansweredRequests requests = new UnansweredRequests(); + + @Test + void theEndClaimsTheRequestsStillWaitingOnce() { + assertThat(requests.sent("a-1")).isTrue(); + assertThat(requests.sent("a-2")).isTrue(); + requests.answered("a-1"); + + assertThat(requests.end()).containsExactly("a-2"); + assertThat(requests.ended()).isTrue(); + assertThat(requests.end()).isEmpty(); + } + + @Test + void aRequestSentAfterTheEndIsRefused() { + assertThat(requests.ended()).isFalse(); + requests.end(); + + assertThat(requests.sent("a-1")).isFalse(); + assertThat(requests.end()).isEmpty(); + } + + /** JSON-RPC ids compare by value: an int sent may come back as a long, and "1" is not 1. */ + @Test + void idsCompareAsJsonRpcValues() { + requests.sent(1); + requests.sent("1"); + requests.answered(1L); + + assertThat(requests.end()).containsExactly("1"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilitiesTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilitiesTest.java index 50eb647e..bb6a59b4 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilitiesTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/capabilities/NegotiatedCapabilitiesTest.java @@ -180,7 +180,7 @@ void fromAgentExtractsDeleteAndAdditionalDirectoriesSessionCapabilities() { @Test void fromAgentExtractsProvidersCapability() { AcpSchema.AgentCapabilities agentCaps = new AcpSchema.AgentCapabilities(true, null, - new AcpSchema.McpCapabilities(), new AcpSchema.PromptCapabilities(), + new AcpSchema.McpCapabilities(), new AcpSchema.PromptCapabilities(), null, new AcpSchema.ProvidersCapabilities(), null); NegotiatedCapabilities caps = NegotiatedCapabilities.fromAgent(agentCaps); @@ -289,13 +289,12 @@ void capabilityExceptionContainsCapabilityName() { } @Test - void capabilityExceptionConvertsToProtocolException() { - AcpCapabilityException exception = new AcpCapabilityException("terminal"); - - var protocolException = exception.toProtocolException(); - - assertThat(protocolException.getCode()).isEqualTo(-32001); - assertThat(protocolException.getData()).isEqualTo("terminal"); + void aMissingCapabilityHasOneProtocolAnswer() { + // ACP's elicitation RFD names -32602 (Invalid params) for an elicitation mode the client + // did not declare, which is the one answer the SDK gives; there is no second converter + // that answers -32600. + assertThat(java.util.Arrays.stream(AcpCapabilityException.class.getMethods()) + .map(java.lang.reflect.Method::getName)).doesNotContain("toProtocolException"); } } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpAsyncClientTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpAsyncClientTest.java index 9397e650..b4036827 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpAsyncClientTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpAsyncClientTest.java @@ -30,7 +30,7 @@ class AcpAsyncClientTest { private static final Duration TIMEOUT = Duration.ofSeconds(5); private static final AcpSchema.InitializeResponse MOCK_INIT_RESPONSE = new AcpSchema.InitializeResponse(1, - new AcpSchema.AgentCapabilities(null, null, null), List.of()); + new AcpSchema.AgentCapabilities(true, null, null), List.of()); @Test void testConstructorWithNullSession() { @@ -67,12 +67,12 @@ void testInitialize() { // Set up mock transport that responds to initialize var transport = createMockTransportWithInitialize(); - AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); - - AcpSchema.InitializeRequest request = new AcpSchema.InitializeRequest(1, - new AcpSchema.ClientCapabilities(null, null)); + AcpAsyncClient client = AcpClient.async(transport) + .requestTimeout(TIMEOUT) + .clientCapabilities(new AcpSchema.ClientCapabilities(null, null)) + .build(); - StepVerifier.create(client.initialize(request)).consumeNextWith(response -> { + StepVerifier.create(client.initialize()).consumeNextWith(response -> { assertThat(response).isNotNull(); assertThat(response.protocolVersion()).isEqualTo(1); assertThat(response.agentCapabilities()).isNotNull(); @@ -82,14 +82,11 @@ void testInitialize() { } @Test - void testInitializeWithNullRequest() { + void testBuilderWithNullClientInfo() { var transport = new MockAcpClientTransport(); - AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); - - assertThatThrownBy(() -> client.initialize(null)).isInstanceOf(IllegalArgumentException.class) - .hasMessage("Initialize request must not be null"); - - client.close(); + assertThatThrownBy(() -> AcpClient.async(transport).clientInfo(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Client info must not be null"); } @Test @@ -97,6 +94,7 @@ void testAuthenticate() { var transport = createMockTransportWithAuthMethods(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.AuthenticateRequest authRequest = new AcpSchema.AuthenticateRequest("bearer"); @@ -123,6 +121,7 @@ void testNewSession() { var transport = createMockTransportWithSession(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.NewSessionRequest sessionRequest = new AcpSchema.NewSessionRequest("/workspace", List.of()); @@ -150,6 +149,7 @@ void testLoadSession() { var transport = createMockTransportWithLoadSession(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.LoadSessionRequest loadRequest = new AcpSchema.LoadSessionRequest("session-123", null, List.of()); @@ -176,6 +176,7 @@ void testSetSessionMode() { var transport = createMockTransportWithSetMode(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.SetSessionModeRequest modeRequest = new AcpSchema.SetSessionModeRequest("session-123", "code"); @@ -202,6 +203,7 @@ void testPrompt() { var transport = createMockTransportWithPrompt(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.PromptRequest promptRequest = new AcpSchema.PromptRequest("session-123", List.of(new AcpSchema.TextContent("Fix the bug"))); @@ -227,8 +229,9 @@ void testPromptWithNullRequest() { @Test void testCancel() { - var transport = new MockAcpClientTransport(); + var transport = createMockTransportWithInitialize(); AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); AcpSchema.CancelNotification cancelNotification = new AcpSchema.CancelNotification("session-123"); @@ -260,7 +263,7 @@ void testSessionUpdateNotificationHandling() { var transport = new MockAcpClientTransport(); AcpAsyncClient client = AcpClient.async(transport) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { receivedNotification.tryEmitValue(notification); return Mono.empty(); }) @@ -268,13 +271,13 @@ void testSessionUpdateNotificationHandling() { // Simulate incoming session update notification AcpSchema.SessionNotification sessionUpdate = new AcpSchema.SessionNotification("session-123", - new AcpSchema.UserMessageChunk("userMessage", new AcpSchema.TextContent("Hello"))); + new AcpSchema.UserMessageChunk(new AcpSchema.TextContent("Hello"))); AcpSchema.JSONRPCNotification notification = new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, AcpSchema.METHOD_SESSION_UPDATE, sessionUpdate); transport.simulateIncomingMessage(notification); - // Verify the consumer received the notification + // Verify the update handler received the notification AcpSchema.SessionNotification received = receivedNotification.asMono().block(Duration.ofSeconds(1)); assertThat(received).isNotNull(); assertThat(received.sessionId()).isEqualTo("session-123"); @@ -282,6 +285,35 @@ void testSessionUpdateNotificationHandling() { client.close(); } + @Test + void unknownSessionUpdateReachesTheConsumerAndTheNextUpdateFollows() throws Exception { + Sinks.Many received = Sinks.many().replay().all(); + var transport = new MockAcpClientTransport(); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).sessionUpdateHandler(n -> { + received.tryEmitNext(n); + return Mono.empty(); + }).build(); + var json = com.agentclientprotocol.sdk.json.AcpJsonMapper.createDefault(); + var mapType = new com.agentclientprotocol.sdk.json.TypeRef>() { + }; + + // As a newer agent sends them: an update type this SDK does not know, then a known one + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE, + json.readValue("{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"future_update\",\"x\":1}}", + mapType))); + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE, + json.readValue("{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"session_info_update\"," + + "\"title\":\"Renamed\"}}", mapType))); + + StepVerifier.create(received.asFlux().take(2).map(AcpSchema.SessionNotification::update)) + .expectNext(new AcpSchema.UnknownSessionUpdate("future_update", Map.of("x", 1))) + .expectNext(new AcpSchema.SessionInfoUpdate("Renamed", null)) + .expectComplete() + .verify(Duration.ofSeconds(5)); + + client.close(); + } + @Test void testGracefulShutdown() { var transport = new MockAcpClientTransport(); @@ -302,8 +334,23 @@ private MockAcpClientTransport createMockTransportWithInitialize() { }); } - private MockAcpClientTransport createMockTransportWithAuthMethods() { + /** A transport that answers initialize, then lets the handler answer the rest. */ + private MockAcpClientTransport answeringInitialize( + java.util.function.BiConsumer rest) { return new MockAcpClientTransport((t, msg) -> { + if (msg instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + t.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESPONSE, null)); + } + else { + rest.accept(t, msg); + } + }); + } + + private MockAcpClientTransport createMockTransportWithAuthMethods() { + return answeringInitialize((t, msg) -> { if (msg instanceof AcpSchema.JSONRPCRequest request) { if (AcpSchema.METHOD_AUTHENTICATE.equals(request.method())) { AcpSchema.AuthenticateResponse authResponse = new AcpSchema.AuthenticateResponse(); @@ -315,7 +362,7 @@ private MockAcpClientTransport createMockTransportWithAuthMethods() { } private MockAcpClientTransport createMockTransportWithSession() { - return new MockAcpClientTransport((t, msg) -> { + return answeringInitialize((t, msg) -> { if (msg instanceof AcpSchema.JSONRPCRequest request) { if (AcpSchema.METHOD_SESSION_NEW.equals(request.method())) { AcpSchema.NewSessionResponse sessionResponse = new AcpSchema.NewSessionResponse("session-abc123", null, @@ -328,7 +375,7 @@ private MockAcpClientTransport createMockTransportWithSession() { } private MockAcpClientTransport createMockTransportWithLoadSession() { - return new MockAcpClientTransport((t, msg) -> { + return answeringInitialize((t, msg) -> { if (msg instanceof AcpSchema.JSONRPCRequest request) { if (AcpSchema.METHOD_SESSION_LOAD.equals(request.method())) { AcpSchema.LoadSessionResponse loadResponse = new AcpSchema.LoadSessionResponse(null, null); @@ -340,7 +387,7 @@ private MockAcpClientTransport createMockTransportWithLoadSession() { } private MockAcpClientTransport createMockTransportWithSetMode() { - return new MockAcpClientTransport((t, msg) -> { + return answeringInitialize((t, msg) -> { if (msg instanceof AcpSchema.JSONRPCRequest request) { if (AcpSchema.METHOD_SESSION_SET_MODE.equals(request.method())) { AcpSchema.SetSessionModeResponse modeResponse = new AcpSchema.SetSessionModeResponse(); @@ -352,7 +399,7 @@ private MockAcpClientTransport createMockTransportWithSetMode() { } private MockAcpClientTransport createMockTransportWithPrompt() { - return new MockAcpClientTransport((t, msg) -> { + return answeringInitialize((t, msg) -> { if (msg instanceof AcpSchema.JSONRPCRequest request) { if (AcpSchema.METHOD_SESSION_PROMPT.equals(request.method())) { AcpSchema.PromptResponse promptResponse = new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpClientBuilderTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpClientBuilderTest.java index c7d18ef9..09270bfe 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpClientBuilderTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/AcpClientBuilderTest.java @@ -40,11 +40,11 @@ void asyncBuilderWithRequestTimeout() { } @Test - void asyncBuilderWithSessionUpdateConsumer() { + void asyncBuilderWithSessionUpdateHandler() { MockAcpClientTransport transport = new MockAcpClientTransport(); AcpAsyncClient client = AcpClient.async(transport) - .sessionUpdateConsumer(notification -> Mono.empty()) + .sessionUpdateHandler(notification -> Mono.empty()) .build(); assertThat(client).isNotNull(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCallGatingTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCallGatingTest.java new file mode 100644 index 00000000..63300c3d --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCallGatingTest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A client follows ACP's order: every ACP call but {@code initialize} fails with + * {@link IllegalStateException} until the agent has answered {@code initialize}, and a call the + * agent did not advertise fails with {@link AcpCapabilityException}, as the agent side does for + * the client's capabilities. Neither sends anything. Extension methods are outside the lifecycle + * and not checked ({@code ExtensionMethodsTest.extHandlersDoNotNeedInitialization}). + */ +class ClientCallGatingTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "s-1"; + + static Stream everyCall() { + return Stream.of(call("authenticate", c -> c.authenticate(new AcpSchema.AuthenticateRequest("m"))), + call("logout", c -> c.logout(new AcpSchema.LogoutRequest())), + call("newSession", c -> c.newSession(new AcpSchema.NewSessionRequest("/"))), + call("loadSession", c -> c.loadSession(new AcpSchema.LoadSessionRequest(SESSION, "/", List.of()))), + call("setSessionMode", c -> c.setSessionMode(new AcpSchema.SetSessionModeRequest(SESSION, "m"))), + call("listSessions", c -> c.listSessions(new AcpSchema.ListSessionsRequest(null))), + call("closeSession", c -> c.closeSession(new AcpSchema.CloseSessionRequest(SESSION))), + call("deleteSession", c -> c.deleteSession(new AcpSchema.DeleteSessionRequest(SESSION))), + call("resumeSession", c -> c.resumeSession(new AcpSchema.ResumeSessionRequest(SESSION, "/", List.of()))), + call("forkSession", c -> c.forkSession(new AcpSchema.ForkSessionRequest(SESSION, "/", List.of()))), + call("setSessionConfigOption", + c -> c.setSessionConfigOption(AcpSchema.SetSessionConfigOptionRequest.select(SESSION, "o", "v"))), + call("listProviders", c -> c.listProviders(new AcpSchema.ListProvidersRequest())), + call("setProvider", c -> c.setProvider(new AcpSchema.SetProviderRequest("p", "openai", "http://x"))), + call("disableProvider", c -> c.disableProvider(new AcpSchema.DisableProviderRequest("p"))), + call("prompt", c -> c.prompt(AcpSchema.PromptRequest.text(SESSION, "hi"))), + call("cancel", c -> c.cancel(new AcpSchema.CancelNotification(SESSION)))); + } + + static Stream advertisedCalls() { + return Stream.of( + advertised("loadSession", "loadSession", + c -> c.loadSession(new AcpSchema.LoadSessionRequest(SESSION, "/", List.of()))), + advertised("listSessions", "sessionCapabilities.list", + c -> c.listSessions(new AcpSchema.ListSessionsRequest(null))), + advertised("closeSession", "sessionCapabilities.close", + c -> c.closeSession(new AcpSchema.CloseSessionRequest(SESSION))), + advertised("deleteSession", "sessionCapabilities.delete", + c -> c.deleteSession(new AcpSchema.DeleteSessionRequest(SESSION))), + advertised("resumeSession", "sessionCapabilities.resume", + c -> c.resumeSession(new AcpSchema.ResumeSessionRequest(SESSION, "/", List.of()))), + advertised("forkSession", "sessionCapabilities.fork", + c -> c.forkSession(new AcpSchema.ForkSessionRequest(SESSION, "/", List.of()))), + advertised("logout", "auth.logout", c -> c.logout(new AcpSchema.LogoutRequest())), + advertised("listProviders", "providers", c -> c.listProviders(new AcpSchema.ListProvidersRequest())), + advertised("setProvider", "providers", + c -> c.setProvider(new AcpSchema.SetProviderRequest("p", "openai", "http://x"))), + advertised("disableProvider", "providers", + c -> c.disableProvider(new AcpSchema.DisableProviderRequest("p")))); + } + + @ParameterizedTest + @MethodSource("everyCall") + void aCallBeforeInitializeFailsWithoutSending(Function> call) { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + + assertThatThrownBy(() -> call.apply(client).block(TIMEOUT)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("initialize()"); + assertThat(transport.getSentMessages()).isEmpty(); + client.close(); + } + + @ParameterizedTest + @MethodSource("advertisedCalls") + void aCallTheAgentDidNotAdvertiseFailsWithoutSending(Function> call, String capability) { + MockAcpClientTransport transport = agentAdvertising(Map.of()); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + transport.clearSentMessages(); + + assertThatThrownBy(() -> call.apply(client).block(TIMEOUT)).isInstanceOf(AcpCapabilityException.class) + .satisfies(e -> assertThat(((AcpCapabilityException) e).getCapability()).isEqualTo(capability)); + assertThat(transport.getSentMessages()).isEmpty(); + client.close(); + } + + @ParameterizedTest + @MethodSource("advertisedCalls") + void aCallTheAgentAdvertisedIsSent(Function> call, String capability) { + MockAcpClientTransport transport = agentAdvertising(Map.of("loadSession", true, "sessionCapabilities", + Map.of("list", Map.of(), "close", Map.of(), "delete", Map.of(), "resume", Map.of(), "fork", Map.of()), + "auth", Map.of("logout", Map.of()), "providers", Map.of())); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + transport.clearSentMessages(); + + call.apply(client).subscribe(result -> { + }, error -> { + }); + + assertThat(transport.getSentMessages()).singleElement().isInstanceOf(AcpSchema.JSONRPCRequest.class); + client.close(); + } + + @Test + void aCallBuiltBeforeInitializeIsCheckedWhenSubscribed() { + MockAcpClientTransport transport = agentAdvertising(Map.of()); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + + AcpSchema.NewSessionResponse response = client.initialize() + .then(client.newSession(new AcpSchema.NewSessionRequest("/"))) + .block(TIMEOUT); + + assertThat(response.sessionId()).isEqualTo(SESSION); + client.close(); + } + + @Test + void theSyncClientThrowsTheSame() { + MockAcpClientTransport transport = agentAdvertising(Map.of()); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).build(); + + assertThatThrownBy(() -> client.newSession(new AcpSchema.NewSessionRequest("/"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("initialize()"); + client.initialize(); + assertThatThrownBy(() -> client.loadSession(new AcpSchema.LoadSessionRequest(SESSION, "/", List.of()))) + .isInstanceOf(AcpCapabilityException.class); + assertThat(client.newSession(new AcpSchema.NewSessionRequest("/")).sessionId()).isEqualTo(SESSION); + client.close(); + } + + static Stream callsWithAdditionalDirectories() { + List dirs = List.of("/home/me/shared-lib"); + return Stream.of( + call("newSession", c -> c.newSession(new AcpSchema.NewSessionRequest("/", List.of(), dirs))), + call("loadSession", c -> c.loadSession(new AcpSchema.LoadSessionRequest(SESSION, "/", List.of(), dirs))), + call("resumeSession", + c -> c.resumeSession(new AcpSchema.ResumeSessionRequest(SESSION, "/", List.of(), dirs))), + call("forkSession", c -> c.forkSession(new AcpSchema.ForkSessionRequest(SESSION, "/", List.of(), dirs)))); + } + + /** Every session capability but additionalDirectories. */ + private static final Map SESSIONS_WITHOUT_DIRECTORIES = Map.of("loadSession", true, + "sessionCapabilities", Map.of("resume", Map.of(), "fork", Map.of())); + + @ParameterizedTest + @MethodSource("callsWithAdditionalDirectories") + void additionalDirectoriesAreNotSentToAnAgentThatDidNotAdvertiseThem(Function> call) { + MockAcpClientTransport transport = agentAdvertising(SESSIONS_WITHOUT_DIRECTORIES); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + transport.clearSentMessages(); + + assertThatThrownBy(() -> call.apply(client).block(TIMEOUT)).isInstanceOf(AcpCapabilityException.class) + .satisfies(e -> assertThat(((AcpCapabilityException) e).getCapability()) + .isEqualTo("sessionCapabilities.additionalDirectories")); + assertThat(transport.getSentMessages()).isEmpty(); + client.close(); + } + + @ParameterizedTest + @MethodSource("callsWithAdditionalDirectories") + void additionalDirectoriesAreSentToAnAgentThatAdvertisedThem(Function> call) { + MockAcpClientTransport transport = agentAdvertising(Map.of("loadSession", true, "sessionCapabilities", + Map.of("resume", Map.of(), "fork", Map.of(), "additionalDirectories", Map.of()))); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + transport.clearSentMessages(); + + call.apply(client).subscribe(result -> { + }, error -> { + }); + + assertThat(transport.getSentMessages()).singleElement().isInstanceOf(AcpSchema.JSONRPCRequest.class); + client.close(); + } + + @Test + void noOrEmptyAdditionalDirectoriesNeedNoCapability() { + MockAcpClientTransport transport = agentAdvertising(SESSIONS_WITHOUT_DIRECTORIES); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + + assertThat(client.newSession(new AcpSchema.NewSessionRequest("/", List.of(), null)).block(TIMEOUT).sessionId()) + .isEqualTo(SESSION); + assertThat(client.newSession(new AcpSchema.NewSessionRequest("/", List.of(), List.of())).block(TIMEOUT) + .sessionId()).isEqualTo(SESSION); + client.close(); + } + + /** A transport whose agent answers initialize with the given capabilities and session/new. */ + private static MockAcpClientTransport agentAdvertising(Map agentCapabilities) { + return new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCRequest request) { + Object result = switch (request.method()) { + case AcpSchema.METHOD_INITIALIZE -> Map.of("protocolVersion", 1, "agentCapabilities", + agentCapabilities); + case AcpSchema.METHOD_SESSION_NEW -> Map.of("sessionId", SESSION); + default -> null; + }; + if (result != null) { + t.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)); + } + } + }); + } + + private static Arguments call(String name, Function> call) { + return Arguments.of(Named.of(name, call)); + } + + private static Arguments advertised(String name, String capability, Function> call) { + return Arguments.of(Named.of(name, call), capability); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilitiesBuilderTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilitiesBuilderTest.java index e4f34396..bece2fea 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilitiesBuilderTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilitiesBuilderTest.java @@ -5,7 +5,9 @@ package com.agentclientprotocol.sdk.client; import java.time.Duration; +import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -49,8 +51,8 @@ void tearDown() { /** * Test that clientCapabilities set via the builder are sent to the agent. *

    - * BUG: Currently the clientCapabilities() builder method is ignored - the agent - * receives default capabilities instead of the custom ones configured. + * Regression test: the clientCapabilities() builder method was once ignored, and the + * agent received default capabilities instead of the custom ones configured. */ @Test void clientCapabilitiesBuilderMethodSendsCapabilitiesToAgent() throws Exception { @@ -67,14 +69,14 @@ void clientCapabilitiesBuilderMethodSendsCapabilitiesToAgent() throws Exception capabilitiesLatch.countDown(); return Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of())); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); // Create custom client capabilities with file system and terminal enabled FileSystemCapability fsCaps = new FileSystemCapability(true, true); ClientCapabilities customCaps = new ClientCapabilities(fsCaps, true); // Build client with custom capabilities via builder - AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + AcpAsyncClient client = servingEveryCapability(AcpClient.async(transportPair.clientTransport())) .requestTimeout(TIMEOUT) .clientCapabilities(customCaps) // This should be sent to agent .build(); @@ -90,7 +92,7 @@ void clientCapabilitiesBuilderMethodSendsCapabilitiesToAgent() throws Exception assertThat(capabilitiesLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(receivedCapabilities.get()).isNotNull(); - // BUG: These assertions currently FAIL because builder capabilities are ignored + // These assertions failed while builder capabilities were ignored assertThat(receivedCapabilities.get().fs()) .as("File system capabilities should be set from builder") .isNotNull(); @@ -110,58 +112,108 @@ void clientCapabilitiesBuilderMethodSendsCapabilitiesToAgent() throws Exception } /** - * Test that initialize(request) still works and overrides builder capabilities. - * This is the workaround currently used in tutorial Module 17. + * The builder is the one place a client's capabilities and info are set: there is no + * initialize overload that takes a request, so none can silently replace them. */ @Test - void initializeWithRequestOverridesBuilderCapabilities() throws Exception { - transportPair = InMemoryTransportPair.create(); + void clientsHaveNoInitializeThatTakesARequest() { + for (Class client : List.of(AcpAsyncClient.class, AcpSyncClient.class)) { + assertThat(Arrays.stream(client.getMethods()) + .filter(method -> method.getName().equals("initialize")) + .flatMap(method -> Arrays.stream(method.getParameterTypes()))) + .as(client.getSimpleName() + ".initialize parameters") + .doesNotContain(AcpSchema.InitializeRequest.class, ClientCapabilities.class); + } + } - AtomicReference receivedCapabilities = new AtomicReference<>(); - CountDownLatch capabilitiesLatch = new CountDownLatch(1); + @Test + void initializeSendsTheBuilderCapabilitiesAndClientInfo() throws Exception { + transportPair = InMemoryTransportPair.create(); + AtomicReference received = new AtomicReference<>(); + AcpAsyncAgent agent = capturingAgent(received); + ClientCapabilities caps = ClientCapabilities.builder() + .session(AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions()) + .build(); + AcpSchema.Implementation info = new AcpSchema.Implementation("my-client", "1.0"); - // Build agent - AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()) + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) .requestTimeout(TIMEOUT) - .initializeHandler(request -> { - receivedCapabilities.set(request.clientCapabilities()); - capabilitiesLatch.countDown(); - return Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of())); - }) + .clientCapabilities(caps) + .clientInfo(info) .build(); + agent.start().subscribe(); - // Create different capabilities for builder vs initialize request - FileSystemCapability builderFs = new FileSystemCapability(false, false); - ClientCapabilities builderCaps = new ClientCapabilities(builderFs, false); + client.initialize().block(TIMEOUT); - FileSystemCapability requestFs = new FileSystemCapability(true, true); - ClientCapabilities requestCaps = new ClientCapabilities(requestFs, true); + assertThat(received.get()).isEqualTo(new AcpSchema.InitializeRequest(1, caps, info, null)); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } - // Build client with builder capabilities - AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + @Test + void initializeWithVersionAndMetaKeepsTheBuilderCapabilities() throws Exception { + transportPair = InMemoryTransportPair.create(); + AtomicReference received = new AtomicReference<>(); + AcpAsyncAgent agent = capturingAgent(received); + ClientCapabilities caps = new ClientCapabilities(new FileSystemCapability(true, true), true); + + AcpAsyncClient client = servingEveryCapability(AcpClient.async(transportPair.clientTransport())) .requestTimeout(TIMEOUT) - .clientCapabilities(builderCaps) + .clientCapabilities(caps) .build(); - - // Start agent agent.start().subscribe(); - Thread.sleep(100); - // Initialize with explicit request (should override builder) - client.initialize(new AcpSchema.InitializeRequest(1, requestCaps)).block(TIMEOUT); + client.initialize(1, Map.of("trace", "t")).block(TIMEOUT); - // Verify agent received the request capabilities, not builder capabilities - assertThat(capabilitiesLatch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(receivedCapabilities.get().fs().readTextFile()) - .as("Should receive request capabilities, not builder") - .isTrue(); - assertThat(receivedCapabilities.get().terminal()) - .as("Should receive request capabilities, not builder") - .isTrue(); - - // Cleanup + assertThat(received.get()).isEqualTo(new AcpSchema.InitializeRequest(1, caps, null, Map.of("trace", "t"))); client.closeGracefully().block(TIMEOUT); agent.closeGracefully().block(TIMEOUT); } + @Test + void syncClientSendsTheBuilderCapabilitiesAndClientInfo() throws Exception { + transportPair = InMemoryTransportPair.create(); + AtomicReference received = new AtomicReference<>(); + AcpAsyncAgent agent = capturingAgent(received); + ClientCapabilities caps = ClientCapabilities.builder().terminal(true).build(); + AcpSchema.Implementation info = new AcpSchema.Implementation("my-client", "1.0"); + + AcpSyncClient client = AcpClient.sync(transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(caps) + .clientInfo(info) + .createTerminalHandler(request -> null) + .terminalOutputHandler(request -> null) + .releaseTerminalHandler(request -> null) + .waitForTerminalExitHandler(request -> null) + .killTerminalHandler(request -> null) + .build(); + agent.start().subscribe(); + + client.initialize(); + client.initialize(1, Map.of("trace", "t")); + + assertThat(received.get()).isEqualTo(new AcpSchema.InitializeRequest(1, caps, info, Map.of("trace", "t"))); + client.closeGracefully(); + agent.closeGracefully().block(TIMEOUT); + } + + /** A client advertising files and terminals must serve them, or it does not build. */ + private static AcpClient.AsyncSpec servingEveryCapability(AcpClient.AsyncSpec spec) { + return spec.readTextFileHandler(request -> Mono.empty()) + .writeTextFileHandler(request -> Mono.empty()) + .createTerminalHandler(request -> Mono.empty()) + .terminalOutputHandler(request -> Mono.empty()) + .releaseTerminalHandler(request -> Mono.empty()) + .waitForTerminalExitHandler(request -> Mono.empty()) + .killTerminalHandler(request -> Mono.empty()); + } + + private AcpAsyncAgent capturingAgent(AtomicReference received) { + return AcpAgent.async(transportPair.agentTransport()).requestTimeout(TIMEOUT).initializeHandler(request -> { + received.set(request); + return Mono.just(AcpSchema.InitializeResponse.ok()); + }).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityDerivationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityDerivationTest.java new file mode 100644 index 00000000..59d9ddbd --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityDerivationTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.List; +import java.util.function.Function; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A client built without {@code clientCapabilities(..)} advertises what its handlers serve, as an + * agent without an initialize handler does: {@code fs.readTextFile} and {@code fs.writeTextFile} + * for their handlers, {@code terminal} once all five terminal handlers are registered, and form + * elicitation for an elicitation handler. Capabilities set explicitly win: nothing is derived, and + * the handler check of the explicit capabilities applies as before. + */ +class ClientCapabilityDerivationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final Logger logger = (Logger) LoggerFactory.getLogger(AcpClient.class); + + private final ListAppender logged = new ListAppender<>(); + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + private final AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + + @BeforeEach + void start() { + logged.start(); + logger.addAppender(logged); + agent.start().block(TIMEOUT); + } + + @AfterEach + void stop() { + logger.detachAppender(logged); + agent.close(); + pair.closeGracefully().block(TIMEOUT); + } + + /** Initializes the client and returns what the agent saw. */ + private NegotiatedCapabilities advertised(AcpAsyncClient client) { + try { + client.initialize().block(TIMEOUT); + return agent.getClientCapabilities(); + } + finally { + client.close(); + } + } + + private static Function> none() { + return request -> Mono.empty(); + } + + @Test + void handlersAdvertiseTheirCapabilities() { + NegotiatedCapabilities caps = advertised(AcpClient.async(pair.clientTransport()) + .readTextFileHandler(none()) + .createTerminalHandler(none()) + .terminalOutputHandler(none()) + .releaseTerminalHandler(none()) + .waitForTerminalExitHandler(none()) + .killTerminalHandler(none()) + .createElicitationHandler(none()) + .build()); + + assertThat(caps.supportsReadTextFile()).isTrue(); + assertThat(caps.supportsWriteTextFile()).isFalse(); + assertThat(caps.supportsTerminal()).isTrue(); + assertThat(caps.supportsElicitationForm()).isTrue(); + assertThat(caps.supportsElicitationUrl()).isFalse(); + assertThat(warnings()).isEmpty(); + } + + @Test + void syncHandlersAdvertiseTheirCapabilities() { + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .writeTextFileHandler(request -> new AcpSchema.WriteTextFileResponse()) + .build(); + try { + client.initialize(); + NegotiatedCapabilities caps = agent.getClientCapabilities(); + assertThat(caps.supportsWriteTextFile()).isTrue(); + assertThat(caps.supportsReadTextFile()).isFalse(); + assertThat(caps.supportsTerminal()).isFalse(); + } + finally { + client.close(); + } + } + + @Test + void noHandlersAdvertiseTheDefaultCapabilities() { + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); + try { + client.initialize().block(TIMEOUT); + NegotiatedCapabilities caps = agent.getClientCapabilities(); + assertThat(caps.supportsReadTextFile() || caps.supportsWriteTextFile() || caps.supportsTerminal() + || caps.supportsElicitation()).isFalse(); + } + finally { + client.close(); + } + } + + @Test + void someTerminalHandlersAdvertiseNoTerminalAndWarn() { + NegotiatedCapabilities caps = advertised(AcpClient.async(pair.clientTransport()) + .createTerminalHandler(none()) + .killTerminalHandler(none()) + .build()); + + assertThat(caps.supportsTerminal()).isFalse(); + assertThat(warnings()).singleElement() + .satisfies(warning -> assertThat(warning).contains("terminalOutputHandler", "releaseTerminalHandler", + "waitForTerminalExitHandler")); + } + + @Test + void explicitCapabilitiesWin() { + NegotiatedCapabilities caps = advertised(AcpClient.async(pair.clientTransport()) + .clientCapabilities(AcpSchema.ClientCapabilities.builder() + .session(AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions()) + .build()) + .readTextFileHandler(none()) + .build()); + + assertThat(caps.supportsReadTextFile()).isFalse(); + assertThat(warnings()).singleElement() + .satisfies(warning -> assertThat(warning).contains("readTextFileHandler", "fs.readTextFile")); + } + + private List warnings() { + return logged.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityHandlersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityHandlersTest.java new file mode 100644 index 00000000..e598d451 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientCapabilityHandlersTest.java @@ -0,0 +1,161 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A client's advertised capabilities must match its handlers: advertising {@code fs.readTextFile}, + * {@code fs.writeTextFile}, {@code terminal} or {@code elicitation} without the handler that + * serves it fails at {@code build()}, naming the setter, instead of every such request from the + * agent being answered "Method not found". With capabilities set explicitly, a handler for a + * capability the client does not advertise is logged once at WARN: an SDK agent will not call it. + * (Without them the handlers decide; see {@code ClientCapabilityDerivationTest}.) + */ +class ClientCapabilityHandlersTest { + + private final Logger logger = (Logger) LoggerFactory.getLogger(AcpClient.class); + + private final ListAppender logged = new ListAppender<>(); + + @BeforeEach + void captureWarnings() { + logged.start(); + logger.addAppender(logged); + } + + @AfterEach + void restoreLogging() { + logger.detachAppender(logged); + } + + @Test + void advertisingFileReadsWithoutAHandlerFails() { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .clientCapabilities(capabilities(true, false, false, null)) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("fs.readTextFile") + .hasMessageContaining("readTextFileHandler"); + } + + @Test + void advertisingFileWritesWithoutAHandlerFails() { + assertThatThrownBy(() -> AcpClient.sync(new MockAcpClientTransport()) + .clientCapabilities(capabilities(false, true, false, null)) + .readTextFileHandler(request -> null) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("fs.writeTextFile") + .hasMessageContaining("writeTextFileHandler") + .hasMessageNotContaining("readTextFileHandler,"); + } + + @Test + void advertisingTerminalsNeedsAllFiveTerminalHandlers() { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .clientCapabilities(capabilities(false, false, true, null)) + .createTerminalHandler(request -> Mono.empty()) + .killTerminalHandler(request -> Mono.empty()) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal") + .hasMessageContaining("terminalOutputHandler") + .hasMessageContaining("releaseTerminalHandler") + .hasMessageContaining("waitForTerminalExitHandler") + .hasMessageNotContaining("createTerminalHandler") + .hasMessageNotContaining("killTerminalHandler"); + } + + @Test + void advertisingElicitationWithoutAHandlerFails() { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .clientCapabilities(capabilities(false, false, false, AcpSchema.ElicitationCapabilities.formOnly())) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("elicitation") + .hasMessageContaining("createElicitationHandler"); + } + + @Test + void everyMissingHandlerIsNamedAtOnce() { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .clientCapabilities(capabilities(true, true, false, null)) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("readTextFileHandler") + .hasMessageContaining("writeTextFileHandler"); + } + + @Test + void advertisedCapabilitiesWithTheirHandlersBuild() { + AcpSyncClient client = AcpClient.sync(new MockAcpClientTransport()) + .clientCapabilities(capabilities(true, true, true, AcpSchema.ElicitationCapabilities.formOnly())) + .readTextFileHandler(request -> null) + .writeTextFileHandler(request -> null) + .createTerminalHandler(request -> null) + .terminalOutputHandler(request -> null) + .releaseTerminalHandler(request -> null) + .waitForTerminalExitHandler(request -> null) + .killTerminalHandler(request -> null) + .createElicitationHandler(request -> null) + .build(); + + assertThat(warnings()).isEmpty(); + client.close(); + } + + @Test + void aHandlerForACapabilityNotAdvertisedLogsOneWarning() { + AcpAsyncClient client = AcpClient.async(new MockAcpClientTransport()) + .clientCapabilities(new AcpSchema.ClientCapabilities()) + .readTextFileHandler(request -> Mono.empty()) + .createTerminalHandler(request -> Mono.empty()) + .createElicitationHandler(request -> Mono.empty()) + .requestPermissionHandler(request -> Mono.empty()) + .build(); + + assertThat(warnings()).singleElement().satisfies(warning -> assertThat(warning) + .contains("readTextFileHandler", "fs.readTextFile", "createTerminalHandler", "terminal", + "createElicitationHandler", "elicitation") + .doesNotContain("requestPermissionHandler")); + client.close(); + } + + @Test + void theDefaultCapabilitiesNeedNoHandler() { + AcpAsyncClient client = AcpClient.async(new MockAcpClientTransport()) + .requestPermissionHandler(request -> Mono.empty()) + .build(); + + assertThat(warnings()).isEmpty(); + client.close(); + } + + private java.util.List warnings() { + return logged.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + private static AcpSchema.ClientCapabilities capabilities(boolean read, boolean write, boolean terminal, + AcpSchema.ElicitationCapabilities elicitation) { + return AcpSchema.ClientCapabilities.builder() + .fs(new AcpSchema.FileSystemCapability(read, write)) + .terminal(terminal) + .elicitation(elicitation) + .build(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientDuplicateHandlerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientDuplicateHandlerTest.java new file mode 100644 index 00000000..84268558 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/ClientDuplicateHandlerTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Registering a second handler for a method the client builders already have one for is a + * mistake that used to replace the first silently. It now fails at the second call, naming the + * setter; a null handler fails at once. + */ +class ClientDuplicateHandlerTest { + + static Stream asyncRegistrations() { + return Stream.of( + async("readTextFileHandler", spec -> spec.readTextFileHandler(r -> Mono.empty())), + async("writeTextFileHandler", spec -> spec.writeTextFileHandler(r -> Mono.empty())), + async("requestPermissionHandler", spec -> spec.requestPermissionHandler(r -> Mono.empty())), + async("createTerminalHandler", spec -> spec.createTerminalHandler(r -> Mono.empty())), + async("terminalOutputHandler", spec -> spec.terminalOutputHandler(r -> Mono.empty())), + async("releaseTerminalHandler", spec -> spec.releaseTerminalHandler(r -> Mono.empty())), + async("waitForTerminalExitHandler", spec -> spec.waitForTerminalExitHandler(r -> Mono.empty())), + async("killTerminalHandler", spec -> spec.killTerminalHandler(r -> Mono.empty())), + async("createElicitationHandler", spec -> spec.createElicitationHandler(r -> Mono.empty())), + async("completeElicitationHandler", spec -> spec.completeElicitationHandler(n -> Mono.empty())), + async("requestHandler", spec -> spec.requestHandler("_x/raw", params -> Mono.just("raw"))), + async("notificationHandler", spec -> spec.notificationHandler("_x/note", params -> Mono.empty())), + async("extRequestHandler", spec -> spec.extRequestHandler("_x/ext", params -> Mono.just("ok"))), + async("extNotificationHandler", spec -> spec.extNotificationHandler("_x/extnote", params -> Mono.empty()))); + } + + static Stream syncRegistrations() { + return Stream.of( + sync("readTextFileHandler", spec -> spec.readTextFileHandler(r -> null)), + sync("writeTextFileHandler", spec -> spec.writeTextFileHandler(r -> null)), + sync("requestPermissionHandler", spec -> spec.requestPermissionHandler(r -> null)), + sync("createTerminalHandler", spec -> spec.createTerminalHandler(r -> null)), + sync("terminalOutputHandler", spec -> spec.terminalOutputHandler(r -> null)), + sync("releaseTerminalHandler", spec -> spec.releaseTerminalHandler(r -> null)), + sync("waitForTerminalExitHandler", spec -> spec.waitForTerminalExitHandler(r -> null)), + sync("killTerminalHandler", spec -> spec.killTerminalHandler(r -> null)), + sync("createElicitationHandler", spec -> spec.createElicitationHandler(r -> null)), + sync("completeElicitationHandler", spec -> spec.completeElicitationHandler(n -> { + })), + sync("requestHandler", spec -> spec.requestHandler("_x/raw", params -> "raw")), + sync("extRequestHandler", spec -> spec.extRequestHandler("_x/ext", params -> "ok")), + sync("extNotificationHandler", spec -> spec.extNotificationHandler("_x/extnote", params -> { + }))); + } + + @ParameterizedTest + @MethodSource("asyncRegistrations") + void aSecondAsyncRegistrationIsRefused(Consumer register, String setter) { + AcpClient.AsyncSpec spec = AcpClient.async(new MockAcpClientTransport()); + register.accept(spec); + + assertThatThrownBy(() -> register.accept(spec)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already registered") + .hasMessageContaining(setter); + } + + @ParameterizedTest + @MethodSource("syncRegistrations") + void aSecondSyncRegistrationIsRefused(Consumer register, String setter) { + AcpClient.SyncSpec spec = AcpClient.sync(new MockAcpClientTransport()); + register.accept(spec); + + assertThatThrownBy(() -> register.accept(spec)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already registered") + .hasMessageContaining(setter); + } + + @Test + void anExtensionHandlerAndARawHandlerForTheSameMethodAreRefused() { + AcpClient.AsyncSpec spec = AcpClient.async(new MockAcpClientTransport()) + .extRequestHandler("_x/both", params -> Mono.just("ext")); + + assertThatThrownBy(() -> spec.requestHandler("_x/both", params -> Mono.just("raw"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("_x/both") + .hasMessageContaining("requestHandler"); + } + + @Test + void nullHandlersFailAtOnce() { + AcpClient.AsyncSpec async = AcpClient.async(new MockAcpClientTransport()); + AcpClient.SyncSpec sync = AcpClient.sync(new MockAcpClientTransport()); + List nullRegistrations = List.of(() -> async.readTextFileHandler(null), + () -> async.createElicitationHandler(null), () -> async.completeElicitationHandler(null), + () -> async.sessionUpdateHandler(null), () -> async.requestHandler("_x/a", null), + () -> async.notificationHandler("_x/b", null), () -> async.extRequestHandler("_x/c", null), + () -> async.extNotificationHandler("_x/d", null), () -> sync.readTextFileHandler(null), + () -> sync.killTerminalHandler(null), () -> sync.sessionUpdateHandler(null), + () -> sync.requestHandler("_x/e", null), () -> sync.extRequestHandler("_x/f", null), + () -> sync.extNotificationHandler("_x/g", null)); + for (Runnable registration : nullRegistrations) { + assertThatThrownBy(registration::run).isInstanceOf(IllegalArgumentException.class); + } + } + + private static Arguments async(String setter, Consumer registration) { + return Arguments.of(Named.of(setter, registration), setter); + } + + private static Arguments sync(String setter, Consumer registration) { + return Arguments.of(Named.of(setter, registration), setter); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/DefaultSessionUpdateHandlerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/DefaultSessionUpdateHandlerTest.java new file mode 100644 index 00000000..07da8296 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/DefaultSessionUpdateHandlerTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A framework sets a default session update handler, for example one that logs at DEBUG; the + * application's own handler replaces it rather than running beside it, whichever is registered + * first. + */ +class DefaultSessionUpdateHandlerTest { + + private static final AcpSchema.JSONRPCNotification UPDATE = new AcpSchema.JSONRPCNotification( + AcpSchema.JSONRPC_VERSION, AcpSchema.METHOD_SESSION_UPDATE, + Map.of("sessionId", "s-1", "update", Map.of("sessionUpdate", "agent_message_chunk", "content", + Map.of("type", "text", "text", "hi")))); + + @Test + void theDefaultReceivesUpdatesWhenTheApplicationAddsNone() { + List received = new CopyOnWriteArrayList<>(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpAsyncClient client = AcpClient.async(transport) + .defaultSessionUpdateHandler(record(received, "default")) + .build(); + + transport.simulateIncomingMessage(UPDATE); + + eventually(() -> assertThat(received).containsExactly("default")); + client.close(); + } + + @Test + void anApplicationConsumerReplacesTheDefault() { + List received = new CopyOnWriteArrayList<>(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpAsyncClient client = AcpClient.async(transport) + .defaultSessionUpdateHandler(record(received, "default")) + .sessionUpdateHandler(record(received, "app")) + .build(); + + transport.simulateIncomingMessage(UPDATE); + transport.simulateIncomingMessage(UPDATE); + + eventually(() -> assertThat(received).containsExactly("app", "app")); + client.close(); + } + + @Test + void theOrderOfRegistrationDoesNotMatter() { + List received = new CopyOnWriteArrayList<>(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpAsyncClient client = AcpClient.async(transport) + .sessionUpdateHandler(record(received, "app")) + .defaultSessionUpdateHandler(record(received, "default")) + .build(); + + transport.simulateIncomingMessage(UPDATE); + + eventually(() -> assertThat(received).containsExactly("app")); + client.close(); + } + + @Test + void theSyncBuilderHasTheSameDefault() { + List received = new CopyOnWriteArrayList<>(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + Consumer defaultConsumer = notification -> received.add("default"); + AcpSyncClient defaultOnly = AcpClient.sync(transport).defaultSessionUpdateHandler(defaultConsumer).build(); + transport.simulateIncomingMessage(UPDATE); + eventually(() -> assertThat(received).containsExactly("default")); + defaultOnly.close(); + + received.clear(); + MockAcpClientTransport other = new MockAcpClientTransport(); + AcpSyncClient replaced = AcpClient.sync(other) + .defaultSessionUpdateHandler(defaultConsumer) + .sessionUpdateHandler(notification -> received.add("app")) + .build(); + other.simulateIncomingMessage(UPDATE); + eventually(() -> assertThat(received).containsExactly("app")); + replaced.close(); + } + + @Test + void aSecondDefaultIsRefused() { + AcpClient.AsyncSpec spec = AcpClient.async(new MockAcpClientTransport()) + .defaultSessionUpdateHandler(notification -> Mono.empty()); + + assertThatThrownBy(() -> spec.defaultSessionUpdateHandler(notification -> Mono.empty())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("defaultSessionUpdateHandler"); + assertThatThrownBy(() -> spec.defaultSessionUpdateHandler(null)).isInstanceOf(IllegalArgumentException.class); + } + + /** Retries the assertion for up to five seconds: updates are delivered asynchronously. */ + private static void eventually(Runnable assertion) { + long deadline = System.nanoTime() + 5_000_000_000L; + while (true) { + try { + assertion.run(); + // Give a wrongly delivered extra update the chance to show up. + Thread.sleep(100); + assertion.run(); + return; + } + catch (AssertionError e) { + if (System.nanoTime() > deadline) { + throw e; + } + Thread.onSpinWait(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + } + + private static java.util.function.Function> record(List received, + String name) { + return notification -> Mono.fromRunnable(() -> received.add(name)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/HandlerExceptionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/HandlerExceptionTest.java index 4e84c348..665aa52b 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/HandlerExceptionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/HandlerExceptionTest.java @@ -57,7 +57,8 @@ void handlerExceptionConvertedToJsonRpcError() { assertThat(response.result()).isNull(); assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(-32603); // Internal Error - assertThat(response.error().message()).contains(errorMessage); + // The exception's message stays at the client: the agent gets the generic message. + assertThat(response.error().message()).isEqualTo("Internal error"); client.close(); } @@ -92,7 +93,7 @@ void ioExceptionConvertedToJsonRpcError() { AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) transport.getSentMessages().get(0); assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(-32603); - assertThat(response.error().message()).contains("Permission denied"); + assertThat(response.error().message()).isEqualTo("Internal error"); client.close(); } @@ -102,6 +103,31 @@ void ioExceptionConvertedToJsonRpcError() { * handler runs asynchronously, so a fixed sleep races the dispatch and fails * intermittently on a loaded machine; poll to a generous deadline instead. */ + /** + * JSON-RPC 2.0 section 5.1: params the method cannot read are -32602 Invalid params, not + * -32603 Internal error, and the handler is never called. + */ + @Test + void paramsOfTheWrongTypeAreInvalidParams() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + java.util.concurrent.atomic.AtomicBoolean called = new java.util.concurrent.atomic.AtomicBoolean(); + AcpSyncClient client = AcpClient.sync(transport).readTextFileHandler(req -> { + called.set(true); + return new AcpSchema.ReadTextFileResponse("x"); + }).build(); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "bad-params", + AcpSchema.METHOD_FS_READ_TEXT_FILE, Map.of("sessionId", "s", "path", "/f", "line", "not-a-number"))); + awaitSentMessages(transport, 1); + + AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) transport.getSentMessages().get(0); + assertThat(response.id()).isEqualTo("bad-params"); + assertThat(response.error().code()).isEqualTo(-32602); + assertThat(response.error().message()).isEqualTo("Invalid params"); + assertThat(called).isFalse(); + client.close(); + } + private static void awaitSentMessages(MockAcpClientTransport transport, int expected) { long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); while (transport.getSentMessages().size() < expected && System.nanoTime() < deadline) { diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptCancellationSignalTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptCancellationSignalTest.java new file mode 100644 index 00000000..dc23dabf --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptCancellationSignalTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A client stops a prompt turn with a {@link CancellationSignal} passed to {@code prompt}, found by + * completion on the client, instead of a Reactor context entry: cancelling it sends + * {@code session/cancel} for the prompt's session, and the prompt still returns the agent's answer, + * stop reason {@code cancelled}. + */ +class PromptCancellationSignalTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + private final CountDownLatch started = new CountDownLatch(1); + + private final AtomicInteger cancels = new AtomicInteger(); + + private AcpAsyncAgent agent; + + @BeforeEach + void startAgent() { + agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> context.sendMessage("working") + .doOnSuccess(v -> started.countDown()) + .then(context.whenCancelled()) + .thenReturn(AcpSchema.PromptResponse.cancelled())) + .cancelHandler(notification -> Mono.fromRunnable(cancels::incrementAndGet)) + .build(); + agent.start().block(TIMEOUT); + } + + @AfterEach + void stop() { + agent.close(); + pair.closeGracefully().block(TIMEOUT); + } + + private static AcpSchema.PromptRequest request(String sessionId) { + return new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("go"))); + } + + @Test + void asyncPromptStopsWhenTheSignalIsCancelled() throws Exception { + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())) + .block(TIMEOUT) + .sessionId(); + CancellationSignal stop = new CancellationSignal(); + CompletableFuture answer = client.prompt(request(sessionId), stop).toFuture(); + assertThat(started.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(answer).isNotDone(); + + stop.cancel(); + + assertThat(answer.get(5, TimeUnit.SECONDS).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(stop.isCancelled()).isTrue(); + assertThat(cancels.get()).isEqualTo(1); + } + finally { + client.close(); + } + } + + @Test + void syncPromptStopsWhenAnotherThreadCancelsTheSignal() throws Exception { + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize(); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).sessionId(); + CancellationSignal stop = new CancellationSignal(); + Thread stopper = new Thread(() -> { + try { + if (started.await(5, TimeUnit.SECONDS)) { + stop.cancel(); + } + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + }); + stopper.start(); + + AcpSchema.PromptResponse response = client.prompt(request(sessionId), stop); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + stopper.join(5000); + } + finally { + client.close(); + } + } + + @Test + void aSignalNotCancelledSendsNoCancel() { + InMemoryTransportPair other = InMemoryTransportPair.create(); + AtomicInteger otherCancels = new AtomicInteger(); + AcpAsyncAgent endTurn = AcpAgent.async(other.agentTransport()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .cancelHandler(notification -> Mono.fromRunnable(otherCancels::incrementAndGet)) + .build(); + endTurn.start().block(TIMEOUT); + AcpSyncClient client = AcpClient.sync(other.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize(); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).sessionId(); + CancellationSignal stop = new CancellationSignal(); + + assertThat(client.prompt(request(sessionId), stop).stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + stop.cancel(); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())); + + assertThat(otherCancels.get()).isZero(); + } + finally { + client.close(); + endTurn.close(); + other.closeGracefully().block(TIMEOUT); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptTimeoutTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptTimeoutTest.java new file mode 100644 index 00000000..c7bb8b1e --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptTimeoutTest.java @@ -0,0 +1,139 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeoutException; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A prompt turn is bounded by the client's {@code promptTimeout}, not by its request timeout: a turn + * that runs longer than the request timeout (a 40-second turn under the default 30-second request + * timeout, scaled down here) still gets its answer and is never cancelled. + */ +class PromptTimeoutTest { + + private static final Duration REQUEST_TIMEOUT = Duration.ofMillis(200); + + private static final Duration TURN = Duration.ofMillis(800); + + /** Answers initialize at once and every session/prompt after {@link #TURN}. */ + private static MockAcpClientTransport slowPromptAgent() { + return new MockAcpClientTransport((t, msg) -> { + if (msg instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + t.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), + AcpSchema.InitializeResponse.ok(), null)); + } + if (msg instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_SESSION_PROMPT.equals(request.method())) { + Mono.delay(TURN, Schedulers.parallel()) + .subscribe(tick -> { + try { + t.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, + request.id(), new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN), null)); + } + catch (RuntimeException closed) { + // the client was closed before the turn ended + } + }); + } + }); + } + + private static AcpSchema.PromptRequest prompt() { + return new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("work for a while"))); + } + + private static boolean sentCancelRequest(MockAcpClientTransport transport) { + return transport.getSentMessages() + .stream() + .anyMatch(m -> m instanceof AcpSchema.JSONRPCNotification n + && AcpSchema.METHOD_CANCEL_REQUEST.equals(n.method())); + } + + @Test + void asyncPromptLongerThanRequestTimeoutIsNotCutOff() { + MockAcpClientTransport transport = slowPromptAgent(); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(REQUEST_TIMEOUT).build(); + try { + client.initialize().block(Duration.ofSeconds(10)); + AcpSchema.PromptResponse response = client.prompt(prompt()).block(Duration.ofSeconds(10)); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(sentCancelRequest(transport)).as("no $/cancel_request for a prompt").isFalse(); + } + finally { + client.close(); + } + } + + @Test + void syncPromptLongerThanRequestTimeoutIsNotCutOff() { + MockAcpClientTransport transport = slowPromptAgent(); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(REQUEST_TIMEOUT).build(); + try { + client.initialize(); + assertThat(client.prompt(prompt()).stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(sentCancelRequest(transport)).isFalse(); + } + finally { + client.close(); + } + } + + @Test + void promptTimeoutBoundsTheTurnAndCancelsIt() { + MockAcpClientTransport transport = slowPromptAgent(); + AcpAsyncClient client = AcpClient.async(transport) + .requestTimeout(Duration.ofSeconds(10)) + .promptTimeout(REQUEST_TIMEOUT) + .build(); + try { + client.initialize().block(Duration.ofSeconds(10)); + assertThatThrownBy(() -> client.prompt(prompt()).block(Duration.ofSeconds(10))) + .hasCauseInstanceOf(TimeoutException.class); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (!sentCancelRequest(transport) && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertThat(sentCancelRequest(transport)).isTrue(); + } + finally { + client.close(); + } + } + + @Test + void syncPromptTimeoutBoundsTheTurn() { + MockAcpClientTransport transport = slowPromptAgent(); + AcpSyncClient client = AcpClient.sync(transport).promptTimeout(REQUEST_TIMEOUT).build(); + try { + client.initialize(); + assertThatThrownBy(() -> client.prompt(prompt())).hasRootCauseInstanceOf(TimeoutException.class); + } + finally { + client.close(); + } + } + + @Test + void promptTimeoutRejectsNullAndNegative() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + assertThatThrownBy(() -> AcpClient.async(transport).promptTimeout(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> AcpClient.async(transport).promptTimeout(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptUpdateOrderTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptUpdateOrderTest.java new file mode 100644 index 00000000..95bb024c --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/PromptUpdateOrderTest.java @@ -0,0 +1,282 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentMessageChunk; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code prompt()} returns only once the session-update handler has handled every update the + * agent sent before its prompt response, so the updates a caller collects are complete when + * the stop reason arrives. The update handlers here are slow on purpose: before, {@code prompt()} + * returned while they were still running. + */ +class PromptUpdateOrderTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final String SESSION = "order-session"; + + private static final int UPDATES = 3; + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private AcpSyncAgent agent; + + @AfterEach + void tearDown() { + if (this.agent != null) { + this.agent.close(); + } + this.transportPair.closeGracefully().block(TIMEOUT); + } + + /** An agent whose turn sends {@value #UPDATES} message chunks, then ends. */ + private void startAgent() { + this.agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> InitializeResponse.ok()) + .newSessionHandler(request -> new NewSessionResponse(SESSION, null, null)) + .setSessionModeHandler(request -> new AcpSchema.SetSessionModeResponse()) + .promptHandler((request, context) -> { + for (int i = 1; i <= UPDATES; i++) { + context.sendMessage("chunk " + i); + } + return PromptResponse.endTurn(); + }) + .build(); + this.agent.start(); + } + + private static PromptRequest prompt() { + return new PromptRequest(SESSION, List.of(new TextContent("go"))); + } + + private static String text(AcpSchema.SessionNotification notification) { + return ((TextContent) ((AgentMessageChunk) notification.update()).content()).text(); + } + + @Test + void syncPromptReturnsAfterTheConsumerHandledEveryUpdateOfTheTurn() { + startAgent(); + List handled = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + sleep(100); + handled.add(text(notification)); + }) + .build(); + try { + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + PromptResponse response = client.prompt(prompt()); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(handled).containsExactly("chunk 1", "chunk 2", "chunk 3"); + } + finally { + client.close(); + } + } + + @Test + void asyncPromptCompletesAfterTheConsumerHandledEveryUpdateOfTheTurn() { + startAgent(); + List handled = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.delay(Duration.ofMillis(100)) + .then(Mono.fromRunnable(() -> handled.add(text(notification))))) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + PromptResponse response = client.prompt(prompt()).block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(handled).containsExactly("chunk 1", "chunk 2", "chunk 3"); + } + finally { + client.close(); + } + } + + /** + * An update handler that calls the agent and waits for the answer, while the agent's later + * updates arrive first: that answer is not held behind the update handler that waits for it. + */ + @Test + void aConsumerThatWaitsForARequestOfItsOwnDoesNotDeadlockThePrompt() { + startAgent(); + List handled = new CopyOnWriteArrayList<>(); + AtomicReference self = new AtomicReference<>(); + AcpSyncClient client = AcpClient.sync(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + if (handled.isEmpty()) { + self.get().setSessionMode(new AcpSchema.SetSessionModeRequest(SESSION, "code")); + } + handled.add(text(notification)); + }) + .build(); + self.set(client); + try { + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + long start = System.nanoTime(); + client.prompt(prompt()); + + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(TIMEOUT.dividedBy(2)); + assertThat(handled).containsExactly("chunk 1", "chunk 2", "chunk 3"); + } + finally { + client.close(); + } + } + + /** + * The agent announces a tool call with a {@code tool_call} update, then asks permission for + * it: the permission handler runs only after the session update handler handled the + * announcement, so a client can look the tool call up when it asks the user. + */ + @Test + void anAgentRequestReachesItsHandlerAfterTheUpdatesSentBeforeIt() { + this.agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> InitializeResponse.ok()) + .newSessionHandler(request -> new NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + context.sendSessionUpdate(new AcpSchema.ToolCall(null, "call-1", "Edit file", null, + AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING, null, null, null, null, null)); + context.client().requestPermission(new AcpSchema.RequestPermissionRequest(SESSION, + new AcpSchema.ToolCallUpdate("call-1", "Edit file", AcpSchema.ToolKind.EDIT, + AcpSchema.ToolCallStatus.PENDING), + List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE)))); + return PromptResponse.endTurn(); + }) + .build(); + this.agent.start(); + + List announced = new CopyOnWriteArrayList<>(); + List knownWhenAsked = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + sleep(300); + if (notification.update() instanceof AcpSchema.ToolCall toolCall) { + announced.add(toolCall.toolCallId()); + } + }) + .requestPermissionHandler(request -> { + knownWhenAsked.add(announced.contains(request.toolCall().toolCallId())); + return new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow")); + }) + .build(); + try { + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + client.prompt(prompt()); + + assertThat(knownWhenAsked).containsExactly(true); + } + finally { + client.close(); + } + } + + /** + * An update handler that waits for a prompt of its own, during which the agent asks the client + * something: the agent's request is not held behind the update handler that waits for it. + */ + @Test + void aConsumerWaitingForAPromptDoesNotHoldTheAgentsRequestsOfThatPrompt() { + this.agent = AcpAgent.sync(this.transportPair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> InitializeResponse.ok()) + .newSessionHandler(request -> new NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + String text = ((TextContent) request.prompt().get(0)).text(); + if ("first".equals(text)) { + context.sendMessage("trigger"); + } + else { + context.client().requestPermission(new AcpSchema.RequestPermissionRequest(SESSION, + new AcpSchema.ToolCallUpdate("call-2", "Edit", AcpSchema.ToolKind.EDIT, + AcpSchema.ToolCallStatus.PENDING), + List.of(new AcpSchema.PermissionOption("allow", "Allow", + AcpSchema.PermissionOptionKind.ALLOW_ONCE)))); + } + return PromptResponse.endTurn(); + }) + .build(); + this.agent.start(); + + AtomicReference self = new AtomicReference<>(); + List nested = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(this.transportPair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + if (nested.isEmpty()) { + nested.add("asking"); + // The agent answers only after the client answered its permission request. + nested.add(self.get() + .prompt(new PromptRequest(SESSION + "-2", List.of(new TextContent("second")))) + .stopReason() + .toString()); + } + }) + .requestPermissionHandler( + request -> new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))) + .build(); + self.set(client); + try { + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + long start = System.nanoTime(); + client.prompt(new PromptRequest(SESSION, List.of(new TextContent("first")))); + + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(TIMEOUT.dividedBy(2)); + assertThat(nested).containsExactly("asking", "end_turn"); + } + finally { + client.close(); + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RawClientHandlerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RawClientHandlerTest.java new file mode 100644 index 00000000..758e2daf --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RawClientHandlerTest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The raw {@code requestHandler} and {@code notificationHandler} are the escape hatch for methods + * the SDK does not model: for a method with a typed setter they would bypass its typed params and + * its checks (the elicitation mode check), so they refuse it and name the setter to use. The sync + * builder's raw notification handler is a blocking consumer, as its other handlers are. + */ +class RawClientHandlerTest { + + static Stream typedRequests() { + return Stream.of(Arguments.of(AcpSchema.METHOD_FS_READ_TEXT_FILE, "readTextFileHandler"), + Arguments.of(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, "writeTextFileHandler"), + Arguments.of(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, "requestPermissionHandler"), + Arguments.of(AcpSchema.METHOD_TERMINAL_CREATE, "createTerminalHandler"), + Arguments.of(AcpSchema.METHOD_TERMINAL_OUTPUT, "terminalOutputHandler"), + Arguments.of(AcpSchema.METHOD_TERMINAL_RELEASE, "releaseTerminalHandler"), + Arguments.of(AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, "waitForTerminalExitHandler"), + Arguments.of(AcpSchema.METHOD_TERMINAL_KILL, "killTerminalHandler"), + Arguments.of(AcpSchema.METHOD_ELICITATION_CREATE, "createElicitationHandler")); + } + + static Stream typedNotifications() { + return Stream.of(Arguments.of(AcpSchema.METHOD_SESSION_UPDATE, "sessionUpdateHandler"), + Arguments.of(AcpSchema.METHOD_ELICITATION_COMPLETE, "completeElicitationHandler")); + } + + @ParameterizedTest + @MethodSource("typedRequests") + void aRawRequestHandlerForATypedMethodIsRefused(String method, String setter) { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .requestHandler(method, params -> Mono.just("raw"))).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(method) + .hasMessageContaining(setter); + assertThatThrownBy(() -> AcpClient.sync(new MockAcpClientTransport()).requestHandler(method, params -> "raw")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(setter); + } + + @ParameterizedTest + @MethodSource("typedNotifications") + void aRawNotificationHandlerForATypedMethodIsRefused(String method, String setter) { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .notificationHandler(method, params -> Mono.empty())).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(method) + .hasMessageContaining(setter); + assertThatThrownBy(() -> AcpClient.sync(new MockAcpClientTransport()).notificationHandler(method, params -> { + })).isInstanceOf(IllegalArgumentException.class).hasMessageContaining(setter); + } + + @Test + void aRawRequestHandlerStillServesAMethodTheSdkDoesNotModel() throws Exception { + CompletableFuture answer = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + answer.complete(response); + } + }); + AcpAsyncClient client = AcpClient.async(transport) + .requestHandler("future/method", params -> Mono.just(Map.of("ok", true))) + .build(); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "r-1", "future/method", Map.of())); + + assertThat(answer.get(5, TimeUnit.SECONDS).result()).isEqualTo(Map.of("ok", true)); + client.close(); + } + + @Test + void theSyncRawNotificationHandlerIsABlockingConsumer() throws Exception { + CompletableFuture received = new CompletableFuture<>(); + CompletableFuture thread = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpSyncClient client = AcpClient.sync(transport).notificationHandler("future/note", params -> { + thread.complete(Thread.currentThread().getName()); + received.complete(params); + }).build(); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "future/note", + Map.of("n", 1))); + + assertThat(received.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("n", 1)); + assertThat(thread.get(5, TimeUnit.SECONDS)).startsWith("acp-sync-handler"); + client.close(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RejectedResponseErrorTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RejectedResponseErrorTest.java new file mode 100644 index 00000000..4aebe4f8 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/RejectedResponseErrorTest.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +/** + * A caller catches one type for a request that failed with an error code: {@link AcpError}, for + * the peer's error response and for a response the SDK rejected itself alike. {@code data} tells + * the two apart. + */ +class RejectedResponseErrorTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + @Test + void aResultWithoutARequiredFieldFailsWithAcpError() { + AcpSyncClient client = clientAnswering(Map.of(AcpSchema.METHOD_SESSION_PROMPT, Map.of())); + + AcpError error = catchThrowableOfType(AcpError.class, + () -> client.prompt(new AcpSchema.PromptRequest("s-1", List.of(new AcpSchema.TextContent("hi"))))); + + assertThat(error).isNotNull(); + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).isEqualTo("The response to session/prompt lacks the required field stopReason"); + assertThat(error.getData()).isEqualTo( + Map.of("reason", "missing-required-field", "method", "session/prompt", "field", "stopReason")); + client.close(); + } + + @Test + void aNestedMissingFieldIsNamedByItsPath() { + AcpSyncClient client = clientAnswering(Map.of(AcpSchema.METHOD_SESSION_NEW, + Map.of("sessionId", "s-1", "modes", Map.of("availableModes", List.of())))); + + AcpError error = catchThrowableOfType(AcpError.class, + () -> client.newSession(new AcpSchema.NewSessionRequest("/", List.of()))); + + assertThat(error).isNotNull(); + assertThat(error.getMessage()).doesNotContain("-32603"); + assertThat(error.getData()).isEqualTo( + Map.of("reason", "missing-required-field", "method", "session/new", "field", "modes.currentModeId")); + client.close(); + } + + @Test + void aResponseWithoutAResultFailsWithAcpError() { + AcpSyncClient client = clientAnswering(Map.of(AcpSchema.METHOD_SESSION_NEW, NO_RESULT)); + + AcpError error = catchThrowableOfType(AcpError.class, + () -> client.newSession(new AcpSchema.NewSessionRequest("/", List.of()))); + + assertThat(error).isNotNull(); + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).isEqualTo("The response to session/new carried no result"); + assertThat(error.getData()).isEqualTo(Map.of("reason", "missing-result", "method", "session/new")); + client.close(); + } + + @Test + void aResultOfTheWrongShapeFailsWithAcpError() { + AcpSyncClient client = clientAnswering( + Map.of(AcpSchema.METHOD_SESSION_NEW, Map.of("sessionId", Map.of("not", "a string")))); + + AcpError error = catchThrowableOfType(AcpError.class, + () -> client.newSession(new AcpSchema.NewSessionRequest("/", List.of()))); + + assertThat(error).isNotNull(); + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).startsWith("The response to session/new could not be read"); + assertThat(error.getData()).isInstanceOfSatisfying(Map.class, data -> { + assertThat(data.get("reason")).isEqualTo("unreadable-result"); + assertThat(data.get("method")).isEqualTo("session/new"); + }); + client.close(); + } + + @Test + void aPeersErrorResponseIsStillAcpErrorWithThePeersData() { + AcpSyncClient client = clientAnswering(Map.of(AcpSchema.METHOD_SESSION_NEW, + new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, "boom", Map.of("details", "disk full")))); + + AcpError error = catchThrowableOfType(AcpError.class, + () -> client.newSession(new AcpSchema.NewSessionRequest("/", List.of()))); + + assertThat(error).isNotNull(); + assertThat(error.getData()).isEqualTo(Map.of("details", "disk full")); + client.close(); + } + + private static final Object NO_RESULT = new Object(); + + /** A client whose agent answers initialize, and each given method with the given result. */ + private static AcpSyncClient clientAnswering(Map results) { + Map answers = new HashMap<>(results); + answers.put(AcpSchema.METHOD_INITIALIZE, Map.of("protocolVersion", 1, "agentCapabilities", Map.of())); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCRequest request && answers.containsKey(request.method())) { + Object answer = answers.get(request.method()); + AcpSchema.JSONRPCResponse response = answer instanceof AcpSchema.JSONRPCError error + ? new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, error) + : new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), + answer == NO_RESULT ? null : answer, null); + t.simulateIncomingMessage(response); + } + }); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).build(); + client.initialize(); + return client; + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/SyncHandlerSchedulerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/SyncHandlerSchedulerTest.java new file mode 100644 index 00000000..d1fb7a91 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/SyncHandlerSchedulerTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The default handler executor on either JDK, whichever JDK runs the test: the virtual-thread + * executor where there is one (JDK 21 and later), else a cached pool of named daemon threads. + */ +class SyncHandlerSchedulerTest { + + @Test + void theVirtualThreadExecutorIsUsedWhenThereIsOne() { + ExecutorService virtual = Executors.newSingleThreadExecutor(); + try { + assertThat(SyncHandlerScheduler.executor(virtual)).isSameAs(virtual); + } + finally { + virtual.shutdownNow(); + } + } + + @Test + void withoutOneACachedPoolOfNamedDaemonThreads() throws Exception { + ExecutorService pool = SyncHandlerScheduler.executor(null); + try { + Thread thread = pool.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("acp-sync-handler"); + assertThat(thread.isDaemon()).isTrue(); + assertThat(VirtualThreads.isVirtual(thread)).isFalse(); + } + finally { + pool.shutdownNow(); + } + } + + @Test + void theDefaultIsVirtualExactlyWhereTheJdkHasVirtualThreads() throws Exception { + CompletableFuture ran = new CompletableFuture<>(); + SyncHandlerScheduler.DEFAULT.schedule(() -> ran.complete(Thread.currentThread())); + Thread thread = ran.get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("acp-sync-handler"); + assertThat(VirtualThreads.isVirtual(thread)).isEqualTo(VirtualThreads.isSupported()); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/TypedHandlerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/TypedHandlerTest.java index 38d62649..99ce760b 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/TypedHandlerTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/TypedHandlerTest.java @@ -57,7 +57,7 @@ void typedReadTextFileHandlerReceivesUnmarshalledRequest() throws InterruptedExc AcpSchema.JSONRPC_VERSION, "test-id", AcpSchema.METHOD_FS_READ_TEXT_FILE, - Map.of("path", testPath) + Map.of("sessionId", "session-123", "path", testPath) ); transport.simulateIncomingMessage(request); @@ -109,7 +109,7 @@ void typedWriteTextFileHandlerReceivesUnmarshalledRequest() throws InterruptedEx AcpSchema.JSONRPC_VERSION, "test-id", AcpSchema.METHOD_FS_WRITE_TEXT_FILE, - Map.of("path", testPath, "content", testContent) + Map.of("sessionId", "session-123", "path", testPath, "content", testContent) ); transport.simulateIncomingMessage(request); @@ -153,6 +153,7 @@ void typedRequestPermissionHandlerReceivesUnmarshalledRequest() throws Interrupt // Simulate incoming request from agent - toolCall is a nested structure Map toolCall = Map.of( + "toolCallId", "call-1", "title", "Read File", "description", "Reading test.txt" ); @@ -160,7 +161,7 @@ void typedRequestPermissionHandlerReceivesUnmarshalledRequest() throws Interrupt AcpSchema.JSONRPC_VERSION, "test-id", AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, - Map.of("sessionId", testSessionId, "toolCall", toolCall) + Map.of("sessionId", testSessionId, "toolCall", toolCall, "options", java.util.List.of()) ); transport.simulateIncomingMessage(request); @@ -202,7 +203,7 @@ void asyncTypedReadTextFileHandlerWorksWithMono() throws InterruptedException { AcpSchema.JSONRPC_VERSION, "test-id", AcpSchema.METHOD_FS_READ_TEXT_FILE, - Map.of("path", testPath) + Map.of("sessionId", "session-123", "path", testPath) ); transport.simulateIncomingMessage(request); @@ -244,7 +245,7 @@ void typedHandlerErrorPropagatesToJsonRpcError() throws InterruptedException { AcpSchema.JSONRPC_VERSION, "test-id", AcpSchema.METHOD_FS_READ_TEXT_FILE, - Map.of("path", "/nonexistent") + Map.of("sessionId", "session-123", "path", "/nonexistent") ); transport.simulateIncomingMessage(request); @@ -257,7 +258,7 @@ void typedHandlerErrorPropagatesToJsonRpcError() throws InterruptedException { AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) sentMessage; assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(-32603); // Internal error - assertThat(response.error().message()).isEqualTo(errorMessage); + assertThat(response.error().message()).isEqualTo("Internal error"); client.close(); } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentEnvironmentTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentEnvironmentTest.java new file mode 100644 index 00000000..99c39913 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentEnvironmentTest.java @@ -0,0 +1,83 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The agent process's environment: by default the client's whole environment plus + * {@link AgentParameters#getEnv()}; with {@code inheritEnvironment(false)} only the safe default + * variables and the ones added on the builder, so the client's secrets do not reach the agent. + */ +class AgentEnvironmentTest { + + private static final Set SAFE = Set.of("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER", "APPDATA", + "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", + "USERNAME", "USERPROFILE"); + + /** Starts {@code java -version} and returns the environment it was started with. */ + private static Map environmentOf(AgentParameters params) { + ProcessBuilder[] used = new ProcessBuilder[1]; + StdioAcpClientTransport transport = new StdioAcpClientTransport(params) { + @Override + protected ProcessBuilder getProcessBuilder() { + used[0] = super.getProcessBuilder(); + return used[0]; + } + }; + try { + transport.connect(message -> Mono.empty()).block(Duration.ofSeconds(30)); + return Map.copyOf(used[0].environment()); + } + finally { + transport.close(); + } + } + + private static String java() { + return Path.of(System.getProperty("java.home"), "bin", "java").toString(); + } + + private static String aVariableOutsideTheSafeList() { + List candidates = System.getenv().keySet().stream().filter(name -> !SAFE.contains(name)).toList(); + assertThat(candidates).as("the test JVM has a variable outside the safe list").isNotEmpty(); + return candidates.get(0); + } + + @Test + void byDefaultTheAgentInheritsTheClientsWholeEnvironment() { + String inherited = aVariableOutsideTheSafeList(); + Map env = environmentOf( + AgentParameters.builder(java()).arg("-version").addEnvVar("ACP_ADDED", "1").build()); + + assertThat(env).containsKey(inherited).containsEntry("ACP_ADDED", "1"); + } + + @Test + void withoutInheritingTheAgentGetsOnlyTheSafeListAndWhatWasAdded() { + String notInherited = aVariableOutsideTheSafeList(); + AgentParameters params = AgentParameters.builder(java()) + .arg("-version") + .addEnvVar("ACP_ADDED", "1") + .inheritEnvironment(false) + .build(); + assertThat(params.isInheritEnvironment()).isFalse(); + + Map env = environmentOf(params); + + assertThat(env).doesNotContainKey(notInherited).containsEntry("ACP_ADDED", "1"); + assertThat(env.keySet()).allMatch(name -> SAFE.contains(name) || name.equals("ACP_ADDED")); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentParametersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentParametersTest.java index 5375c9ab..14cfea04 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentParametersTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/AgentParametersTest.java @@ -67,6 +67,13 @@ void testBuilderWithArgsVarargs() { assertThat(params.getArgs()).containsExactly("--experimental-acp", "--model", "gemini-1.5-pro"); } + @Test + void argAfterArgsVarargsAppends() { + AgentParameters params = AgentParameters.builder("agent").args("--a", "--b").arg("--c").build(); + + assertThat(params.getArgs()).containsExactly("--a", "--b", "--c"); + } + @Test void testBuilderWithArgsList() { List argsList = Arrays.asList("--experimental-acp", "--model", "gemini-1.5-pro"); @@ -162,7 +169,9 @@ void testBuilderWithEmptyEnvMap() { void testDefaultEnvironmentVariablesIncluded() { AgentParameters params = AgentParameters.builder("gemini").build(); - // Should include safe default environment variables like PATH, HOME, USER, etc. + // getEnv() starts with the default variables copied from the client's environment (PATH, + // HOME, USER, ...), not the whole environment: the process inherits that separately, + // unless inheritEnvironment(false) assertThat(params.getEnv()).isNotEmpty(); // PATH should always be included @@ -172,7 +181,7 @@ void testDefaultEnvironmentVariablesIncluded() { assertThat(params.getEnv()).containsKey("PATH"); } else { - // Unix-like systems should have PATH and HOME + // Unix-like systems should have PATH (HOME is copied only when it is set) assertThat(params.getEnv()).containsKey("PATH"); } } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCharsetTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCharsetTest.java new file mode 100644 index 00000000..5c139699 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCharsetTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +/** + * The agent writes UTF-8 (ACP requires it), so the client must read the agent's stdout as + * UTF-8 whatever the JVM's default charset. It decoded with the default charset, which on + * Java 17 follows the platform (Cp1252 on Windows, US-ASCII under a POSIX locale), turning + * non-ASCII text into mojibake. The client runs in a child JVM whose default charset is + * ISO-8859-1, since a running JVM's default charset cannot be changed. + */ +class StdioAcpClientTransportCharsetTest { + + @Test + void readsAgentOutputAsUtf8WhateverTheDefaultCharset() throws Exception { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + Process probe = new ProcessBuilder(java, "-Dfile.encoding=ISO-8859-1", "-cp", + System.getProperty("java.class.path"), StdioCharsetProbe.class.getName()) + .redirectErrorStream(true) + .start(); + String output = new String(probe.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertThat(probe.waitFor(60, TimeUnit.SECONDS)).as(output).isTrue(); + + List results = output.lines() + .filter(line -> line.startsWith(StdioCharsetProbe.PREFIX)) + .map(line -> line.substring(StdioCharsetProbe.PREFIX.length())) + .toList(); + assumeThat(results).as("the child JVM honours file.encoding").contains("charset=ISO-8859-1"); + assertThat(results).as(output).contains("text=" + StdioCharsetProbe.escape(StdioCharsetProbe.TEXT)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCloseTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCloseTest.java new file mode 100644 index 00000000..4ec5089f --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportCloseTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.agent.transport.EndOfInputAgent; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * How {@link StdioAcpClientTransport#closeGracefully()} ends the agent process: it closes the + * agent's standard input and lets an agent that exits by itself at the end of its input do so, + * exit code 0, before it falls back to SIGTERM; and it reports the end once, however often the + * transport is closed. + */ +class StdioAcpClientTransportCloseTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + private final Logger logger = (Logger) LoggerFactory.getLogger(StdioAcpClientTransport.class); + + private final ListAppender logs = new ListAppender<>(); + + @BeforeEach + void captureLogs() { + this.logs.start(); + this.logger.addAppender(this.logs); + } + + @AfterEach + void releaseLogs() { + this.logger.detachAppender(this.logs); + this.logs.stop(); + } + + private List stops() { + return this.logs.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith("ACP agent process stopped") + || message.startsWith("Process terminated")) + .toList(); + } + + private static AgentParameters javaAgent(Class main) { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + return AgentParameters.builder(java) + .arg("-Dlogback.configurationFile=logback-stdio-agent.xml") + .arg("-cp") + .arg(System.getProperty("java.class.path")) + .arg(main.getName()) + .build(); + } + + /** + * An SDK stdio agent, written as the README shows, answers what it received once its input + * ends and exits 0: the graceful close lets it, rather than SIGTERM it (exit 143). + */ + @Test + void aJavaStdioAgentExitsByItselfAfterAGracefulClose() { + StdioAcpClientTransport transport = new StdioAcpClientTransport(javaAgent(EndOfInputAgent.class)); + transport.setStdErrorHandler(line -> { + }); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).build(); + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())); + client.prompt(new AcpSchema.PromptRequest("s-1", List.of(new AcpSchema.TextContent("hi")))); + + assertThat(client.closeGracefully()).isTrue(); + + assertThat(stops()).containsExactly("ACP agent process stopped (exit code 0)"); + } + + /** An agent that goes on running after its input ends is sent SIGTERM. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void anAgentThatIgnoresTheEndOfItsInputIsTerminated() { + StdioAcpClientTransport transport = new StdioAcpClientTransport(javaAgent(StubbornAgent.class)); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + transport.closeGracefully().block(TIMEOUT); + + assertThat(stops()).containsExactly("ACP agent process stopped (exit code 143)"); + } + + /** {@code closeGracefully()} then {@code close()}, as try-with-resources does: one report. */ + @Test + void closingTwiceReportsTheStopOnce() { + StdioAcpClientTransport transport = new StdioAcpClientTransport(javaAgent(EndOfInputAgent.class)); + transport.setStdErrorHandler(line -> { + }); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).build(); + client.initialize(); + + client.closeGracefully(); + client.close(); + transport.closeGracefully().block(TIMEOUT); + + assertThat(stops()).hasSize(1); + } + + /** Never reads its input, and never exits by itself. */ + public static final class StubbornAgent { + + private StubbornAgent() { + } + + public static void main(String[] args) throws InterruptedException { + Thread.sleep(Long.MAX_VALUE); + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportProcessTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportProcessTest.java new file mode 100644 index 00000000..d6ae3bf8 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportProcessTest.java @@ -0,0 +1,432 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.CapturedLogs; +import com.agentclientprotocol.sdk.QuietLoggers; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCNotification; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@link StdioAcpClientTransport} in this JVM against a child process: the outbound writer, + * the inbound and standard-error readers, and the end of the agent process. + */ +class StdioAcpClientTransportProcessTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + private StdioAcpClientTransport transport; + + @AfterEach + void tearDown() { + if (transport != null) { + transport.closeGracefully().block(TIMEOUT); + } + } + + private static AgentParameters echoAgent() { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + return AgentParameters.builder(java) + .arg("-cp") + .arg(System.getProperty("java.class.path")) + .arg(EchoAgent.class.getName()) + .build(); + } + + @Test + void messagesRoundTripThroughTheAgentProcess() throws InterruptedException { + List received = new CopyOnWriteArrayList<>(); + CountDownLatch receivedTwo = new CountDownLatch(2); + List stderr = new CopyOnWriteArrayList<>(); + CountDownLatch ready = new CountDownLatch(1); + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + stderr.add(line); + ready.countDown(); + }); + transport.connect(message -> message.doOnNext(m -> { + received.add(m); + receivedTwo.countDown(); + }).then(Mono.empty())).block(TIMEOUT); + + transport.sendMessage(new JSONRPCNotification("note/one", Map.of("text", "line one\nline two"))).block(TIMEOUT); + transport.sendMessage(new JSONRPCNotification("note/two", null)).block(TIMEOUT); + + assertThat(receivedTwo.await(30, TimeUnit.SECONDS)).as("echoed: %s", received).isTrue(); + assertThat(received).extracting(m -> ((JSONRPCNotification) m).method()) + .containsExactly("note/one", "note/two"); + assertThat(((JSONRPCNotification) received.get(0)).params()).isEqualTo(Map.of("text", "line one\nline two")); + assertThat(ready.await(30, TimeUnit.SECONDS)).isTrue(); + assertThat(stderr).contains(EchoAgent.READY); + } + + /** + * Without a handler of its own, the agent's standard error goes to INFO on a dedicated + * logger, with a short prefix, so it stays visible and can be turned down by name. + */ + @Test + void agentStandardErrorIsLoggedAtInfoOnItsOwnLogger() throws InterruptedException { + Logger logger = (Logger) LoggerFactory.getLogger(StdioAcpClientTransport.AGENT_STDERR_LOGGER); + ListAppender logs = new ListAppender<>(); + logs.start(); + logger.addAppender(logs); + try { + transport = new StdioAcpClientTransport(echoAgent()); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (logs.list.isEmpty() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + } + finally { + logger.detachAppender(logs); + logs.stop(); + } + + assertThat(StdioAcpClientTransport.AGENT_STDERR_LOGGER) + .isEqualTo("com.agentclientprotocol.sdk.client.transport.agent-stderr"); + assertThat(logs.list.get(0).getLevel()).isEqualTo(Level.INFO); + assertThat(logs.list.get(0).getLoggerName()).isEqualTo(StdioAcpClientTransport.AGENT_STDERR_LOGGER); + assertThat(logs.list.get(0).getFormattedMessage()).isEqualTo("agent: " + EchoAgent.READY); + } + + /** + * A malformed line from the agent is reported to the exception handler, answered with + * -32700 and a null id (which the echo agent sends back), and skipped: the message after + * it still arrives. Before, the first such line stopped the reader for good, silently. + */ + @Test + void aMalformedLineIsReportedAnsweredAndSkipped() throws InterruptedException { + List received = new CopyOnWriteArrayList<>(); + CountDownLatch receivedThree = new CountDownLatch(3); + List reported = new CopyOnWriteArrayList<>(); + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.setExceptionHandler(reported::add); + transport.connect(message -> message.doOnNext(m -> { + received.add(m); + receivedThree.countDown(); + }).then(Mono.empty())).block(TIMEOUT); + + try (QuietLoggers quiet = QuietLoggers.of(StdioAcpClientTransport.class)) { + transport.sendMessage(new JSONRPCNotification("note/one", null)).block(TIMEOUT); + transport.sendMessage(new JSONRPCNotification(EchoAgent.MALFORMED, null)).block(TIMEOUT); + transport.sendMessage(new JSONRPCNotification("note/two", null)).block(TIMEOUT); + assertThat(receivedThree.await(30, TimeUnit.SECONDS)).as("received: %s", received).isTrue(); + } + + assertThat(received).filteredOn(JSONRPCNotification.class::isInstance) + .extracting(m -> ((JSONRPCNotification) m).method()) + .containsExactly("note/one", "note/two"); + assertThat(received).filteredOn(AcpSchema.JSONRPCResponse.class::isInstance) + .singleElement() + .satisfies(m -> { + AcpSchema.JSONRPCResponse answer = (AcpSchema.JSONRPCResponse) m; + assertThat(answer.id()).isNull(); + assertThat(answer.error()).isNotNull(); + assertThat(answer.error().code()).isEqualTo(-32700); + }); + assertThat(reported).hasSize(1); + } + + /** + * A malformed line is the peer's payload and can carry personal data: it is logged at + * DEBUG at most, never in the ERROR that reports it. + */ + @Test + void aMalformedLineIsNotLoggedAboveDebug() throws InterruptedException { + Logger logger = (Logger) LoggerFactory.getLogger(StdioAcpClientTransport.class); + ListAppender logs = new ListAppender<>(); + logs.start(); + logger.addAppender(logs); + try { + CountDownLatch answered = new CountDownLatch(1); + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.setExceptionHandler(e -> { + }); + transport.connect(message -> message.doOnNext(m -> answered.countDown()).then(Mono.empty())) + .block(TIMEOUT); + + transport.sendMessage(new JSONRPCNotification(EchoAgent.MALFORMED, null)).block(TIMEOUT); + assertThat(answered.await(30, TimeUnit.SECONDS)).isTrue(); + } + finally { + logger.detachAppender(logs); + logs.stop(); + } + + assertThat(logs.list).filteredOn(event -> event.getLevel() == Level.ERROR) + .extracting(ILoggingEvent::getFormattedMessage) + .anySatisfy(message -> assertThat(message).contains("not a JSON-RPC message")); + assertThat(logs.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .allSatisfy(event -> { + assertThat(event.getFormattedMessage()).doesNotContain("someone@example.com"); + if (event.getThrowableProxy() != null) { + assertThat(event.getThrowableProxy().getMessage()).doesNotContain("someone@example.com"); + } + }); + } + + /** + * An invalid request from the agent is its payload and can carry the user's data: it is + * answered -32600 (which the echo agent sends back), and no log at INFO or above, the + * default exception handler's included, quotes it. + */ + @Test + void anInvalidRequestIsNotLoggedAboveDebug() throws InterruptedException { + CountDownLatch answered = new CountDownLatch(1); + List received = new CopyOnWriteArrayList<>(); + try (CapturedLogs logs = CapturedLogs.open()) { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.connect(message -> message.doOnNext(m -> { + received.add(m); + answered.countDown(); + }).then(Mono.empty())).block(TIMEOUT); + + transport.sendMessage(new JSONRPCNotification(EchoAgent.INVALID, null)).block(TIMEOUT); + assertThat(answered.await(30, TimeUnit.SECONDS)).isTrue(); + + assertThat(logs.events()).as("the refusal is logged").isNotEmpty(); + logs.assertNoneAtInfoOrAboveContains(EchoAgent.SECRET); + } + assertThat(received).singleElement() + .isInstanceOfSatisfying(AcpSchema.JSONRPCResponse.class, + answer -> assertThat(answer.error().code()).isEqualTo(-32600)); + } + + /** + * Waiting for the process is interruptible, and the interrupt is not lost: the call throws + * {@link java.util.concurrent.CancellationException} and the thread stays interrupted. + */ + @Test + void awaitProcessExitKeepsTheInterruptFlag() { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + Thread.currentThread().interrupt(); + try { + org.assertj.core.api.Assertions.assertThatThrownBy(transport::awaitProcessExit) + .isInstanceOf(java.util.concurrent.CancellationException.class) + .hasCauseInstanceOf(InterruptedException.class); + org.assertj.core.api.Assertions.assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } + finally { + Thread.interrupted(); + } + } + + @Test + void awaitProcessExitReturnsWhenTheAgentExits() { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + transport.sendMessage(new JSONRPCNotification(EchoAgent.EXIT, null)).block(TIMEOUT); + + Mono.fromRunnable(transport::awaitProcessExit).block(TIMEOUT); + } + + /** + * The agent exits while a request is waiting: the request fails at once, naming the + * exit code, instead of waiting out the request timeout; closing still works. + */ + @Test + void anAgentThatExitsMidRequestFailsThePendingRequestPromptly() { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + Duration requestTimeout = Duration.ofMinutes(5); + AcpClientSession session = new AcpClientSession(requestTimeout, transport, Map.of(), Map.of(), + Function.identity()); + + long start = System.nanoTime(); + try (QuietLoggers quiet = QuietLoggers.of(StdioAcpClientTransport.class, AcpClientSession.class)) { + assertThatThrownBy(() -> session.sendRequest(EchoAgent.EXIT_3, Map.of(), new TypeRef>() { + }).block(TIMEOUT)).hasMessageContaining("terminated") + .rootCause() + .hasMessageContaining("ACP agent process exited with code 3"); + } + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(Duration.ofSeconds(10)); + + session.closeGracefully().block(TIMEOUT); + } + + /** An agent killed by a signal: the reason names the signal Java reports as 128 + n. */ + @Test + @DisabledOnOs(OS.WINDOWS) + void terminationNamesTheSignalThatKilledTheAgent() { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + try (QuietLoggers quiet = QuietLoggers.of(StdioAcpClientTransport.class)) { + transport.sendMessage(new JSONRPCNotification(EchoAgent.KILL, null)).block(TIMEOUT); + + assertThatThrownBy(() -> transport.awaitTermination().block(TIMEOUT)) + .hasMessageContaining("ACP agent process exited with code 137 (signal 9)"); + } + } + + /** Closing the transport ends {@code awaitTermination} without an error. */ + @Test + void closingTheTransportCompletesAwaitTermination() { + transport = new StdioAcpClientTransport(echoAgent()); + transport.setStdErrorHandler(line -> { + }); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + Mono termination = transport.awaitTermination().cache(); + termination.subscribe(v -> { + }, e -> { + }); + + transport.closeGracefully().block(TIMEOUT); + + termination.block(TIMEOUT); + } + + @Test + void anAgentThatCannotBeStartedFailsTheConnect() { + transport = new StdioAcpClientTransport(AgentParameters.builder("/nonexistent/acp-agent-binary").build()); + + assertThatThrownBy(() -> transport.connect(message -> message).block(TIMEOUT)) + .hasMessageContaining("Failed to start process") + .hasMessageContaining("/nonexistent/acp-agent-binary"); + } + + /** + * A JSON library that fails with an {@link Error} while reading the agent's output (a + * {@code NoSuchMethodError} from a Jackson version clash, say) is reported as that error, not as + * "the agent closed its standard output". + */ + @Test + void anErrorReadingTheAgentsOutputIsReportedAsItself() { + com.agentclientprotocol.sdk.json.AcpJsonMapper real = com.agentclientprotocol.sdk.json.AcpJsonMapper + .createDefault(); + com.agentclientprotocol.sdk.json.AcpJsonMapper clashing = (com.agentclientprotocol.sdk.json.AcpJsonMapper) java.lang.reflect.Proxy + .newProxyInstance(getClass().getClassLoader(), + new Class[] { com.agentclientprotocol.sdk.json.AcpJsonMapper.class }, (proxy, method, args) -> { + if (method.getName().startsWith("readValue")) { + throw new NoSuchMethodError("'com.fasterxml.jackson.core.JsonParser.example()' (version clash)"); + } + try { + return method.invoke(real, args); + } + catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + }); + java.util.List reported = new java.util.concurrent.CopyOnWriteArrayList<>(); + transport = new StdioAcpClientTransport(echoAgent(), clashing); + transport.setStdErrorHandler(line -> { + }); + transport.setExceptionHandler(reported::add); + transport.connect(message -> message.then(Mono.empty())).block(TIMEOUT); + + transport.sendMessage(new JSONRPCNotification("test/ping", null)).block(TIMEOUT); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> transport.awaitTermination().block(TIMEOUT)) + .hasMessageContaining("NoSuchMethodError") + .hasMessageNotContaining("closed its standard output"); + org.assertj.core.api.Assertions.assertThat(reported).anyMatch(e -> e instanceof NoSuchMethodError); + } + + /** Echoes each stdin line to stdout, and exits on the {@link #EXIT} notification. */ + public static final class EchoAgent { + + static final String READY = "echo agent ready"; + + static final String EXIT = "test/exit"; + + /** Exits with status 3 instead of echoing. */ + static final String EXIT_3 = "test/exit3"; + + /** Kills itself with SIGKILL instead of echoing. */ + static final String KILL = "test/kill"; + + /** Answered with a line that is not JSON instead of its echo. */ + static final String MALFORMED = "test/malformed"; + + /** Answered with {@link #INVALID_LINE} instead of its echo. */ + static final String INVALID = "test/invalid"; + + /** A marker for the user's data in {@link #INVALID_LINE}. */ + static final String SECRET = "SECRET-123"; + + /** A request JSON-RPC 2.0 does not allow (no jsonrpc member) that carries the user's data. */ + static final String INVALID_LINE = "{\"id\":1,\"method\":\"session/request_permission\"," + + "\"params\":{\"sessionId\":\"s\",\"text\":\"" + SECRET + "\"}}"; + + /** The line that is not JSON; it carries personal data, as an agent's output can. */ + static final String MALFORMED_LINE = "{not json someone@example.com"; + + private EchoAgent() { + } + + public static void main(String[] args) throws Exception { + System.err.println(READY); + System.err.flush(); + BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + String line; + while ((line = in.readLine()) != null) { + if (line.contains("\"" + EXIT + "\"")) { + return; + } + if (line.contains("\"" + EXIT_3 + "\"")) { + System.exit(3); + } + if (line.contains("\"" + KILL + "\"")) { + // A process may not destroy itself through ProcessHandle; ask kill(1). + new ProcessBuilder("kill", "-KILL", Long.toString(ProcessHandle.current().pid())).start() + .waitFor(); + Thread.sleep(Long.MAX_VALUE); + } + String echo = line.contains("\"" + MALFORMED + "\"") ? MALFORMED_LINE + : line.contains("\"" + INVALID + "\"") ? INVALID_LINE : line; + System.out.write((echo + "\n").getBytes(StandardCharsets.UTF_8)); + System.out.flush(); + } + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportTest.java index ff56984f..d6110626 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioAcpClientTransportTest.java @@ -54,6 +54,17 @@ void testConstructorWithNullMapper() { .hasMessage("The JsonMapper can not be null"); } + @Test + void awaitProcessExitBeforeConnectIsRefused() { + // No process exists until connect starts one; this used to fail with a + // NullPointerException on the unset process field. + StdioAcpClientTransport transport = new StdioAcpClientTransport(AgentParameters.builder("gemini").build(), + AcpJsonMapper.createDefault()); + + assertThatThrownBy(transport::awaitProcessExit).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("connect first"); + } + @Test void testProtocolVersions() { AgentParameters params = AgentParameters.builder("gemini").build(); @@ -235,4 +246,20 @@ void resubscribingTheSameConnectPublisherIsRejected() { } } + /** A message sent once the transport is closed fails; it used to be dropped silently. */ + @Test + void aMessageSentAfterCloseFails() { + String javaBin = java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(); + StdioAcpClientTransport transport = new StdioAcpClientTransport( + AgentParameters.builder(javaBin).arg("-version").build(), AcpJsonMapper.createDefault()); + transport.connect(message -> reactor.core.publisher.Mono.empty()).block(java.time.Duration.ofSeconds(30)); + transport.closeGracefully().block(java.time.Duration.ofSeconds(30)); + + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> transport + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "session/cancel", null)) + .block(java.time.Duration.ofSeconds(5))) + .isInstanceOf(com.agentclientprotocol.sdk.error.AcpConnectionException.class); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioCharsetProbe.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioCharsetProbe.java new file mode 100644 index 00000000..e07cfc2d --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StdioCharsetProbe.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * Child-process side of {@link StdioAcpClientTransportCharsetTest}. With {@code agent} it + * is a minimal agent that writes one UTF-8 notification carrying non-ASCII text; otherwise + * it is a client that runs {@link StdioAcpClientTransport} against that agent and prints, + * in ASCII, its default charset and the text it received. + */ +public final class StdioCharsetProbe { + + static final String TEXT = "café ✓"; + + static final String PREFIX = "PROBE "; + + private StdioCharsetProbe() { + } + + public static void main(String[] args) throws Exception { + if (args.length > 0 && "agent".equals(args[0])) { + runAgent(); + return; + } + runClient(); + } + + private static void runAgent() throws Exception { + String line = "{\"jsonrpc\":\"2.0\",\"method\":\"probe\",\"params\":{\"text\":\"" + TEXT + "\"}}\n"; + System.out.write(line.getBytes(StandardCharsets.UTF_8)); + System.out.flush(); + // Stay up until the client closes stdin. + InputStream in = System.in; + while (in.read() != -1) { + // discard + } + } + + private static void runClient() throws Exception { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + StdioAcpClientTransport transport = new StdioAcpClientTransport(AgentParameters.builder(java) + .arg("-cp") + .arg(System.getProperty("java.class.path")) + .arg(StdioCharsetProbe.class.getName()) + .arg("agent") + .build()); + CompletableFuture received = new CompletableFuture<>(); + transport.connect(message -> message.doOnNext(m -> { + if (m instanceof AcpSchema.JSONRPCNotification notification + && notification.params() instanceof Map params) { + received.complete(String.valueOf(params.get("text"))); + } + }).then(Mono.empty())).block(Duration.ofSeconds(30)); + String text = received.get(30, TimeUnit.SECONDS); + System.out.println(PREFIX + "charset=" + Charset.defaultCharset().name()); + System.out.println(PREFIX + "text=" + escape(text)); + System.out.flush(); + transport.closeGracefully().block(Duration.ofSeconds(10)); + System.exit(0); + } + + static String escape(String text) { + StringBuilder escaped = new StringBuilder(); + text.chars().forEach(c -> escaped.append(c < 128 ? String.valueOf((char) c) : String.format("\\u%04x", c))); + return escaped.toString(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java index decbf024..466ac444 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpAcpClientTransportTest.java @@ -14,11 +14,14 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -29,6 +32,7 @@ import java.util.stream.Collectors; import com.agentclientprotocol.sdk.AcpTestFixtures; +import com.agentclientprotocol.sdk.CapturedLogs; import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.spec.AcpSchema; @@ -167,6 +171,166 @@ void rejectsSessionStreamWhenConfiguredSseLimitIsReached() throws Exception { } } + /** + * A server that knows no stream for a session answers its GET with 404, as the RFD's GET + * tree says, and the Python server a POST naming it too. The client opens a session's + * stream before posting in that session; when the server refuses it, the request is still + * posted, on the connection without Acp-Session-Id, and answered there (session/delete of + * an unknown session, session/load on a new connection). Before, the request failed + * without being sent. + */ + @Test + void aSessionRequestIsPostedWhenTheServerRefusesTheSessionStream() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + List posts = new CopyOnWriteArrayList<>(); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), initializeResponse)); + } + if ("GET".equals(request.method()) && request.headers().firstValue("Acp-Session-Id").isEmpty()) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + if ("GET".equals(request.method())) { + return CompletableFuture.completedFuture(response(404, Map.of(), emptyBody())); + } + posts.add(request); + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + try { + transport.setExceptionHandler(error -> { + }); + transport.connect(message -> Mono.empty()).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_DELETE, "delete-1", + new AcpSchema.DeleteSessionRequest("no-such-session"))) + .block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_LOAD, "load-1", + new AcpSchema.LoadSessionRequest("sess-1", "/workspace", List.of()))) + .block(); + + assertThat(posts).hasSize(2) + .allSatisfy(post -> assertThat(post.headers().firstValue("Acp-Session-Id")).isEmpty()); + } + finally { + transport.close(); + connectionStreamWriter.close(); + } + } + + /** + * An SSE event that is no valid JSON-RPC request (here jsonrpc "1.0") is answered as + * JSON-RPC 2.0 says, -32600 with the request's id, posted in the scope of the stream it + * came on, as the stdio and WebSocket transports answer. Before, the HTTP client only + * skipped it and the agent waited for an answer forever. + */ + @Test + void anInvalidRequestOnAStreamIsAnswered() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + BlockingQueue posted = new LinkedBlockingQueue<>(); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), initializeResponse)); + } + if ("GET".equals(request.method())) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + posted.add(bodyOf(request)); + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + try { + transport.setExceptionHandler(error -> { + }); + transport.connect(message -> Mono.empty()).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + + connectionStreamWriter.write(("data: {\"jsonrpc\":\"1.0\",\"id\":\"agent-1\",\"method\":\"fs/read_text_file\"}\n\n") + .getBytes(StandardCharsets.UTF_8)); + connectionStreamWriter.flush(); + + assertThat(posted.poll(5, TimeUnit.SECONDS)).isEqualTo( + "{\"jsonrpc\":\"2.0\",\"id\":\"agent-1\",\"error\":{\"code\":-32600,\"message\":\"Invalid Request\"}}"); + } + finally { + transport.close(); + connectionStreamWriter.close(); + } + } + + /** + * An invalid request on an SSE stream is the agent's payload and can carry the user's + * data: it is answered -32600, and no log at INFO or above, the default exception + * handler's included, quotes it. + */ + @Test + void anInvalidRequestOnAStreamIsNotLoggedAboveDebug() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + BlockingQueue posted = new LinkedBlockingQueue<>(); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), initializeResponse)); + } + if ("GET".equals(request.method())) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + posted.add(bodyOf(request)); + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + try (CapturedLogs logs = CapturedLogs.open()) { + transport.connect(message -> Mono.empty()).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + + connectionStreamWriter.write(("data: {\"id\":\"agent-1\",\"method\":\"session/request_permission\"," + + "\"params\":{\"sessionId\":\"s\",\"text\":\"SECRET-123\"}}\n\n") + .getBytes(StandardCharsets.UTF_8)); + connectionStreamWriter.flush(); + + assertThat(posted.poll(5, TimeUnit.SECONDS)).contains("-32600"); + assertThat(logs.events()).as("the refusal is logged").isNotEmpty(); + logs.assertNoneAtInfoOrAboveContains("SECRET-123"); + } + finally { + transport.close(); + connectionStreamWriter.close(); + } + } + @Test void defaultAcpPathIsCorrect() { assertThat(StreamableHttpAcpClientTransport.DEFAULT_ACP_PATH).isEqualTo("/acp"); @@ -201,6 +365,50 @@ void initializeRejectsResponseWithDifferentId() throws Exception { .hasMessage("ACP initialize response id did not match initialize request"); } + /** + * Media types are case-insensitive. The Content-Type check lower-cased with the default + * locale, and in Turkish {@code "APPLICATION/JSON".toLowerCase()} is + * {@code "applıcatıon/json"} (dotless i), so initialize failed against a server that sent + * an upper-case media type. + */ + @Test + void initializeAcceptsUpperCaseContentTypeInTurkishLocale() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + String body = jsonMapper.writeValueAsString( + AcpTestFixtures.createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + HttpResponse response = response(200, + Map.of("Content-Type", "APPLICATION/JSON", "Acp-Connection-Id", "conn-1"), body); + // The connection stream stays open: one that ends at once makes the transport give up + // on it and terminate, which can beat the initialize response into the inbound stream. + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("GET".equals(request.method())) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + return CompletableFuture.completedFuture(response); + }); + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + transport.setExceptionHandler(error -> { + }); + + Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr-TR")); + try { + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + } + finally { + Locale.setDefault(defaultLocale); + transport.close(); + connectionStreamWriter.close(); + } + } + @Test void concurrentSessionLoadsReuseInFlightSessionStreamOpen() throws Exception { HttpClient httpClient = mock(HttpClient.class); @@ -388,6 +596,64 @@ void sessionNewErrorResponseIsDeliveredWithoutOpeningSessionStream() throws Exce } } + @Test + void sessionNewResponseWithoutSessionIdIsAnsweredAsAnInternalError() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + AtomicInteger sessionGetCount = new AtomicInteger(); + BlockingQueue inboundMessages = new LinkedBlockingQueue<>(); + + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) + && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), + initializeResponse)); + } + if ("GET".equals(request.method()) + && request.headers().firstValue("Acp-Session-Id").isEmpty()) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + if ("GET".equals(request.method())) { + sessionGetCount.incrementAndGet(); + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), emptyBody())); + } + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + try { + transport.connect(message -> message.doOnNext(inboundMessages::add).then(Mono.empty())).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + awaitResponse(inboundMessages, "init-1"); + + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_SESSION_NEW, "new-1", + AcpTestFixtures.createNewSessionRequest("/workspace"))) + .block(); + writeSse(connectionStreamWriter, + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, "new-1", Map.of(), null)); + + AcpSchema.JSONRPCResponse response = awaitResponse(inboundMessages, "new-1"); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(-32603); + assertThat(response.error().message()).isEqualTo("session/new response missing sessionId"); + assertThat(sessionGetCount).hasValue(0); + } + finally { + connectionStreamWriter.close(); + transport.close(); + } + } + @Test void concurrentSessionSseEventsAreSerializedIntoInboundSink() throws Exception { HttpClient httpClient = mock(HttpClient.class); @@ -473,11 +739,16 @@ void concurrentSessionSseEventsAreSerializedIntoInboundSink() throws Exception { } } + /** + * A malformed SSE event is skipped and reported to the exception handler, as the other + * transports report one. It is not answered: an SSE stream has no reply channel. + */ @Test - void malformedSseEventDoesNotStopConnectionReader() throws Exception { + void malformedSseEventIsReportedAndDoesNotStopConnectionReader() throws Exception { HttpClient httpClient = mock(HttpClient.class); PipedInputStream connectionStreamBody = new PipedInputStream(); PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + List reported = new CopyOnWriteArrayList<>(); BlockingQueue inboundMessages = new LinkedBlockingQueue<>(); when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { @@ -501,6 +772,7 @@ void malformedSseEventDoesNotStopConnectionReader() throws Exception { StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); try { + transport.setExceptionHandler(reported::add); transport.connect(message -> message.doOnNext(inboundMessages::add).then(Mono.empty())).block(); transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", AcpTestFixtures.createInitializeRequest())) @@ -514,6 +786,62 @@ void malformedSseEventDoesNotStopConnectionReader() throws Exception { writeSse(connectionStreamWriter, AcpTestFixtures.createJsonRpcResponse("ping-1", Map.of())); assertThat(awaitResponse(inboundMessages, "ping-1")).isNotNull(); + assertThat(reported).hasSize(1); + } + finally { + connectionStreamWriter.close(); + transport.close(); + } + } + + /** JSON-RPC's answer to a request the agent could not parse carries "id": null. */ + @Test + void responseWithNullIdIsDeliveredAndTheReaderContinues() throws Exception { + HttpClient httpClient = mock(HttpClient.class); + PipedInputStream connectionStreamBody = new PipedInputStream(); + PipedOutputStream connectionStreamWriter = new PipedOutputStream(connectionStreamBody); + BlockingQueue inboundMessages = new LinkedBlockingQueue<>(); + + when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + if ("POST".equals(request.method()) + && request.headers().firstValue("Acp-Connection-Id").isEmpty()) { + String initializeResponse = jsonMapper.writeValueAsString(AcpTestFixtures + .createJsonRpcResponse("init-1", AcpTestFixtures.createInitializeResponse())); + return CompletableFuture.completedFuture(response(200, + Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), + initializeResponse)); + } + if ("GET".equals(request.method()) + && request.headers().firstValue("Acp-Session-Id").isEmpty()) { + return CompletableFuture.completedFuture( + response(200, Map.of("Content-Type", "text/event-stream"), connectionStreamBody)); + } + return CompletableFuture.completedFuture(response(202, Map.of(), null)); + }); + + StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( + URI.create("https://localhost:8443/acp"), jsonMapper, httpClient); + try { + transport.connect(message -> message.doOnNext(inboundMessages::add).then(Mono.empty())).block(); + transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", + AcpTestFixtures.createInitializeRequest())) + .block(); + awaitResponse(inboundMessages, "init-1"); + + transport.sendMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "ping-1", + "extension/ping", Map.of())) + .block(); + writeRawSse(connectionStreamWriter, + "{\"jsonrpc\":\"2.0\",\"id\":null,\"error\":{\"code\":-32700,\"message\":\"Parse error\"}}"); + writeSse(connectionStreamWriter, AcpTestFixtures.createJsonRpcResponse("ping-1", Map.of())); + + AcpSchema.JSONRPCMessage first = inboundMessages.poll(2, TimeUnit.SECONDS); + assertThat(first).isInstanceOfSatisfying(AcpSchema.JSONRPCResponse.class, nullId -> { + assertThat(nullId.id()).isNull(); + assertThat(nullId.error()).isNotNull(); + }); + assertThat(awaitResponse(inboundMessages, "ping-1")).isNotNull(); } finally { connectionStreamWriter.close(); @@ -525,6 +853,11 @@ void malformedSseEventDoesNotStopConnectionReader() throws Exception { void connectionSseClosureTerminatesTransport() throws Exception { HttpClient httpClient = mock(HttpClient.class); BlockingQueue errors = new LinkedBlockingQueue<>(); + // The first stream ends once initialize has completed; ended at once, the transport + // could give up on it before delivering the initialize response. + PipedInputStream firstConnectionStreamBody = new PipedInputStream(); + PipedOutputStream firstConnectionStreamWriter = new PipedOutputStream(firstConnectionStreamBody); + AtomicInteger connectionGets = new AtomicInteger(); when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { HttpRequest request = invocation.getArgument(0); @@ -537,8 +870,9 @@ void connectionSseClosureTerminatesTransport() throws Exception { initializeResponse)); } if ("GET".equals(request.method())) { + InputStream body = connectionGets.incrementAndGet() == 1 ? firstConnectionStreamBody : emptyBody(); return CompletableFuture.completedFuture( - response(200, Map.of("Content-Type", "text/event-stream"), emptyBody())); + response(200, Map.of("Content-Type", "text/event-stream"), body)); } return CompletableFuture.completedFuture(response(202, Map.of(), null)); }); @@ -551,6 +885,7 @@ void connectionSseClosureTerminatesTransport() throws Exception { transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", AcpTestFixtures.createInitializeRequest())) .block(); + firstConnectionStreamWriter.close(); Throwable error = errors.poll(2, TimeUnit.SECONDS); assertThat(error).isInstanceOf(AcpConnectionException.class) @@ -802,6 +1137,11 @@ void cleartextServerWithoutH2cGetsPlainHttp11Requests() throws Exception { HttpClient httpClient = mock(HttpClient.class); when(httpClient.version()).thenReturn(HttpClient.Version.HTTP_2); List requests = new java.util.concurrent.CopyOnWriteArrayList<>(); + // The first connection stream ends once initialize has completed; ended at once, the + // transport could give up on it before delivering the initialize response. + PipedInputStream firstConnectionStreamBody = new PipedInputStream(); + PipedOutputStream firstConnectionStreamWriter = new PipedOutputStream(firstConnectionStreamBody); + AtomicInteger connectionGets = new AtomicInteger(); when(httpClient.sendAsync(any(), any())).thenAnswer(invocation -> { HttpRequest request = invocation.getArgument(0); requests.add(request); @@ -816,8 +1156,9 @@ void cleartextServerWithoutH2cGetsPlainHttp11Requests() throws Exception { return CompletableFuture.completedFuture(response(200, Map.of("Content-Type", "application/json", "Acp-Connection-Id", "conn-1"), initializeResponse)); } + InputStream body = connectionGets.incrementAndGet() == 1 ? firstConnectionStreamBody : emptyBody(); return CompletableFuture.completedFuture( - response(200, Map.of("Content-Type", "text/event-stream"), emptyBody())); + response(200, Map.of("Content-Type", "text/event-stream"), body)); }); StreamableHttpAcpClientTransport transport = new StreamableHttpAcpClientTransport( URI.create("http://localhost:8080/acp"), jsonMapper, httpClient); @@ -828,9 +1169,12 @@ void cleartextServerWithoutH2cGetsPlainHttp11Requests() throws Exception { transport.sendMessage(AcpTestFixtures.createJsonRpcRequest(AcpSchema.METHOD_INITIALIZE, "init-1", AcpTestFixtures.createInitializeRequest())).block(); - // The mocked connection stream ends at once, so the client keeps reconnecting while - // this runs; take a snapshot. Reconnects must be pinned too. + // End the connection stream: the client reconnects until it gives up. Reconnects + // must be pinned too. + firstConnectionStreamWriter.close(); + transport.awaitTermination().onErrorResume(error -> Mono.empty()).block(Duration.ofSeconds(5)); List seen = List.copyOf(requests); + assertThat(connectionGets).as("the connection stream was reopened").hasValueGreaterThan(1); assertThat(seen.get(0).method()).as("the probe").isEqualTo("GET"); assertThat(seen.get(0).headers().firstValue("Acp-Connection-Id")).isEmpty(); assertThat(seen.subList(1, seen.size())) @@ -926,8 +1270,7 @@ private void writeSessionUpdates(PipedOutputStream writer, String sessionId, int for (int i = 0; i < count; i++) { writeSse(writer, new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE, new AcpSchema.SessionNotification(sessionId, - new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent(sessionId + "-" + i))))); + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent(sessionId + "-" + i))))); } } @@ -969,6 +1312,45 @@ private void awaitNotifications(BlockingQueue messages throw new AssertionError("Timed out waiting for " + expected + " notifications; received " + count); } + /** The body a request publishes, read synchronously. */ + private static String bodyOf(HttpRequest request) { + HttpRequest.BodyPublisher publisher = request.bodyPublisher().orElseThrow(); + java.io.ByteArrayOutputStream bytes = new java.io.ByteArrayOutputStream(); + CountDownLatch done = new CountDownLatch(1); + publisher.subscribe(new java.util.concurrent.Flow.Subscriber() { + + @Override + public void onSubscribe(java.util.concurrent.Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(java.nio.ByteBuffer item) { + byte[] chunk = new byte[item.remaining()]; + item.get(chunk); + bytes.write(chunk, 0, chunk.length); + } + + @Override + public void onError(Throwable throwable) { + done.countDown(); + } + + @Override + public void onComplete() { + done.countDown(); + } + + }); + try { + done.await(5, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return bytes.toString(StandardCharsets.UTF_8); + } + private InputStream emptyBody() { return new ByteArrayInputStream(new byte[0]); } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInboundTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInboundTest.java new file mode 100644 index 00000000..776209b3 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpInboundTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.PinnedVirtualThreads; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link StreamableHttpInbound}, the ordered stream an SSE reader hands each + * received message to. + */ +class StreamableHttpInboundTest { + + /** + * The SSE readers run on virtual threads on JDK 21+, and emitting runs the client's + * handler chain on the reader's thread. A park there must not pin the carrier: every + * carrier pinned on a lock whose owner waits for a carrier deadlocks the client. + */ + @Test + void emitDoesNotPinTheVirtualThreadItRunsOn() throws Exception { + AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + StreamableHttpInbound inbound = new StreamableHttpInbound(new StreamableHttpRoutes(jsonMapper), + mock(StreamableHttpStreams.class), jsonMapper); + Disposable subscription = inbound.messages().subscribe(message -> { + try { + Thread.sleep(50); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + AcpSchema.JSONRPCNotification update = new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_SESSION_UPDATE, + Map.of("sessionId", "s1")); + + List pinned = PinnedVirtualThreads + .pinnedParks(() -> inbound.emit(update).block(Duration.ofSeconds(5))); + + assertThat(pinned).isEmpty(); + subscription.dispose(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutesTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutesTest.java new file mode 100644 index 00000000..f0ea234b --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpRoutesTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.LoggerFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Every client-to-agent method of ACP v1, stable and unstable (schema/v1/meta.unstable.json), + * is routed from the table in {@link StreamableHttpRoutes}: connection-scoped methods carry + * no {@code Acp-Session-Id}, session-scoped ones carry the session their params name, and + * none falls back to inferring its scope from its params. + */ +class StreamableHttpRoutesTest { + + private static final String SESSION = "sess-1"; + + private ListAppender appender; + + private Logger logger; + + @BeforeEach + void captureLogs() { + this.logger = (Logger) LoggerFactory.getLogger(StreamableHttpRoutes.class); + this.appender = new ListAppender<>(); + this.appender.start(); + this.logger.addAppender(this.appender); + } + + @AfterEach + void releaseLogs() { + this.logger.detachAppender(this.appender); + this.appender.stop(); + } + + static Stream connectionScopedMethods() { + return Stream.of("authenticate", "logout", "session/new", "session/list", "providers/list", "providers/set", + "providers/disable", "nes/start", "mcp/message", "$/cancel_request") + .map(Arguments::of); + } + + static Stream sessionScopedMethods() { + return Stream.of("session/prompt", "session/cancel", "session/set_mode", "session/set_config_option", + "session/close", "session/delete", "session/fork", "session/load", "session/resume", "nes/suggest", + "nes/accept", "nes/reject", "nes/close", "document/didOpen", "document/didChange", "document/didClose", + "document/didSave", "document/didFocus") + .map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("connectionScopedMethods") + void connectionScopedMethodIsPostedOnTheConnection(String method) { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + + RouteScope scope = routes.resolveOutbound(new AcpSchema.JSONRPCRequest(method, 1, Map.of("cwd", "/w"))); + + assertThat(scope).isEqualTo(RouteScope.connection()); + assertNoFallback(); + } + + @ParameterizedTest + @MethodSource("sessionScopedMethods") + void sessionScopedMethodIsPostedInItsSession(String method) { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + + RouteScope scope = routes.resolveOutbound(new AcpSchema.JSONRPCRequest(method, 1, Map.of("sessionId", SESSION))); + + assertThat(scope).isEqualTo(RouteScope.session(SESSION)); + assertNoFallback(); + } + + @ParameterizedTest + @MethodSource("sessionScopedMethods") + void sessionScopedMethodWithoutASessionIdIsRefused(String method) { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + + assertThatThrownBy(() -> routes.resolveOutbound(new AcpSchema.JSONRPCRequest(method, 1, Map.of()))) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("Missing sessionId"); + } + + /** The table names every method AcpSchema defines for the client to call. */ + @Test + void everyAgentMethodConstantHasARoute() { + List agentMethods = List.of(AcpSchema.METHOD_INITIALIZE, AcpSchema.METHOD_AUTHENTICATE, + AcpSchema.METHOD_LOGOUT, AcpSchema.METHOD_SESSION_NEW, AcpSchema.METHOD_SESSION_LOAD, + AcpSchema.METHOD_SESSION_PROMPT, AcpSchema.METHOD_SESSION_SET_MODE, AcpSchema.METHOD_SESSION_CANCEL, + AcpSchema.METHOD_SESSION_LIST, AcpSchema.METHOD_SESSION_CLOSE, AcpSchema.METHOD_SESSION_DELETE, + AcpSchema.METHOD_SESSION_RESUME, AcpSchema.METHOD_SESSION_FORK, + AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, AcpSchema.METHOD_PROVIDERS_LIST, + AcpSchema.METHOD_PROVIDERS_SET, AcpSchema.METHOD_PROVIDERS_DISABLE); + + assertThat(StreamableHttpRoutes.routedMethods()).containsAll(agentMethods); + } + + /** The load family answers on the connection stream: the client has no session stream yet. */ + @Test + void loadAndResumeAreAnsweredOnTheConnectionStream() { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + routes.resolveOutbound(new AcpSchema.JSONRPCRequest(AcpSchema.METHOD_SESSION_LOAD, 1, Map.of("sessionId", SESSION))); + routes.resolveOutbound(new AcpSchema.JSONRPCRequest(AcpSchema.METHOD_SESSION_DELETE, 2, Map.of("sessionId", SESSION))); + + assertThat(routes.hasPendingResponseFor(RouteScope.connection())).isTrue(); + routes.answered(1); + assertThat(routes.hasPendingResponseFor(RouteScope.connection())).isFalse(); + assertThat(routes.hasPendingResponseFor(RouteScope.session(SESSION))).isTrue(); + } + + /** An extension method has no table entry; it is still routed, by inference. */ + @Test + void anExtensionMethodIsRoutedByItsParams() { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + + assertThat(routes.resolveOutbound(new AcpSchema.JSONRPCRequest("_vendor/ping", 1, Map.of("sessionId", SESSION)))) + .isEqualTo(RouteScope.session(SESSION)); + assertThat(routes.resolveOutbound(new AcpSchema.JSONRPCRequest("_vendor/ping", 2, Map.of()))) + .isEqualTo(RouteScope.connection()); + } + + /** + * $/cancel_request is protocol-level: posted on the connection, without the session + * header, even while the prompt it cancels was posted in its session (the Rust SDK's rule). + */ + @Test + void aCancelRequestIsPostedOnTheConnectionEvenForASessionRequest() { + StreamableHttpRoutes routes = new StreamableHttpRoutes(AcpJsonMapper.createDefault()); + routes.resolveOutbound(new AcpSchema.JSONRPCRequest(AcpSchema.METHOD_SESSION_PROMPT, 7, + Map.of("sessionId", SESSION, "prompt", java.util.List.of()))); + + RouteScope scope = routes.resolveOutbound(new AcpSchema.JSONRPCNotification(AcpSchema.METHOD_CANCEL_REQUEST, + new AcpSchema.CancelRequestNotification(7))); + + assertThat(scope).isEqualTo(RouteScope.connection()); + assertNoFallback(); + } + + private void assertNoFallback() { + assertThat(this.appender.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.WARN)) + .extracting(ILoggingEvent::getFormattedMessage) + .isEmpty(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpThreadsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpThreadsTest.java new file mode 100644 index 00000000..3bd144b4 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/StreamableHttpThreadsTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.net.URI; +import java.net.http.HttpClient; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.client.transport.StreamableHttpRequests.HttpClientBundle; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The threads the Streamable HTTP client transport runs on without an executor of the + * application's: a virtual thread per task where the JDK has them, unless the options say + * platform threads; else bounded pools of daemon platform threads. Opting out of virtual threads + * only changes anything on JDK 21 and later, so that is where these tests matter most. + */ +class StreamableHttpThreadsTest { + + private static final URI ENDPOINT = URI.create("http://127.0.0.1:1/acp"); + + @Test + void byDefaultTheWorkRunsOnVirtualThreadsWhereTheJdkHasThem() throws Exception { + HttpClientBundle bundle = HttpClientBundle.createDefault(StreamableHttpAcpClientTransportOptions.defaults()); + try { + if (!VirtualThreads.isSupported()) { + assertThat(bundle.workExecutor()).isNull(); + assertThat(bundle.ownedExecutor()).isInstanceOf(ThreadPoolExecutor.class); + return; + } + assertThat(bundle.workExecutor()).isSameAs(bundle.ownedExecutor()); + Thread thread = runOn(bundle.ownedExecutor()); + assertThat(VirtualThreads.isVirtual(thread)).isTrue(); + assertThat(thread.getName()).isEqualTo("acp-streamable-http"); + } + finally { + bundle.ownedExecutor().shutdownNow(); + } + } + + @Test + void withVirtualThreadsOffTheWorkRunsOnBoundedPoolsOfPlatformThreadsOnEveryJdk() throws Exception { + StreamableHttpAcpClientTransportOptions options = StreamableHttpAcpClientTransportOptions.builder() + .virtualThreads(false) + .httpWorkerThreads(2) + .httpSignalThreads(1) + .build(); + HttpClientBundle bundle = HttpClientBundle.createDefault(options); + assertThat(bundle.workExecutor()).isNull(); + assertThat(bundle.ownedExecutor()).isInstanceOf(ThreadPoolExecutor.class); + assertThat(((ThreadPoolExecutor) bundle.ownedExecutor()).getMaximumPoolSize()).isEqualTo(2); + assertThat(bundle.httpClient().executor()).containsSame(bundle.ownedExecutor()); + Thread thread = runOn(bundle.ownedExecutor()); + assertThat(VirtualThreads.isVirtual(thread)).isFalse(); + assertThat(thread.isDaemon()).isTrue(); + assertThat(thread.getName()).isEqualTo("acp-streamable-http-client"); + + StreamableHttpRequests requests = new StreamableHttpRequests(ENDPOINT, bundle, options); + requests.shutdown(); + assertThat(bundle.ownedExecutor().isShutdown()).isTrue(); + } + + @Test + void anApplicationsClientWithVirtualThreadsOffGetsNoExecutorOfTheTransports() { + StreamableHttpAcpClientTransportOptions options = StreamableHttpAcpClientTransportOptions.builder() + .virtualThreads(false) + .build(); + HttpClient client = HttpClient.newHttpClient(); + HttpClientBundle bundle = HttpClientBundle.of(client, options); + assertThat(bundle.httpClient()).isSameAs(client); + assertThat(bundle.ownedExecutor()).isNull(); + assertThat(bundle.workExecutor()).isNull(); + new StreamableHttpRequests(ENDPOINT, bundle, options).shutdown(); + } + + private static Thread runOn(ExecutorService executor) throws Exception { + return executor.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportFrameTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportFrameTest.java new file mode 100644 index 00000000..4bf513bb --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportFrameTest.java @@ -0,0 +1,471 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.client.transport; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; + +import com.agentclientprotocol.sdk.CapturedLogs; +import com.agentclientprotocol.sdk.QuietLoggers; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Frame-level tests for {@link WebSocketAcpClientTransport}: the WebSocket listener and the + * outbound path, driven through a fake {@link HttpClient} so that each frame, close and + * error can be delivered exactly. + */ +class WebSocketAcpClientTransportFrameTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String REQUEST = "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"session/request_permission\",\"params\":{}}"; + + private final FakeWebSocket webSocket = new FakeWebSocket(); + + private final FakeHttpClient httpClient = new FakeHttpClient(webSocket); + + private final WebSocketAcpClientTransport transport = new WebSocketAcpClientTransport( + URI.create("ws://localhost:1/acp"), AcpJsonMapper.createDefault(), httpClient); + + private final List received = new CopyOnWriteArrayList<>(); + + private final AtomicReference<@Nullable Throwable> reported = new AtomicReference<>(); + + @AfterEach + void tearDown() { + transport.closeGracefully().block(TIMEOUT); + } + + private void connect() { + transport.setExceptionHandler(reported::set); + transport.connect(message -> message.doOnNext(received::add) + .map(request -> new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, + ((AcpSchema.JSONRPCRequest) request).id(), "ok", null))) + .block(TIMEOUT); + } + + @Test + void openRequestsTheFirstFrame() { + connect(); + + assertThat(webSocket.requested).isEqualTo(1); + } + + @Test + void aFragmentedMessageIsReassembledAndAnswered() { + connect(); + WebSocket.Listener listener = httpClient.listener(); + + listener.onText(webSocket, REQUEST.substring(0, 20), false); + assertThat(received).isEmpty(); + listener.onText(webSocket, REQUEST.substring(20), true); + + awaitSentFrames(1); + assertThat(received).singleElement() + .isInstanceOfSatisfying(AcpSchema.JSONRPCRequest.class, request -> assertThat(request.id()).isEqualTo(7)); + assertThat(webSocket.sent.get(0)).contains("\"id\":7").contains("\"result\":\"ok\""); + assertThat(webSocket.requested).as("one more frame requested after each fragment").isEqualTo(3); + } + + @Test + void aMalformedFrameIsReportedAnsweredAndTheNextFrameStillArrives() { + connect(); + WebSocket.Listener listener = httpClient.listener(); + + try (QuietLoggers quiet = QuietLoggers.of(WebSocketAcpClientTransport.class)) { + listener.onText(webSocket, "{not json", true); + } + listener.onText(webSocket, REQUEST, true); + + assertThat(reported.get()).isNotNull(); + awaitSentFrames(2); + assertThat(received).hasSize(1); + assertThat(webSocket.sent).anySatisfy(frame -> assertThat(frame).contains("\"id\":null").contains("-32700")); + assertThat(webSocket.sent).anySatisfy(frame -> assertThat(frame).contains("\"id\":7")); + } + + @Test + void aCloseFromTheServerTerminatesTheTransport() { + connect(); + + httpClient.listener().onClose(webSocket, WebSocket.NORMAL_CLOSURE, "server closing"); + + transport.awaitTermination().block(TIMEOUT); + assertThat(reported.get()).isNull(); + } + + @Test + void aWebSocketErrorIsReportedAndTerminatesTheTransport() { + connect(); + IOException failure = new IOException("connection reset"); + + try (QuietLoggers quiet = QuietLoggers.of(WebSocketAcpClientTransport.class)) { + httpClient.listener().onError(webSocket, failure); + } + + assertThat(reported.get()).isSameAs(failure); + assertThatThrownBy(() -> transport.awaitTermination().block(TIMEOUT)).hasCause(failure); + } + + @Test + void anErrorAfterCloseIsNotReported() { + connect(); + WebSocket.Listener listener = httpClient.listener(); + listener.onClose(webSocket, WebSocket.NORMAL_CLOSURE, "server closing"); + + listener.onError(webSocket, new IOException("late")); + + assertThat(reported.get()).isNull(); + transport.awaitTermination().block(TIMEOUT); + } + + @Test + void aMessageSentBeforeTheConnectionOpensIsDeliveredOnceItDoes() { + Mono early = transport + .sendMessage(new AcpSchema.JSONRPCNotification("session/cancel", java.util.Map.of("sessionId", "s"))); + early.subscribe(); + + connect(); + + awaitSentFrames(1); + assertThat(webSocket.sent.get(0)).contains("session/cancel"); + } + + @Test + void aFailedSendIsReported() { + webSocket.failSends = true; + connect(); + + try (QuietLoggers quiet = QuietLoggers.of(WebSocketAcpClientTransport.class)) { + transport.sendMessage(new AcpSchema.JSONRPCNotification("session/cancel", null)).block(TIMEOUT); + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (reported.get() == null && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + } + + assertThat(reported.get()).hasRootCauseMessage("send failed"); + } + + @Test + void aFailedConnectIsReportedAndMayBeRetried() { + httpClient.failConnect = true; + transport.setExceptionHandler(reported::set); + + try (QuietLoggers quiet = QuietLoggers.of(WebSocketAcpClientTransport.class)) { + assertThatThrownBy(() -> transport.connect(message -> message).block(TIMEOUT)) + .hasRootCauseMessage("refused"); + } + assertThat(reported.get()).hasMessage("refused"); + + httpClient.failConnect = false; + connect(); + assertThat(webSocket.requested).isEqualTo(1); + + // The retried connection works: the agent's request reaches the handler and is answered. + httpClient.listener().onText(webSocket, REQUEST, true); + awaitSentFrames(1); + assertThat(received).singleElement().isInstanceOf(AcpSchema.JSONRPCRequest.class); + assertThat(webSocket.sent.get(0)).contains("\"id\":7").contains("\"result\":\"ok\""); + } + + @Test + void connectIsRefusedWhileConnected() { + connect(); + + assertThatThrownBy(() -> transport.connect(message -> message).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Already connected"); + } + + @Test + void closeGracefullySendsANormalClosure() { + connect(); + + transport.closeGracefully().block(TIMEOUT); + + assertThat(webSocket.closeStatus).isEqualTo(WebSocket.NORMAL_CLOSURE); + transport.awaitTermination().block(TIMEOUT); + } + + /** + * An invalid request from the agent is its payload and can carry the user's data: it is + * answered -32600, and no log at INFO or above, the default exception handler's included, + * quotes it. + */ + @Test + void anInvalidRequestIsNotLoggedAboveDebug() { + try (CapturedLogs logs = CapturedLogs.open()) { + transport.connect(message -> message).block(TIMEOUT); + + httpClient.listener() + .onText(webSocket, "{\"id\":1,\"method\":\"session/request_permission\"," + + "\"params\":{\"sessionId\":\"s\",\"text\":\"SECRET-123\"}}", true); + + awaitSentFrames(1); + assertThat(logs.events()).as("the refusal is logged").isNotEmpty(); + logs.assertNoneAtInfoOrAboveContains("SECRET-123"); + } + assertThat(webSocket.sent.get(0)).contains("-32600"); + } + + /** + * The endpoint's query string can carry an access token, and its user information a + * password: neither is logged when connecting, nor when the connect fails. + */ + @Test + void theEndpointIsLoggedWithoutItsQueryOrUserInformation() { + URI endpoint = URI.create("ws://user:PASSWORD-123@localhost:1/acp?token=SECRET-123"); + WebSocketAcpClientTransport withToken = new WebSocketAcpClientTransport(endpoint, + AcpJsonMapper.createDefault(), httpClient); + try (CapturedLogs logs = CapturedLogs.open()) { + httpClient.failConnect = true; + withToken.setExceptionHandler(error -> { + }); + assertThatThrownBy(() -> withToken.connect(message -> message).block(TIMEOUT)).isNotNull(); + httpClient.failConnect = false; + withToken.connect(message -> message).block(TIMEOUT); + + assertThat(logs.events()).extracting(event -> event.getFormattedMessage()) + .anySatisfy(message -> assertThat(message).contains("Connected").contains("ws://localhost:1/acp")); + logs.assertNoneAtInfoOrAboveContains("SECRET-123"); + logs.assertNoneAtInfoOrAboveContains("PASSWORD-123"); + } + finally { + withToken.closeGracefully().block(TIMEOUT); + } + } + + private void awaitSentFrames(int count) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (webSocket.sent.size() < count && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertThat(webSocket.sent).hasSizeGreaterThanOrEqualTo(count); + } + + /** Records what the transport sends; requests and closes are counted, not acted on. */ + static final class FakeWebSocket implements WebSocket { + + final List sent = new CopyOnWriteArrayList<>(); + + volatile long requested; + + volatile int closeStatus = -1; + + volatile boolean failSends; + + @Override + public CompletableFuture sendText(CharSequence data, boolean last) { + if (failSends) { + return CompletableFuture.failedFuture(new IOException("send failed")); + } + sent.add(data.toString()); + return CompletableFuture.completedFuture(this); + } + + @Override + public CompletableFuture sendBinary(ByteBuffer data, boolean last) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture sendPing(ByteBuffer message) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture sendPong(ByteBuffer message) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture sendClose(int statusCode, String reason) { + closeStatus = statusCode; + return CompletableFuture.completedFuture(this); + } + + @Override + public void request(long n) { + requested += n; + } + + @Override + public String getSubprotocol() { + return ""; + } + + @Override + public boolean isOutputClosed() { + return closeStatus != -1; + } + + @Override + public boolean isInputClosed() { + return false; + } + + @Override + public void abort() { + } + + } + + /** An HttpClient whose WebSocket builder opens the fake socket and keeps the listener. */ + static final class FakeHttpClient extends HttpClient { + + private final FakeWebSocket webSocket; + + private final AtomicReference listener = new AtomicReference<>(); + + volatile boolean failConnect; + + FakeHttpClient(FakeWebSocket webSocket) { + this.webSocket = webSocket; + } + + WebSocket.Listener listener() { + WebSocket.Listener current = listener.get(); + assertThat(current).as("connected").isNotNull(); + return current; + } + + @Override + public WebSocket.Builder newWebSocketBuilder() { + return new WebSocket.Builder() { + + @Override + public WebSocket.Builder header(String name, String value) { + return this; + } + + @Override + public WebSocket.Builder connectTimeout(Duration timeout) { + return this; + } + + @Override + public WebSocket.Builder subprotocols(String mostPreferred, String... lesserPreferred) { + return this; + } + + @Override + public CompletableFuture buildAsync(URI uri, WebSocket.Listener webSocketListener) { + if (failConnect) { + return CompletableFuture.failedFuture(new IOException("refused")); + } + listener.set(webSocketListener); + webSocketListener.onOpen(webSocket); + return CompletableFuture.completedFuture(webSocket); + } + + }; + } + + @Override + public Optional cookieHandler() { + return Optional.empty(); + } + + @Override + public Optional connectTimeout() { + return Optional.empty(); + } + + @Override + public Redirect followRedirects() { + return Redirect.NEVER; + } + + @Override + public Optional proxy() { + return Optional.empty(); + } + + @Override + public SSLContext sslContext() { + throw new UnsupportedOperationException(); + } + + @Override + public SSLParameters sslParameters() { + throw new UnsupportedOperationException(); + } + + @Override + public Optional authenticator() { + return Optional.empty(); + } + + @Override + public Version version() { + return Version.HTTP_1_1; + } + + @Override + public Optional executor() { + return Optional.empty(); + } + + @Override + public HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> sendAsync(HttpRequest request, + HttpResponse.BodyHandler responseBodyHandler, + HttpResponse.PushPromiseHandler pushPromiseHandler) { + throw new UnsupportedOperationException(); + } + + } + + /** A message sent once the transport is closed fails; it used to be dropped silently. */ + @Test + void aMessageSentAfterCloseFails() { + connect(); + transport.closeGracefully().block(TIMEOUT); + + assertThatThrownBy(() -> transport + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "session/cancel", null)) + .block(TIMEOUT)).isInstanceOf(com.agentclientprotocol.sdk.error.AcpConnectionException.class); + assertThat(webSocket.sent).isEmpty(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportTest.java index e6df7ef3..40161c10 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/client/transport/WebSocketAcpClientTransportTest.java @@ -91,4 +91,30 @@ void failedConnectAllowsRetry() { } } + /** + * The transport's own HTTP client runs on an executor the transport created; closing the + * transport shuts it down, so its threads do not linger (about 60 seconds) after close. + */ + @Test + void closeShutsDownTheExecutorOfItsOwnHttpClient() throws Exception { + WebSocketAcpClientTransport transport = new WebSocketAcpClientTransport(URI.create("ws://127.0.0.1:1/acp"), + jsonMapper); + try { + transport.connect(msg -> Mono.empty()).block(Duration.ofSeconds(10)); + } + catch (RuntimeException expected) { + // nothing listens on port 1 + } + java.lang.reflect.Field field = WebSocketAcpClientTransport.class.getDeclaredField("httpClient"); + field.setAccessible(true); + java.net.http.HttpClient httpClient = (java.net.http.HttpClient) field.get(transport); + java.util.concurrent.ExecutorService executor = (java.util.concurrent.ExecutorService) httpClient.executor() + .orElseThrow(); + assertThat(executor.isShutdown()).isFalse(); + + transport.closeGracefully().block(Duration.ofSeconds(10)); + + assertThat(executor.isShutdown()).isTrue(); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpErrorCodesTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpErrorCodesTest.java index 4d520d4e..9aaa2a06 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpErrorCodesTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpErrorCodesTest.java @@ -23,24 +23,40 @@ void verifyStandardJsonRpcErrorCodes() { assertThat(AcpErrorCodes.INTERNAL_ERROR).isEqualTo(-32603); } + /** + * ACP v1 schema, {@code $defs.ErrorCode}: the only codes ACP defines in its reserved + * -32000..-32099 range are -32000 (authentication required) and -32002 (resource not + * found), plus -32800 (request cancelled). + */ @Test void verifyAcpSpecificErrorCodes() { - // ACP-specific error codes should be in the -32000 to -32099 range - assertThat(AcpErrorCodes.CONCURRENT_PROMPT).isEqualTo(-32000); - assertThat(AcpErrorCodes.CAPABILITY_NOT_SUPPORTED).isEqualTo(-32001); - assertThat(AcpErrorCodes.SESSION_NOT_FOUND).isEqualTo(-32002); - assertThat(AcpErrorCodes.NOT_INITIALIZED).isEqualTo(-32003); - assertThat(AcpErrorCodes.AUTHENTICATION_REQUIRED).isEqualTo(-32004); - assertThat(AcpErrorCodes.PERMISSION_DENIED).isEqualTo(-32005); + assertThat(AcpErrorCodes.REQUEST_CANCELLED).isEqualTo(-32800); + assertThat(AcpErrorCodes.AUTHENTICATION_REQUIRED).isEqualTo(-32000); + assertThat(AcpErrorCodes.RESOURCE_NOT_FOUND).isEqualTo(-32002); + } + + /** Every public constant is a code the ACP v1 schema defines, and no two share a value. */ + @Test + void everyConstantIsASpecErrorCode() throws IllegalAccessException { + java.util.Set spec = java.util.Set.of(-32700, -32600, -32601, -32602, -32603, -32800, -32000, + -32002); + java.util.List values = new java.util.ArrayList<>(); + for (java.lang.reflect.Field field : AcpErrorCodes.class.getFields()) { + if (field.getType() == int.class) { + values.add(field.getInt(null)); + } + } + assertThat(values).hasSize(spec.size()).doesNotHaveDuplicates(); + assertThat(spec).containsAll(values); } @Test void getDescriptionReturnsCorrectMessages() { assertThat(AcpErrorCodes.getDescription(AcpErrorCodes.PARSE_ERROR)).isEqualTo("Parse error"); assertThat(AcpErrorCodes.getDescription(AcpErrorCodes.METHOD_NOT_FOUND)).isEqualTo("Method not found"); - assertThat(AcpErrorCodes.getDescription(AcpErrorCodes.CONCURRENT_PROMPT)).isEqualTo("Concurrent prompt"); - assertThat(AcpErrorCodes.getDescription(AcpErrorCodes.CAPABILITY_NOT_SUPPORTED)) - .isEqualTo("Capability not supported"); + assertThat(AcpErrorCodes.getDescription(-32000)).isEqualTo("Authentication required"); + assertThat(AcpErrorCodes.getDescription(-32002)).isEqualTo("Resource not found"); + assertThat(AcpErrorCodes.getDescription(-32800)).isEqualTo("Request cancelled"); } @Test diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpProtocolExceptionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpProtocolExceptionTest.java index fd7b5ed7..9e9cd1da 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpProtocolExceptionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/error/AcpProtocolExceptionTest.java @@ -18,7 +18,7 @@ class AcpProtocolExceptionTest { void constructFromJsonRpcError() { AcpSchema.JSONRPCError error = new AcpSchema.JSONRPCError(-32601, "Method not found", "session/unknown"); - AcpProtocolException exception = new AcpProtocolException(error); + AcpProtocolException exception = error.toException(); assertThat(exception.getCode()).isEqualTo(-32601); assertThat(exception.getMessage()).contains("-32601"); @@ -28,11 +28,11 @@ void constructFromJsonRpcError() { @Test void constructWithCodeAndMessage() { - AcpProtocolException exception = new AcpProtocolException(AcpErrorCodes.CONCURRENT_PROMPT, + AcpProtocolException exception = new AcpProtocolException(AcpErrorCodes.INVALID_REQUEST, "Already processing"); - assertThat(exception.getCode()).isEqualTo(-32000); - assertThat(exception.getMessage()).contains("-32000"); + assertThat(exception.getCode()).isEqualTo(-32600); + assertThat(exception.getMessage()).contains("-32600"); assertThat(exception.getMessage()).contains("Already processing"); assertThat(exception.getData()).isNull(); } @@ -52,13 +52,30 @@ void toJsonRpcErrorConvertsCorrectly() { AcpProtocolException exception = new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, "Unexpected failure", null); - AcpSchema.JSONRPCError error = exception.toJsonRpcError(); + AcpSchema.JSONRPCError error = AcpSchema.JSONRPCError.from(exception); assertThat(error.code()).isEqualTo(-32603); assertThat(error.message()).contains("Unexpected failure"); assertThat(error.data()).isNull(); } + /** + * The wire message is the plain message, the code travels in {@code code}; the Java + * exception's own message keeps the code for logs. A received error passed on keeps its + * message unchanged. + */ + @Test + void theWireMessageCarriesNoCode() { + AcpProtocolException exception = new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, + "unknown directive: #nope"); + + assertThat(AcpSchema.JSONRPCError.from(exception).message()).isEqualTo("unknown directive: #nope"); + assertThat(exception.getErrorMessage()).isEqualTo("unknown directive: #nope"); + assertThat(exception.getMessage()).isEqualTo("[-32602] unknown directive: #nope"); + AcpSchema.JSONRPCError received = new AcpSchema.JSONRPCError(-32601, "Method not found", null); + assertThat(AcpSchema.JSONRPCError.from(received.toException())).isEqualTo(received); + } + @Test void isMethodNotFoundReturnsTrueForCorrectCode() { AcpProtocolException methodNotFound = new AcpProtocolException(AcpErrorCodes.METHOD_NOT_FOUND, "Unknown method"); @@ -69,12 +86,12 @@ void isMethodNotFoundReturnsTrueForCorrectCode() { } @Test - void isConcurrentPromptReturnsTrueForCorrectCode() { - AcpProtocolException concurrent = new AcpProtocolException(AcpErrorCodes.CONCURRENT_PROMPT, "Already running"); + void isAuthenticationRequiredReturnsTrueForCorrectCode() { + AcpProtocolException authRequired = new AcpProtocolException(-32000, "Log in first"); AcpProtocolException otherError = new AcpProtocolException(AcpErrorCodes.INTERNAL_ERROR, "Internal error"); - assertThat(concurrent.isConcurrentPrompt()).isTrue(); - assertThat(otherError.isConcurrentPrompt()).isFalse(); + assertThat(authRequired.isAuthenticationRequired()).isTrue(); + assertThat(otherError.isAuthenticationRequired()).isFalse(); } @Test diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/AbstractAcpClientAgentIT.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/AbstractAcpClientAgentIT.java index c86c9ffb..85791093 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/AbstractAcpClientAgentIT.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/AbstractAcpClientAgentIT.java @@ -81,7 +81,7 @@ void initializeHandshakeSucceeds() throws Exception { return Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(true, null, null), List.of())); // loadSession=true }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); // Build client AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); @@ -92,7 +92,7 @@ void initializeHandshakeSucceeds() throws Exception { // Initialize client AcpSchema.InitializeResponse response = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) + .initialize() .block(TIMEOUT); assertThat(response).isNotNull(); @@ -124,7 +124,7 @@ void sessionCreationWorks() throws Exception { assertThat(request.cwd()).isEqualTo("/test/workspace"); return Mono.just(new AcpSchema.NewSessionResponse("session-123", null, null)); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); // Build client AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); @@ -132,7 +132,7 @@ void sessionCreationWorks() throws Exception { // Start and initialize agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); // Create session AcpSchema.NewSessionResponse sessionResponse = client @@ -151,6 +151,50 @@ void sessionCreationWorks() throws Exception { } } + @Test + void sessionResponsesCarryConfigOptions() throws Exception { + AcpClientTransport clientTransport = createClientTransport(); + AcpAgentTransport agentTransport = createAgentTransport(); + List options = List.of( + new AcpSchema.SessionConfigSelect("model", "Model", "fast", + List.of(new AcpSchema.SessionConfigSelectOption("fast", "Fast"), + new AcpSchema.SessionConfigSelectOption("smart", "Smart"))), + new AcpSchema.SessionConfigBoolean("web", "Web search", true)); + + try { + AcpAsyncAgent agent = AcpAgent.async(agentTransport) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(1, + new AcpSchema.AgentCapabilities(true, new AcpSchema.SessionCapabilities(null, null, Map.of()), + null, null, null), + List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, options))) + .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null, options))) + .resumeSessionHandler(request -> Mono.just(new AcpSchema.ResumeSessionResponse(null, options))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); + agent.start().subscribe(); + Thread.sleep(100); + client.initialize().block(TIMEOUT); + + var created = client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT); + var loaded = client.loadSession(new AcpSchema.LoadSessionRequest("s1", "/w", List.of())).block(TIMEOUT); + var resumed = client.resumeSession(new AcpSchema.ResumeSessionRequest("s1", "/w", List.of())) + .block(TIMEOUT); + + assertThat(created.configOptions()).isEqualTo(options); + assertThat(loaded.configOptions()).isEqualTo(options); + assertThat(resumed.configOptions()).isEqualTo(options); + + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + finally { + closeTransports(); + } + } + @Test void promptResponseFlowWorks() throws Exception { AcpClientTransport clientTransport = createClientTransport(); @@ -178,7 +222,7 @@ void promptResponseFlowWorks() throws Exception { // Start, initialize, and create session agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt @@ -218,20 +262,16 @@ void sessionUpdatesStreamCorrectly() throws Exception { .promptHandler((request, updater) -> { // Send streaming updates during prompt processing return updater - .sendUpdate("session-updates", - new AcpSchema.AgentThoughtChunk("agent_thought_chunk", - new AcpSchema.TextContent("Analyzing code..."))) - .then(updater.sendUpdate("session-updates", - new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Found the issue")))) + .sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent("Analyzing code..."))) + .then(updater.sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("Found the issue")))) .then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))); }) .build(); - // Build client with session update consumer + // Build client with session update handler AcpAsyncClient client = AcpClient.async(clientTransport) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { receivedUpdates.add(notification); updateLatch.countDown(); return Mono.empty(); @@ -241,7 +281,7 @@ void sessionUpdatesStreamCorrectly() throws Exception { // Start, initialize, and create session agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt and verify updates are received @@ -281,8 +321,7 @@ void agentToClientPermissionRequestWorks() throws Exception { request -> Mono.just(new AcpSchema.NewSessionResponse("session-permission", null, null))) .promptHandler((request, updater) -> { // Request permission from client - AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-123", "Write File", - AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING, null, null, null, null); + AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-123", "Write File", AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING); List options = List.of( new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE), new AcpSchema.PermissionOption("deny", "Deny", AcpSchema.PermissionOptionKind.REJECT_ONCE)); @@ -311,7 +350,7 @@ void agentToClientPermissionRequestWorks() throws Exception { // Start, initialize, and create session agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt which triggers permission request @@ -366,7 +405,10 @@ void agentToClientFileReadWorks() throws Exception { agentRef.set(agent); // Build client with file read handler - using typed handler + AcpSchema.FileSystemCapability fsCaps = new AcpSchema.FileSystemCapability(true, false); + AcpSchema.ClientCapabilities clientCaps = new AcpSchema.ClientCapabilities(fsCaps, false); AcpAsyncClient client = AcpClient.async(clientTransport) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> { assertThat(request.path()).isEqualTo("/src/Main.java"); @@ -376,11 +418,9 @@ void agentToClientFileReadWorks() throws Exception { // Start, initialize, and create session // Client must advertise file reading capability for agent to use it - AcpSchema.FileSystemCapability fsCaps = new AcpSchema.FileSystemCapability(true, false); - AcpSchema.ClientCapabilities clientCaps = new AcpSchema.ClientCapabilities(fsCaps, false); agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt which triggers file read @@ -411,6 +451,7 @@ void gracefulShutdownWorks() throws Exception { .requestTimeout(TIMEOUT) .initializeHandler(request -> Mono .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build(); // Build client @@ -419,7 +460,7 @@ void gracefulShutdownWorks() throws Exception { // Start and initialize agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); // Graceful shutdown should complete without error client.closeGracefully().block(TIMEOUT); @@ -451,7 +492,7 @@ void cancelNotificationWorks() throws Exception { cancelLatch.countDown(); return Mono.empty(); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); // Build client AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); @@ -459,7 +500,7 @@ void cancelNotificationWorks() throws Exception { // Start, initialize, create session agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send cancel notification @@ -485,15 +526,13 @@ void forkSessionWorks() throws Exception { try { AcpAsyncAgent agent = AcpAgent.async(agentTransport) .requestTimeout(TIMEOUT) - .initializeHandler(request -> Mono - .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) .newSessionHandler( request -> Mono.just(new AcpSchema.NewSessionResponse("session-original", null, null))) .forkSessionHandler(request -> { assertThat(request.sessionId()).isEqualTo("session-original"); - return Mono.just(new AcpSchema.ForkSessionResponse("session-forked", null, null)); + return Mono.just(new AcpSchema.ForkSessionResponse("session-forked", null)); }) - .build(); + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); AcpAsyncClient client = AcpClient.async(clientTransport) .requestTimeout(TIMEOUT) @@ -501,7 +540,7 @@ void forkSessionWorks() throws Exception { agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); AcpSchema.ForkSessionResponse forkResp = client @@ -559,7 +598,11 @@ void agentToClientElicitationFormWorks() throws Exception { agentRef.set(agent); // Build client with elicitation handler that accepts with "fast" + var caps = new AcpSchema.ClientCapabilities( + new AcpSchema.FileSystemCapability(), false, + null, null, AcpSchema.ElicitationCapabilities.formOnly(), null); AcpAsyncClient client = AcpClient.async(clientTransport) + .clientCapabilities(caps) .requestTimeout(TIMEOUT) .createElicitationHandler(elicitRequest -> { assertThat(elicitRequest.mode()).isEqualTo("form"); @@ -576,10 +619,7 @@ void agentToClientElicitationFormWorks() throws Exception { Thread.sleep(100); // Advertise elicitation capability - var caps = new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(), false, - new AcpSchema.ElicitationCapabilities(), null); - client.initialize(new AcpSchema.InitializeRequest(1, caps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt which triggers elicitation @@ -632,7 +672,11 @@ void agentToClientElicitationDeclineWorks() throws Exception { agentRef.set(agent); // Client declines the form + var caps = new AcpSchema.ClientCapabilities( + new AcpSchema.FileSystemCapability(), false, + null, null, AcpSchema.ElicitationCapabilities.formOnly(), null); AcpAsyncClient client = AcpClient.async(clientTransport) + .clientCapabilities(caps) .requestTimeout(TIMEOUT) .createElicitationHandler(req -> Mono.just(AcpSchema.CreateElicitationResponse.decline())) @@ -640,10 +684,7 @@ void agentToClientElicitationDeclineWorks() throws Exception { agent.start().subscribe(); Thread.sleep(100); - var caps = new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(), false, - new AcpSchema.ElicitationCapabilities(), null); - client.initialize(new AcpSchema.InitializeRequest(1, caps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new AcpSchema.PromptRequest("session-decline", List.of(new AcpSchema.TextContent("test")))).block(TIMEOUT); @@ -692,7 +733,11 @@ void agentToClientElicitationCancelWorks() throws Exception { agentRef.set(agent); // Client cancels the form + var caps = new AcpSchema.ClientCapabilities( + new AcpSchema.FileSystemCapability(), false, + null, null, AcpSchema.ElicitationCapabilities.formOnly(), null); AcpAsyncClient client = AcpClient.async(clientTransport) + .clientCapabilities(caps) .requestTimeout(TIMEOUT) .createElicitationHandler(req -> Mono.just(AcpSchema.CreateElicitationResponse.cancel())) @@ -700,10 +745,7 @@ void agentToClientElicitationCancelWorks() throws Exception { agent.start().subscribe(); Thread.sleep(100); - var caps = new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(), false, - new AcpSchema.ElicitationCapabilities(), null); - client.initialize(new AcpSchema.InitializeRequest(1, caps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.prompt(new AcpSchema.PromptRequest("session-cancel", List.of(new AcpSchema.TextContent("test")))).block(TIMEOUT); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CancelRequestEndToEndTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CancelRequestEndToEndTest.java new file mode 100644 index 00000000..f584964b --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CancelRequestEndToEndTest.java @@ -0,0 +1,173 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.RequestCancellation; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * {@code $/cancel_request} end to end, both directions: the side that gives up on a request + * cancels the work at its peer. + */ +class CancelRequestEndToEndTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "session-cancel-request"; + + /** The client disposes its prompt: the agent's handler is cancelled and the turn ends. */ + @Test + void aPromptTheClientGivesUpOnIsCancelledAtTheAgent() { + Sinks.Empty handlerCancelled = Sinks.empty(); + Sinks.Empty handlerStarted = Sinks.empty(); + AtomicInteger prompts = new AtomicInteger(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> { + if (prompts.incrementAndGet() > 1) { + return Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + } + return Mono.never() + .doOnSubscribe(s -> handlerStarted.tryEmitEmpty()) + .doOnCancel(handlerCancelled::tryEmitEmpty); + }) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + AcpSchema.PromptRequest prompt = new AcpSchema.PromptRequest(SESSION, + List.of(new AcpSchema.TextContent("work"))); + + Disposable first = client.prompt(prompt).subscribe(); + handlerStarted.asMono().block(TIMEOUT); + first.dispose(); + + handlerCancelled.asMono().block(TIMEOUT); + // The agent answered the cancelled prompt and ended its turn: the next one is accepted. + assertThat(retryWhileConcurrent(client, prompt).stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + + /** A graceful cancel: the client keeps waiting and receives the agent's -32800. */ + @Test + void aGracefullyCancelledPromptEndsWithTheAgentsRequestCancelled() { + Sinks.Empty handlerStarted = Sinks.empty(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> Mono.never() + .doOnSubscribe(s -> handlerStarted.tryEmitEmpty())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + Mono prompt = client + .prompt(new AcpSchema.PromptRequest(SESSION, List.of(new AcpSchema.TextContent("work")))) + .contextWrite(RequestCancellation.cancelWhen(handlerStarted.asMono())); + + assertThatThrownBy(() -> prompt.block(TIMEOUT)).isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED)); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + + /** + * The cancelled answer and the next prompt travel separately; the client does not see the + * answer (it gave up), so it may get there first. Retry briefly on -32000. + */ + private static AcpSchema.PromptResponse retryWhileConcurrent(AcpAsyncClient client, + AcpSchema.PromptRequest prompt) { + return client.prompt(prompt) + .retryWhen(reactor.util.retry.Retry.fixedDelay(50, Duration.ofMillis(20))) + .block(TIMEOUT); + } + + /** + * The agent gives up on a permission request (its own timeout here): the client's + * permission handler is cancelled. + */ + @Test + void aPermissionRequestTheAgentGivesUpOnIsCancelledAtTheClient() { + Sinks.Empty permissionCancelled = Sinks.empty(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Write File", + AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING); + List options = List + .of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE)); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> context + .client().requestPermission(new AcpSchema.RequestPermissionRequest(SESSION, toolCall, options)) + .timeout(Duration.ofMillis(200)) + .map(response -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)) + .onErrorReturn(new AcpSchema.PromptResponse(AcpSchema.StopReason.REFUSAL))) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .requestPermissionHandler(request -> Mono.never() + .doOnCancel(permissionCancelled::tryEmitEmpty)) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(SESSION, List.of(new AcpSchema.TextContent("work")))) + .block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.REFUSAL); + permissionCancelled.asMono().block(TIMEOUT); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CleanShutdownIT.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CleanShutdownIT.java index 3b886fa1..ccb5740f 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CleanShutdownIT.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/CleanShutdownIT.java @@ -120,6 +120,7 @@ void agentAwaitBlocksUntilTransportCloses() throws InterruptedException { .requestTimeout(Duration.ofSeconds(5)) .initializeHandler(request -> new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of())) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) .build(); // Track whether await() returned @@ -130,7 +131,7 @@ void agentAwaitBlocksUntilTransportCloses() throws InterruptedException { Thread agentThread = new Thread(() -> { agent.start(); agentStarted.countDown(); - agent.await(); // Should block until transport closes + agent.awaitTermination(); // Should block until transport closes awaitReturned.set(true); }); agentThread.setDaemon(true); @@ -172,6 +173,7 @@ void agentRunBlocksUntilTransportCloses() throws InterruptedException { .requestTimeout(Duration.ofSeconds(5)) .initializeHandler(request -> new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of())) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) .build(); // Track whether run() returned diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ElicitationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ElicitationTest.java new file mode 100644 index 00000000..aa989859 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ElicitationTest.java @@ -0,0 +1,300 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.CompleteElicitationNotification; +import com.agentclientprotocol.sdk.spec.AcpSchema.CreateElicitationRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.CreateElicitationResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.ElicitationCapabilities; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Elicitation (stable since ACP v1.9.1): the agent asks, the client's typed handler + * answers, URL-mode completion reaches the client's typed {@code elicitation/complete} + * handler, and each side honours the modes the client advertised. + */ +class ElicitationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "session-elicit"; + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + @AfterEach + void close() { + pair.closeGracefully().block(TIMEOUT); + } + + private AcpAsyncAgent startAgent() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + return agent; + } + + private static AcpSchema.ClientCapabilities capabilities(ElicitationCapabilities elicitation) { + return AcpSchema.ClientCapabilities.builder().elicitation(elicitation).build(); + } + + private static CreateElicitationRequest urlRequest(String elicitationId) { + return CreateElicitationRequest.url(SESSION, "Authorize access", elicitationId, + "https://agent.example.com/connect?elicitationId=" + elicitationId); + } + + @Test + void urlElicitationCompletionReachesTheAsyncClientsTypedHandler() throws Exception { + AcpAsyncAgent agent = startAgent(); + CompletableFuture completed = new CompletableFuture<>(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .clientCapabilities(capabilities(ElicitationCapabilities.urlOnly())) + .requestTimeout(TIMEOUT) + .createElicitationHandler(request -> { + assertThat(request.mode()).isEqualTo(CreateElicitationRequest.MODE_URL); + return Mono.just(CreateElicitationResponse.accept()); + }) + .completeElicitationHandler(notification -> Mono.fromRunnable(() -> completed.complete(notification))) + .build(); + client.initialize().block(TIMEOUT); + + CreateElicitationResponse response = agent.createElicitation(urlRequest("github-oauth-001")).block(TIMEOUT); + assertThat(response).isNotNull(); + assertThat(response.action()).isEqualTo(AcpSchema.ElicitationAction.ACCEPT); + agent.completeElicitation(new CompleteElicitationNotification("github-oauth-001")).block(TIMEOUT); + + assertThat(completed.get(5, TimeUnit.SECONDS).elicitationId()).isEqualTo("github-oauth-001"); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + @Test + void urlElicitationCompletionReachesTheSyncClientsTypedHandler() throws Exception { + AcpAsyncAgent agent = startAgent(); + CompletableFuture completed = new CompletableFuture<>(); + Function create = request -> CreateElicitationResponse + .accept(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .clientCapabilities(capabilities(ElicitationCapabilities.formAndUrl())) + .requestTimeout(TIMEOUT) + .createElicitationHandler(create) + .completeElicitationHandler(completed::complete) + .build(); + client.initialize(); + + AcpSyncAgent syncAgent = new AcpSyncAgent(agent, TIMEOUT); + assertThat(syncAgent.createElicitation(urlRequest("e-sync")).action()) + .isEqualTo(AcpSchema.ElicitationAction.ACCEPT); + syncAgent.completeElicitation(new CompleteElicitationNotification("e-sync", Map.of("k", "v"))); + + CompleteElicitationNotification notification = completed.get(5, TimeUnit.SECONDS); + assertThat(notification.elicitationId()).isEqualTo("e-sync"); + assertThat(notification.meta()).containsEntry("k", "v"); + client.close(); + agent.closeGracefully().block(TIMEOUT); + } + + @Test + void agentRefusesAModeTheClientDidNotAdvertise() { + AcpAsyncAgent agent = startAgent(); + AtomicInteger asked = new AtomicInteger(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .clientCapabilities(capabilities(ElicitationCapabilities.formOnly())) + .requestTimeout(TIMEOUT) + .createElicitationHandler(request -> { + asked.incrementAndGet(); + return Mono.just(CreateElicitationResponse.decline()); + }) + .build(); + client.initialize().block(TIMEOUT); + + assertThatThrownBy(() -> agent.createElicitation(urlRequest("e1")).block(TIMEOUT)) + .isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("elicitation.url"); + CreateElicitationResponse form = agent + .createElicitation(CreateElicitationRequest.form(SESSION, "Name?", + new AcpSchema.ElicitationSchema(Map.of("name", AcpSchema.StringPropertySchema.text("Name")), + List.of("name")))) + .block(TIMEOUT); + assertThat(form).isNotNull(); + assertThat(form.action()).isEqualTo(AcpSchema.ElicitationAction.DECLINE); + assertThat(asked).hasValue(1); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + @Test + void anEmptyElicitationCapabilityAdvertisesNoMode() { + AcpAsyncAgent agent = startAgent(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .clientCapabilities(capabilities(new ElicitationCapabilities(null, null, null))).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + + assertThatThrownBy(() -> agent + .createElicitation(CreateElicitationRequest.form(SESSION, "Name?", + new AcpSchema.ElicitationSchema(Map.of(), null))) + .block(TIMEOUT)).isInstanceOf(AcpCapabilityException.class).hasMessageContaining("elicitation.form"); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + @Test + void clientAnswersInvalidParamsForAModeItDidNotAdvertise() throws Exception { + CompletableFuture answer = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + answer.complete(response); + } + }); + AtomicInteger asked = new AtomicInteger(); + AcpAsyncClient client = AcpClient.async(transport) + .clientCapabilities(capabilities(ElicitationCapabilities.formOnly())).createElicitationHandler(request -> { + asked.incrementAndGet(); + return Mono.just(CreateElicitationResponse.cancel()); + }).build(); + client.initialize().subscribe(r -> { + }, e -> { + }); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "e-1", + AcpSchema.METHOD_ELICITATION_CREATE, Map.of("sessionId", SESSION, "mode", "url", "message", "Authorize", + "elicitationId", "e1", "url", "https://agent.example.com/connect"))); + + AcpSchema.JSONRPCResponse response = answer.get(5, TimeUnit.SECONDS); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.INVALID_PARAMS); + assertThat(asked).hasValue(0); + client.closeGracefully().block(TIMEOUT); + } + + @Test + void clientPassesAnAdvertisedModeToItsHandler() throws Exception { + CompletableFuture answer = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + answer.complete(response); + } + }); + AcpAsyncClient client = AcpClient.async(transport) + .clientCapabilities(capabilities(ElicitationCapabilities.formOnly())) + .createElicitationHandler(request -> Mono.just(CreateElicitationResponse.accept(Map.of("name", "Ada")))) + .build(); + client.initialize().subscribe(r -> { + }, e -> { + }); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "e-2", + AcpSchema.METHOD_ELICITATION_CREATE, + Map.of("requestId", 12, "mode", "form", "message", "Name?", "requestedSchema", Map.of()))); + + AcpSchema.JSONRPCResponse response = answer.get(5, TimeUnit.SECONDS); + assertThat(response.error()).isNull(); + assertThat(response.result()).isInstanceOf(CreateElicitationResponse.class); + client.closeGracefully().block(TIMEOUT); + } + + @Test + void aModeTheSdkDoesNotKnowIsLeftToTheHandler() { + AcpAsyncAgent agent = startAgent(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .clientCapabilities(capabilities(ElicitationCapabilities.formOnly())) + .requestTimeout(TIMEOUT) + .createElicitationHandler(request -> Mono.just(request.mode().equals("x-future") + ? CreateElicitationResponse.decline() : CreateElicitationResponse.cancel())) + .build(); + client.initialize().block(TIMEOUT); + + CreateElicitationResponse response = agent + .createElicitation(new CreateElicitationRequest(SESSION, null, null, "?", "x-future", null, null, null, + null)) + .block(TIMEOUT); + assertThat(response).isNotNull(); + assertThat(response.action()).isEqualTo(AcpSchema.ElicitationAction.DECLINE); + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + } + + @Test + void beforeInitializeTheClientKnowsNoModeAndAsksItsHandler() throws Exception { + CompletableFuture answer = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + answer.complete(response); + } + }); + AcpAsyncClient client = AcpClient.async(transport) + .createElicitationHandler(request -> Mono.just(CreateElicitationResponse.decline())) + .build(); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "e-3", + AcpSchema.METHOD_ELICITATION_CREATE, Map.of("sessionId", SESSION, "mode", "url", "message", "Authorize", + "elicitationId", "e3", "url", "https://agent.example.com/connect"))); + + assertThat(answer.get(5, TimeUnit.SECONDS).error()).isNull(); + client.closeGracefully().block(TIMEOUT); + } + + @Test + void initializingWithoutCapabilitiesAdvertisesFormModeOnly() throws Exception { + // Without clientCapabilities(..) an elicitation handler advertises form mode, not URL mode + CompletableFuture answer = new CompletableFuture<>(); + MockAcpClientTransport transport = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCResponse response) { + answer.complete(response); + } + }); + AcpAsyncClient client = AcpClient.async(transport) + .createElicitationHandler(request -> Mono.just(CreateElicitationResponse.decline())) + .build(); + client.initialize().subscribe(r -> { + }, e -> { + }); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "e-4", + AcpSchema.METHOD_ELICITATION_CREATE, Map.of("sessionId", SESSION, "mode", "url", "message", "Authorize", + "elicitationId", "e4", "url", "https://agent.example.com/connect"))); + + AcpSchema.JSONRPCResponse response = answer.get(5, TimeUnit.SECONDS); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.INVALID_PARAMS); + client.closeGracefully().block(TIMEOUT); + } + + @Test + void aRawRequestHandlerCannotBypassTheTypedOne() { + assertThatThrownBy(() -> AcpClient.async(new MockAcpClientTransport()) + .requestHandler(AcpSchema.METHOD_ELICITATION_CREATE, params -> Mono.just("raw"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("createElicitationHandler"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ErrorCodePropagationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ErrorCodePropagationTest.java index f071a8b6..a391aeb9 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ErrorCodePropagationTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ErrorCodePropagationTest.java @@ -17,6 +17,7 @@ import com.agentclientprotocol.sdk.error.AcpErrorCodes; import com.agentclientprotocol.sdk.error.AcpProtocolException; import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpError; import com.agentclientprotocol.sdk.spec.AcpClientSession; import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.test.InMemoryTransportPair; @@ -80,15 +81,15 @@ void invalidParamsErrorCodeIsPreserved() throws Exception { // Start agent and initialize client agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt - should receive INVALID_PARAMS error assertThatThrownBy(() -> { client.prompt(new AcpSchema.PromptRequest("session-123", List.of(new AcpSchema.TextContent("test")))) .block(TIMEOUT); - }).isInstanceOf(AcpClientSession.AcpError.class).satisfies(ex -> { - AcpClientSession.AcpError acpError = (AcpClientSession.AcpError) ex; + }).isInstanceOf(AcpError.class).satisfies(ex -> { + AcpError acpError = (AcpError) ex; // BUG: Currently returns -32603 (INTERNAL_ERROR) instead of -32602 (INVALID_PARAMS) assertThat(acpError.getCode()).as("Error code should be INVALID_PARAMS, not wrapped in INTERNAL_ERROR") .isEqualTo(AcpErrorCodes.INVALID_PARAMS); @@ -124,15 +125,15 @@ void methodNotFoundErrorCodeIsPreserved() throws Exception { // Start agent and initialize client agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt - should receive METHOD_NOT_FOUND error assertThatThrownBy(() -> { client.prompt(new AcpSchema.PromptRequest("session-123", List.of(new AcpSchema.TextContent("test")))) .block(TIMEOUT); - }).isInstanceOf(AcpClientSession.AcpError.class).satisfies(ex -> { - AcpClientSession.AcpError acpError = (AcpClientSession.AcpError) ex; + }).isInstanceOf(AcpError.class).satisfies(ex -> { + AcpError acpError = (AcpError) ex; assertThat(acpError.getCode()).as("Error code should be METHOD_NOT_FOUND, not wrapped in INTERNAL_ERROR") .isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND); }); @@ -143,22 +144,22 @@ void methodNotFoundErrorCodeIsPreserved() throws Exception { } /** - * Test that ACP-specific error codes (like CONCURRENT_PROMPT) are preserved. + * Test that ACP-specific error codes (like AUTHENTICATION_REQUIRED) are preserved. */ @Test void acpSpecificErrorCodeIsPreserved() throws Exception { transportPair = InMemoryTransportPair.create(); - // Build agent that throws CAPABILITY_NOT_SUPPORTED error + // Build agent that throws AUTHENTICATION_REQUIRED error AcpAsyncAgent agent = AcpAgent.async(transportPair.agentTransport()) .requestTimeout(TIMEOUT) .initializeHandler(request -> Mono .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("session-123", null, null))) .promptHandler((request, updater) -> { - // Throw CAPABILITY_NOT_SUPPORTED error + // Throw AUTHENTICATION_REQUIRED error return Mono.error( - new AcpProtocolException(AcpErrorCodes.CAPABILITY_NOT_SUPPORTED, "Terminal not supported")); + new AcpProtocolException(AcpErrorCodes.AUTHENTICATION_REQUIRED, "Log in first")); }) .build(); @@ -168,18 +169,18 @@ void acpSpecificErrorCodeIsPreserved() throws Exception { // Start agent and initialize client agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - // Send prompt - should receive CAPABILITY_NOT_SUPPORTED error + // Send prompt - should receive AUTHENTICATION_REQUIRED error assertThatThrownBy(() -> { client.prompt(new AcpSchema.PromptRequest("session-123", List.of(new AcpSchema.TextContent("test")))) .block(TIMEOUT); - }).isInstanceOf(AcpClientSession.AcpError.class).satisfies(ex -> { - AcpClientSession.AcpError acpError = (AcpClientSession.AcpError) ex; + }).isInstanceOf(AcpError.class).satisfies(ex -> { + AcpError acpError = (AcpError) ex; assertThat(acpError.getCode()) - .as("Error code should be CAPABILITY_NOT_SUPPORTED, not wrapped in INTERNAL_ERROR") - .isEqualTo(AcpErrorCodes.CAPABILITY_NOT_SUPPORTED); + .as("Error code should be AUTHENTICATION_REQUIRED, not wrapped in INTERNAL_ERROR") + .isEqualTo(AcpErrorCodes.AUTHENTICATION_REQUIRED); }); // Cleanup @@ -196,7 +197,7 @@ void clientHandlerErrorCodeIsPreserved() throws Exception { transportPair = InMemoryTransportPair.create(); AtomicReference agentRef = new AtomicReference<>(); - AtomicReference receivedError = new AtomicReference<>(); + AtomicReference receivedError = new AtomicReference<>(); CountDownLatch errorLatch = new CountDownLatch(1); // Build agent that will request a file read @@ -210,7 +211,7 @@ void clientHandlerErrorCodeIsPreserved() throws Exception { return agentRef.get() .readTextFile(new AcpSchema.ReadTextFileRequest("session-123", "/nonexistent.txt", null, null)) .onErrorResume(error -> { - if (error instanceof AcpAgentSession.AcpError acpError) { + if (error instanceof AcpError acpError) { receivedError.set(acpError); errorLatch.countDown(); } @@ -222,7 +223,10 @@ void clientHandlerErrorCodeIsPreserved() throws Exception { agentRef.set(agent); // Build client that throws INVALID_PARAMS for file reads + AcpSchema.FileSystemCapability fsCaps = new AcpSchema.FileSystemCapability(true, false); + AcpSchema.ClientCapabilities clientCaps = new AcpSchema.ClientCapabilities(fsCaps, false); AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .clientCapabilities(clientCaps) .requestTimeout(TIMEOUT) .readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> { // Throw INVALID_PARAMS to simulate validation error @@ -234,9 +238,7 @@ void clientHandlerErrorCodeIsPreserved() throws Exception { // Start agent and initialize agent.start().subscribe(); Thread.sleep(100); - AcpSchema.FileSystemCapability fsCaps = new AcpSchema.FileSystemCapability(true, false); - AcpSchema.ClientCapabilities clientCaps = new AcpSchema.ClientCapabilities(fsCaps, false); - client.initialize(new AcpSchema.InitializeRequest(1, clientCaps)).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt which triggers file read @@ -281,15 +283,15 @@ void unexpectedExceptionGetsWrappedInInternalError() throws Exception { // Start agent and initialize client agent.start().subscribe(); Thread.sleep(100); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); // Send prompt - should receive INTERNAL_ERROR (this is correct behavior) assertThatThrownBy(() -> { client.prompt(new AcpSchema.PromptRequest("session-123", List.of(new AcpSchema.TextContent("test")))) .block(TIMEOUT); - }).isInstanceOf(AcpClientSession.AcpError.class).satisfies(ex -> { - AcpClientSession.AcpError acpError = (AcpClientSession.AcpError) ex; + }).isInstanceOf(AcpError.class).satisfies(ex -> { + AcpError acpError = (AcpError) ex; // This SHOULD be INTERNAL_ERROR - unexpected exceptions should be wrapped assertThat(acpError.getCode()).as("Unexpected exceptions should be wrapped in INTERNAL_ERROR") .isEqualTo(AcpErrorCodes.INTERNAL_ERROR); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsFromHandlersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsFromHandlersTest.java new file mode 100644 index 00000000..637e2647 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsFromHandlersTest.java @@ -0,0 +1,249 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Extension methods sent from inside the agent's handlers: a prompt handler reaches the client's + * extension handlers through its context's {@code client()}, and an extension handler that takes + * the agent as a second parameter can call the client back. + */ +class ExtensionMethodsFromHandlersTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final TypeRef PING = new TypeRef<>() { + }; + + private static final TypeRef PONG = new TypeRef<>() { + }; + + record Ping(String text) { + } + + record Pong(String text, int length) { + } + + private InMemoryTransportPair pair; + + private final CompletableFuture typedNote = new CompletableFuture<>(); + + private final CompletableFuture rawNote = new CompletableFuture<>(); + + @BeforeEach + void setUp() { + pair = InMemoryTransportPair.create(); + } + + @AfterEach + void tearDown() { + pair.closeGracefully().block(TIMEOUT); + } + + /** A client that answers {@code _test/ping} and {@code _test/raw} and records two notifications. */ + private AcpAsyncClient client() { + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .extRequestHandler("_test/ping", PING, + ping -> Mono.just(new Pong("client: " + ping.text(), ping.text().length()))) + .extRequestHandler("_test/raw", params -> Mono.just(Map.of("client", params))) + .extNotificationHandler("_test/typed", PING, ping -> Mono.fromRunnable(() -> typedNote.complete(ping))) + .extNotificationHandler("_test/rawnote", params -> Mono.fromRunnable(() -> rawNote.complete(params))) + .build(); + client.initialize().block(TIMEOUT); + return client; + } + + private static AcpSchema.PromptRequest prompt() { + return new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("go"))); + } + + private void assertNotesArrived() throws Exception { + assertThat(typedNote.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("note")); + assertThat(rawNote.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("k", 1)); + } + + // --------------------------------------------------------------------------- + // From a prompt handler, through context.client() + // --------------------------------------------------------------------------- + + @Test + void asyncPromptHandlerSendsExtRequestsAndNotificationsThroughItsContext() throws Exception { + AtomicReference typed = new AtomicReference<>(); + AtomicReference raw = new AtomicReference<>(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> context.client() + .sendExtRequest("_test/ping", new Ping("hello"), PONG) + .doOnNext(typed::set) + .then(context.client().sendExtRequest("_test/raw", Map.of("q", 1)).doOnNext(raw::set)) + .then(context.client().sendExtNotification("_test/typed", new Ping("note"))) + .then(context.client().sendExtNotification("_test/rawnote", Map.of("k", 1))) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = client(); + try { + client.prompt(prompt()).block(TIMEOUT); + + assertThat(typed.get()).isEqualTo(new Pong("client: hello", 5)); + assertThat(raw.get()).isEqualTo(Map.of("client", Map.of("q", 1))); + assertNotesArrived(); + } + finally { + agent.close(); + } + } + + @Test + void syncPromptHandlerSendsExtRequestsAndNotificationsThroughItsContext() throws Exception { + AtomicReference typed = new AtomicReference<>(); + AtomicReference raw = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()).promptHandler((request, context) -> { + typed.set(context.client().sendExtRequest("_test/ping", new Ping("hi"), PONG)); + raw.set(context.client().sendExtRequest("_test/raw", List.of("x"))); + context.client().sendExtNotification("_test/typed", new Ping("note")); + context.client().sendExtNotification("_test/rawnote", Map.of("k", 1)); + return AcpSchema.PromptResponse.endTurn(); + }).build(); + agent.start(); + AcpAsyncClient client = client(); + try { + client.prompt(prompt()).block(TIMEOUT); + + assertThat(typed.get()).isEqualTo(new Pong("client: hi", 2)); + assertThat(raw.get()).isEqualTo(Map.of("client", List.of("x"))); + assertNotesArrived(); + } + finally { + agent.close(); + } + } + + // --------------------------------------------------------------------------- + // Agent-aware extension handlers + // --------------------------------------------------------------------------- + + @Test + void asyncAgentAwareExtHandlersReceiveTheBuiltAgentAndCallTheClientBack() throws Exception { + List received = new CopyOnWriteArrayList<>(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .extRequestHandler("_agent/ping", PING, (ping, self) -> { + received.add(self); + return self.sendExtRequest("_test/ping", ping, PONG); + }) + .extRequestHandler("_agent/raw", (params, self) -> { + received.add(self); + return self.sendExtRequest("_test/raw", params); + }) + .extNotificationHandler("_agent/typed", PING, (ping, self) -> { + received.add(self); + return self.sendExtNotification("_test/typed", ping); + }) + .extNotificationHandler("_agent/rawnote", (params, self) -> { + received.add(self); + return self.sendExtNotification("_test/rawnote", params); + }) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = client(); + try { + assertThat(client.sendExtRequest("_agent/ping", new Ping("abc"), PONG).block(TIMEOUT)) + .isEqualTo(new Pong("client: abc", 3)); + assertThat(client.sendExtRequest("_agent/raw", Map.of("n", 2)).block(TIMEOUT)) + .isEqualTo(Map.of("client", Map.of("n", 2))); + client.sendExtNotification("_agent/typed", new Ping("note")).block(TIMEOUT); + client.sendExtNotification("_agent/rawnote", Map.of("k", 1)).block(TIMEOUT); + + assertNotesArrived(); + assertThat(received).hasSize(4).allSatisfy(self -> assertThat(self).isSameAs(agent)); + } + finally { + agent.close(); + } + } + + @Test + void syncAgentAwareExtHandlersReceiveTheBuiltAgentAndCallTheClientBack() throws Exception { + List received = new CopyOnWriteArrayList<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .extRequestHandler("_agent/ping", PING, (ping, self) -> { + received.add(self); + return self.sendExtRequest("_test/ping", ping, PONG); + }) + .extRequestHandler("_agent/raw", (params, self) -> { + received.add(self); + return self.sendExtRequest("_test/raw", params); + }) + .extNotificationHandler("_agent/typed", PING, (ping, self) -> { + received.add(self); + self.sendExtNotification("_test/typed", ping); + }) + .extNotificationHandler("_agent/rawnote", (params, self) -> { + received.add(self); + self.sendExtNotification("_test/rawnote", params); + }) + .build(); + agent.start(); + AcpAsyncClient client = client(); + try { + assertThat(client.sendExtRequest("_agent/ping", new Ping("sync"), PONG).block(TIMEOUT)) + .isEqualTo(new Pong("client: sync", 4)); + assertThat(client.sendExtRequest("_agent/raw", List.of(1)).block(TIMEOUT)) + .isEqualTo(Map.of("client", List.of(1))); + client.sendExtNotification("_agent/typed", new Ping("note")).block(TIMEOUT); + client.sendExtNotification("_agent/rawnote", Map.of("k", 1)).block(TIMEOUT); + + assertNotesArrived(); + assertThat(received).hasSize(4).allSatisfy(self -> assertThat(self).isSameAs(agent)); + } + finally { + agent.close(); + } + } + + @Test + void agentAwareExtSettersRejectNamesWithoutUnderscore() { + AcpAgent.AsyncAgentBuilder agent = AcpAgent.async(pair.agentTransport()); + AcpAgent.SyncAgentBuilder syncAgent = AcpAgent.sync(pair.agentTransport()); + + assertThatThrownBy(() -> agent.extRequestHandler("session/prompt", (params, self) -> Mono.just(Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("session/prompt"); + assertThatThrownBy(() -> agent.extNotificationHandler("x", PING, (ping, self) -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.extNotificationHandler("x", (params, self) -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extRequestHandler("x", PING, (ping, self) -> ping)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extNotificationHandler("x", (params, self) -> { + })).isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsTest.java new file mode 100644 index 00000000..756ec480 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/ExtensionMethodsTest.java @@ -0,0 +1,369 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Extension methods ({@code _}-prefixed, ACP v1 Extensibility) in both directions: each + * side registers handlers for custom requests and notifications and sends them to its + * peer, with a typed or a raw result. + */ +class ExtensionMethodsTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final TypeRef PING = new TypeRef<>() { + }; + + private static final TypeRef PONG = new TypeRef<>() { + }; + + record Ping(String text) { + } + + record Pong(String text, int length) { + } + + private InMemoryTransportPair pair; + + @BeforeEach + void setUp() { + pair = InMemoryTransportPair.create(); + } + + @AfterEach + void tearDown() { + pair.closeGracefully().block(TIMEOUT); + } + + private static Mono pong(Ping ping) { + return Mono.just(new Pong("pong: " + ping.text(), ping.text().length())); + } + + // --------------------------------------------------------------------------- + // Client -> agent + // --------------------------------------------------------------------------- + + @Test + void clientSendsTypedExtRequestToAgent() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .extRequestHandler("_test/ping", PING, ExtensionMethodsTest::pong) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + Pong pong = client.sendExtRequest("_test/ping", new Ping("hi"), PONG).block(TIMEOUT); + + assertThat(pong).isEqualTo(new Pong("pong: hi", 2)); + } + + @Test + void clientSendsRawExtRequestToAgent() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .extRequestHandler("_test/raw", params -> Mono.just(Map.of("echo", params, "list", List.of(1, 2)))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + Object result = client.sendExtRequest("_test/raw", Map.of("a", "b")).block(TIMEOUT); + + assertThat(result).isEqualTo(Map.of("echo", Map.of("a", "b"), "list", List.of(1, 2))); + } + + @Test + void clientSendsExtNotificationToAgent() throws Exception { + CompletableFuture typed = new CompletableFuture<>(); + CompletableFuture raw = new CompletableFuture<>(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .extNotificationHandler("_test/typed", PING, ping -> Mono.fromRunnable(() -> typed.complete(ping))) + .extNotificationHandler("_test/raw", params -> Mono.fromRunnable(() -> raw.complete(params))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + client.sendExtNotification("_test/typed", new Ping("t")).block(TIMEOUT); + client.sendExtNotification("_test/raw", Map.of("k", 1)).block(TIMEOUT); + + assertThat(typed.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("t")); + assertThat(raw.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("k", 1)); + } + + @Test + void syncClientAndSyncAgentExchangeExtMessages() throws Exception { + CompletableFuture notified = new CompletableFuture<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .extRequestHandler("_test/ping", PING, ping -> new Pong("sync: " + ping.text(), 0)) + .extRequestHandler("_test/raw", params -> Map.of("got", params)) + .extNotificationHandler("_test/note", PING, notified::complete) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .build(); + agent.start(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + assertThat(client.sendExtRequest("_test/ping", new Ping("x"), PONG)).isEqualTo(new Pong("sync: x", 0)); + assertThat(client.sendExtRequest("_test/raw", Map.of("q", true))).isEqualTo(Map.of("got", Map.of("q", true))); + client.sendExtNotification("_test/note", new Ping("n")); + + assertThat(notified.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("n")); + } + + @Test + void syncAgentRawNotificationHandlerReceivesParams() throws Exception { + CompletableFuture notified = new CompletableFuture<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .extNotificationHandler("_test/note", notified::complete) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .build(); + agent.start(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + client.sendExtNotification("_test/note", Map.of("v", "w")); + + assertThat(notified.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("v", "w")); + } + + // --------------------------------------------------------------------------- + // Agent -> client + // --------------------------------------------------------------------------- + + @Test + void agentSendsTypedAndRawExtRequestsToClient() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + agent.start().block(TIMEOUT); + AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .extRequestHandler("_test/ping", PING, ExtensionMethodsTest::pong) + .extRequestHandler("_test/raw", params -> Mono.just(List.of("a", params))) + .build(); + + assertThat(agent.sendExtRequest("_test/ping", new Ping("abc"), PONG).block(TIMEOUT)) + .isEqualTo(new Pong("pong: abc", 3)); + assertThat(agent.sendExtRequest("_test/raw", Map.of("n", 1)).block(TIMEOUT)) + .isEqualTo(List.of("a", Map.of("n", 1))); + } + + @Test + void agentSendsExtNotificationsToClient() throws Exception { + CompletableFuture typed = new CompletableFuture<>(); + CompletableFuture raw = new CompletableFuture<>(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + agent.start().block(TIMEOUT); + AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .extNotificationHandler("_test/typed", PING, ping -> Mono.fromRunnable(() -> typed.complete(ping))) + .extNotificationHandler("_test/raw", params -> Mono.fromRunnable(() -> raw.complete(params))) + .build(); + + agent.sendExtNotification("_test/typed", new Ping("t")).block(TIMEOUT); + agent.sendExtNotification("_test/raw", Map.of("k", "v")).block(TIMEOUT); + + assertThat(typed.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("t")); + assertThat(raw.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("k", "v")); + } + + @Test + void syncAgentSendsToSyncClientHandlers() throws Exception { + CompletableFuture typed = new CompletableFuture<>(); + CompletableFuture raw = new CompletableFuture<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()).promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()).build(); + agent.start(); + AcpClient.sync(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .extRequestHandler("_test/ping", PING, ping -> new Pong(ping.text(), 1)) + .extRequestHandler("_test/raw", params -> Map.of("raw", params)) + .extNotificationHandler("_test/typed", PING, typed::complete) + .extNotificationHandler("_test/raw", raw::complete) + .build(); + + assertThat(agent.sendExtRequest("_test/ping", new Ping("s"), PONG)).isEqualTo(new Pong("s", 1)); + assertThat(agent.sendExtRequest("_test/raw", Map.of("x", 2))).isEqualTo(Map.of("raw", Map.of("x", 2))); + agent.sendExtNotification("_test/typed", new Ping("y")); + agent.sendExtNotification("_test/raw", Map.of("z", 3)); + + assertThat(typed.get(5, TimeUnit.SECONDS)).isEqualTo(new Ping("y")); + assertThat(raw.get(5, TimeUnit.SECONDS)).isEqualTo(Map.of("z", 3)); + } + + // --------------------------------------------------------------------------- + // Unhandled extension methods (ACP v1 Extensibility) + // --------------------------------------------------------------------------- + + @Test + void unhandledExtRequestIsMethodNotFoundAndUnhandledNotificationIsIgnored() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .extRequestHandler("_test/ping", PING, ExtensionMethodsTest::pong) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + assertThatThrownBy(() -> client.sendExtRequest("_test/unknown", Map.of()).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND)); + client.sendExtNotification("_test/unknown", Map.of()).block(TIMEOUT); + + // The connection survives both. + assertThat(client.sendExtRequest("_test/ping", new Ping("ok"), PONG).block(TIMEOUT)) + .isEqualTo(new Pong("pong: ok", 2)); + } + + @Test + void clientAnswersUnhandledExtRequestWithMethodNotFound() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + agent.start().block(TIMEOUT); + AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + assertThatThrownBy(() -> agent.sendExtRequest("_test/unknown", Map.of()).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND)); + agent.sendExtNotification("_test/unknown", Map.of()).block(TIMEOUT); + } + + @Test + void extHandlerThatProducesNoResultIsAnsweredWithAnError() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .extRequestHandler("_test/empty", params -> Mono.empty()) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + assertThatThrownBy(() -> client.sendExtRequest("_test/empty", Map.of()).block(TIMEOUT)) + .isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR)); + } + + // --------------------------------------------------------------------------- + // Names must start with an underscore + // --------------------------------------------------------------------------- + + @Test + void buildersRejectNamesWithoutUnderscore() { + AcpAgent.AsyncAgentBuilder agent = AcpAgent.async(pair.agentTransport()); + AcpAgent.SyncAgentBuilder syncAgent = AcpAgent.sync(pair.agentTransport()); + AcpClient.AsyncSpec client = AcpClient.async(pair.clientTransport()); + AcpClient.SyncSpec syncClient = AcpClient.sync(pair.clientTransport()); + + assertThatThrownBy(() -> agent.extRequestHandler("session/prompt", params -> Mono.just(Map.of()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("session/prompt"); + assertThatThrownBy(() -> agent.extRequestHandler("x", PING, ExtensionMethodsTest::pong)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.extNotificationHandler("session/cancel", params -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.extNotificationHandler("x", PING, ping -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extRequestHandler("x", params -> Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extRequestHandler("x", PING, ping -> ping)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extNotificationHandler("x", params -> { + })).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.extNotificationHandler("x", PING, ping -> { + })).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.extRequestHandler("fs/read_text_file", params -> Mono.just(Map.of()))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.extRequestHandler("x", PING, ExtensionMethodsTest::pong)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.extNotificationHandler("session/update", params -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.extNotificationHandler("x", PING, ping -> Mono.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.extRequestHandler("x", params -> Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.extRequestHandler("x", PING, ping -> ping)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.extNotificationHandler("x", params -> { + })).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.extNotificationHandler("x", PING, ping -> { + })).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.extRequestHandler("", params -> Mono.just(Map.of()))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void sendersRejectNamesWithoutUnderscore() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + AcpSyncAgent syncAgent = new AcpSyncAgent(agent); + AcpSyncClient syncClient = new AcpSyncClient(client); + + assertThatThrownBy(() -> client.sendExtRequest("session/prompt", Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("session/prompt"); + assertThatThrownBy(() -> client.sendExtRequest("initialize", Map.of(), PONG)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> client.sendExtNotification("session/cancel", Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.sendExtRequest("fs/read_text_file", Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.sendExtRequest("x", Map.of(), PONG)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> agent.sendExtNotification("session/update", Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.sendExtRequest("x", Map.of())).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncAgent.sendExtNotification("x", Map.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.sendExtRequest("x", Map.of(), PONG)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> syncClient.sendExtNotification("x", Map.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void agentNotStartedFailsExtSends() { + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build(); + + assertThatThrownBy(() -> agent.sendExtRequest("_test/x", Map.of()).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> agent.sendExtNotification("_test/x", Map.of()).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void extHandlersDoNotNeedInitialization() { + // Extension methods are outside the protocol's lifecycle: the SDK does not gate + // them on initialize, the peer decides. + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .extRequestHandler("_test/ping", PING, ExtensionMethodsTest::pong) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + + assertThat(client.sendExtRequest("_test/ping", new Ping("pre"), PONG).block(TIMEOUT)) + .isEqualTo(new Pong("pong: pre", 3)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerErrorTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerErrorTest.java new file mode 100644 index 00000000..389d6819 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerErrorTest.java @@ -0,0 +1,313 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A handler that throws an {@link Error} rather than an exception: code compiled against an + * older SDK hits {@link NoSuchMethodError}, a failed {@code assert} throws + * {@link AssertionError}. Reactor rethrows a {@link LinkageError} instead of signalling it, + * so the request was never answered and the peer waited out its timeout. Every request is + * answered {@code -32603} naming the method (no stack trace, no payload), the error is logged + * at ERROR with the throwable, and the connection stays usable. + */ +class HandlerErrorTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + /** Shorter than {@link #TIMEOUT}: a request that is never answered times out first. */ + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(4); + + private static final String INBOUND_LOGGER = "com.agentclientprotocol.sdk.spec.InboundMessages"; + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private final List closers = new ArrayList<>(); + + private ListAppender appender; + + private Logger logger; + + @BeforeEach + void captureLogs() { + this.logger = (Logger) LoggerFactory.getLogger(INBOUND_LOGGER); + this.appender = new ListAppender<>(); + this.appender.start(); + this.logger.addAppender(this.appender); + } + + @AfterEach + void tearDown() { + closers.forEach(Runnable::run); + this.logger.detachAppender(this.appender); + this.appender.stop(); + transportPair.closeGracefully().block(TIMEOUT); + } + + // ---------------------------------------------------------------- agent side + + private AcpAsyncClient connectAsyncClient() { + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()).requestTimeout(REQUEST_TIMEOUT).build(); + closers.add(0, () -> client.closeGracefully().block(TIMEOUT)); + client.initialize().block(TIMEOUT); + return client; + } + + private void startSyncAgent(AcpAgent.SyncAgentBuilder builder) { + AcpSyncAgent agent = builder.requestTimeout(REQUEST_TIMEOUT) + .initializeHandler(request -> InitializeResponse.ok()) + .newSessionHandler(request -> new NewSessionResponse("s1", null, null)) + .build(); + closers.add(agent::closeGracefully); + agent.start(); + } + + private void startAsyncAgent(AcpAgent.AsyncAgentBuilder builder) { + AcpAsyncAgent agent = builder.requestTimeout(REQUEST_TIMEOUT) + .initializeHandler(request -> Mono.just(InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new NewSessionResponse("s1", null, null))) + .build(); + closers.add(() -> agent.closeGracefully().block(TIMEOUT)); + agent.start().block(TIMEOUT); + } + + private static PromptRequest prompt() { + return new PromptRequest("s1", List.of(new TextContent("hi"))); + } + + /** The first prompt fails with the error; the second, on the same connection, is answered. */ + private void assertPromptAnsweredInternalErrorThenConnectionServes(AcpAsyncClient client, + Class errorType) { + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + assertInternalError(() -> client.prompt(prompt()).block(TIMEOUT), AcpSchema.METHOD_SESSION_PROMPT); + assertLoggedAtError(AcpSchema.METHOD_SESSION_PROMPT, errorType); + assertThat(client.prompt(prompt()).block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + } + + private static void assertInternalError(Runnable call, String method) { + assertThatThrownBy(call::run).isInstanceOfSatisfying(AcpError.class, error -> { + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).contains(method).doesNotContain("\tat ").doesNotContain("secret"); + assertThat(error.getData()).isNull(); + }); + } + + private void assertLoggedAtError(String method, Class errorType) { + assertThat(appender.list).anySatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains(method); + assertThat(event.getThrowableProxy()).isNotNull(); + assertThat(event.getThrowableProxy().getClassName()).isEqualTo(errorType.getName()); + }); + } + + /** Fails the first call with the error, then answers. */ + private static Supplier failingOnce(Supplier error, Supplier answer) { + AtomicInteger calls = new AtomicInteger(); + return () -> { + if (calls.getAndIncrement() == 0) { + throw error.get(); + } + return answer.get(); + }; + } + + @Test + void syncAgentHandlerThrowingNoSuchMethodErrorIsAnswered() { + Supplier handler = failingOnce(() -> new NoSuchMethodError("secret payload"), + PromptResponse::endTurn); + startSyncAgent(AcpAgent.sync(transportPair.agentTransport()).promptHandler((request, context) -> handler.get())); + + assertPromptAnsweredInternalErrorThenConnectionServes(connectAsyncClient(), NoSuchMethodError.class); + } + + @Test + void syncAgentHandlerThrowingAssertionErrorIsAnswered() { + Supplier handler = failingOnce(() -> new AssertionError("secret payload"), + PromptResponse::endTurn); + startSyncAgent(AcpAgent.sync(transportPair.agentTransport()).promptHandler((request, context) -> handler.get())); + + assertPromptAnsweredInternalErrorThenConnectionServes(connectAsyncClient(), AssertionError.class); + } + + @Test + void asyncAgentHandlerThrowingNoSuchMethodErrorIsAnswered() { + Supplier> handler = failingOnce(() -> new NoSuchMethodError("secret payload"), + () -> Mono.just(PromptResponse.endTurn())); + startAsyncAgent( + AcpAgent.async(transportPair.agentTransport()).promptHandler((request, context) -> handler.get())); + + assertPromptAnsweredInternalErrorThenConnectionServes(connectAsyncClient(), NoSuchMethodError.class); + } + + @Test + void asyncAgentHandlerSignallingAssertionErrorIsAnswered() { + AtomicInteger calls = new AtomicInteger(); + startAsyncAgent(AcpAgent.async(transportPair.agentTransport()) + .promptHandler((request, context) -> calls.getAndIncrement() == 0 + ? Mono.error(new AssertionError("secret payload")) : Mono.just(PromptResponse.endTurn()))); + + assertPromptAnsweredInternalErrorThenConnectionServes(connectAsyncClient(), AssertionError.class); + } + + @Test + void syncAgentNonPromptHandlerThrowingNoSuchMethodErrorIsAnswered() { + AcpSyncAgent agent = AcpAgent.sync(transportPair.agentTransport()) + .requestTimeout(REQUEST_TIMEOUT) + .newSessionHandler(request -> { + throw new NoSuchMethodError("secret payload"); + }) + .listSessionsHandler(request -> new AcpSchema.ListSessionsResponse(List.of())) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()).build(); + closers.add(agent::closeGracefully); + agent.start(); + AcpAsyncClient client = connectAsyncClient(); + + assertInternalError(() -> client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT), + AcpSchema.METHOD_SESSION_NEW); + assertThat(client.listSessions(new AcpSchema.ListSessionsRequest(null)).block(TIMEOUT).sessions()).isEmpty(); + } + + /** + * A {@link VirtualMachineError} is answered too, as far as the JVM still can, and is not + * hidden: it also goes to the uncaught-exception handler of the thread that ran the + * handler. + */ + @Test + void stackOverflowErrorIsAnswered() { + Supplier handler = failingOnce(StackOverflowError::new, PromptResponse::endTurn); + startSyncAgent(AcpAgent.sync(transportPair.agentTransport()).promptHandler((request, context) -> handler.get())); + + assertPromptAnsweredInternalErrorThenConnectionServes(connectAsyncClient(), StackOverflowError.class); + } + + // ---------------------------------------------------------------- client side + + /** + * The agent asks the client for permission; the client's handler fails with the error. + * Returns what the agent's request ended with. + */ + private AtomicReference agentAskingPermission() { + AtomicReference outcome = new AtomicReference<>(); + startAsyncAgent(AcpAgent.async(transportPair.agentTransport()) + .promptHandler((request, context) -> context.askPermission("edit") + .doOnError(outcome::set) + .onErrorReturn(false) + .thenReturn(PromptResponse.endTurn()))); + return outcome; + } + + private void promptAndAssertClientAnsweredInternalError(Supplier prompt, + AtomicReference outcome, Class errorType) { + assertThat(prompt.get().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(outcome.get()).isInstanceOfSatisfying(AcpError.class, error -> { + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getMessage()).contains(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION) + .doesNotContain("secret"); + }); + assertLoggedAtError(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, errorType); + // The connection still works + outcome.set(null); + assertThat(prompt.get().stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + } + + @Test + void syncClientHandlerThrowingNoSuchMethodErrorIsAnswered() { + AtomicReference outcome = agentAskingPermission(); + AtomicInteger calls = new AtomicInteger(); + AcpSyncClient client = AcpClient.sync(transportPair.clientTransport()) + .requestTimeout(REQUEST_TIMEOUT) + .requestPermissionHandler(request -> { + if (calls.getAndIncrement() == 0) { + throw new NoSuchMethodError("secret payload"); + } + return new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow")); + }) + .build(); + closers.add(0, client::closeGracefully); + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + promptAndAssertClientAnsweredInternalError(() -> client.prompt(prompt()), outcome, NoSuchMethodError.class); + } + + @Test + void asyncClientHandlerThrowingAssertionErrorIsAnswered() { + AtomicReference outcome = agentAskingPermission(); + AtomicInteger calls = new AtomicInteger(); + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(REQUEST_TIMEOUT) + .requestPermissionHandler(request -> { + if (calls.getAndIncrement() == 0) { + throw new AssertionError("secret payload"); + } + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))); + }) + .build(); + closers.add(0, () -> client.closeGracefully().block(TIMEOUT)); + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + promptAndAssertClientAnsweredInternalError(() -> client.prompt(prompt()).block(TIMEOUT), outcome, + AssertionError.class); + } + + @Test + void asyncClientHandlerThrowingNoSuchMethodErrorIsAnswered() { + AtomicReference outcome = agentAskingPermission(); + AtomicInteger calls = new AtomicInteger(); + AcpAsyncClient client = AcpClient.async(transportPair.clientTransport()) + .requestTimeout(REQUEST_TIMEOUT) + .requestPermissionHandler(request -> { + if (calls.getAndIncrement() == 0) { + throw new NoSuchMethodError("secret payload"); + } + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow"))); + }) + .build(); + closers.add(0, () -> client.closeGracefully().block(TIMEOUT)); + client.initialize().block(TIMEOUT); + client.newSession(new NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + + promptAndAssertClientAnsweredInternalError(() -> client.prompt(prompt()).block(TIMEOUT), outcome, + NoSuchMethodError.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerExecutorTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerExecutorTest.java new file mode 100644 index 00000000..8e0193c6 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerExecutorTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The sync builders run their handlers on an executor the application chooses (virtual threads, a + * framework's worker pool), instead of the SDK's static unbounded pools; without one they keep + * using the SDK's default pool. The default pools are no longer public constants of the entry + * point interfaces. + */ +class HandlerExecutorTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static ExecutorService named(String name) { + return Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, name); + t.setDaemon(true); + return t; + }); + } + + @Test + void syncAgentAndClientHandlersRunOnTheGivenExecutors() { + ExecutorService agentPool = named("app-agent-pool"); + ExecutorService clientPool = named("app-client-pool"); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AtomicReference promptThread = new AtomicReference<>(); + AtomicReference updateThread = new AtomicReference<>(); + AtomicReference extThread = new AtomicReference<>(); + AtomicReference self = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .handlerExecutor(agentPool) + .initializeHandler(req -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(req -> new AcpSchema.NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + promptThread.set(Thread.currentThread().getName()); + context.sendMessage("hi"); + self.get().sendExtRequest("_x/where", Map.of()); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .handlerExecutor(clientPool) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(n -> updateThread.set(Thread.currentThread().getName())) + .extRequestHandler("_x/where", params -> { + extThread.set(Thread.currentThread().getName()); + return Map.of(); + }) + .build(); + self.set(agent); + try { + agent.start(); + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())); + client.prompt(new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("go")))); + + assertThat(promptThread.get()).isEqualTo("app-agent-pool"); + assertThat(updateThread.get()).isEqualTo("app-client-pool"); + assertThat(extThread.get()).isEqualTo("app-client-pool"); + } + finally { + client.close(); + agent.close(); + pair.closeGracefully().block(TIMEOUT); + agentPool.shutdownNow(); + clientPool.shutdownNow(); + } + } + + /** + * Without a handler executor, handlers run on a virtual thread per call on JDK 21 and later, + * and on the SDK's pool of daemon platform threads before. + */ + @Test + void withoutAnExecutorHandlersRunOnVirtualThreadsWhereTheJdkHasThem() { + boolean jdk21 = Runtime.version().feature() >= 21; + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AtomicReference promptThread = new AtomicReference<>(); + AtomicReference updateThread = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .initializeHandler(req -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(req -> new AcpSchema.NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + promptThread.set(Thread.currentThread()); + context.sendMessage("hi"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(n -> updateThread.compareAndSet(null, Thread.currentThread())) + .build(); + try { + agent.start(); + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())); + client.prompt(new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("go")))); + + assertThat(VirtualThreads.isVirtual(promptThread.get())).as("agent handler: %s", promptThread.get()) + .isEqualTo(jdk21); + assertThat(promptThread.get().getName()).isEqualTo("acp-agent-sync-handler"); + assertThat(VirtualThreads.isVirtual(updateThread.get())).as("client handler: %s", updateThread.get()) + .isEqualTo(jdk21); + assertThat(updateThread.get().getName()).isEqualTo("acp-sync-handler"); + } + finally { + client.close(); + agent.close(); + pair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void theDefaultPoolsAreNotPublicConstants() { + for (Class type : List.of(AcpAgent.class, AcpClient.class)) { + for (Field field : type.getDeclaredFields()) { + assertThat(field.getType().getName()).as(type.getSimpleName() + "." + field.getName()) + .isNotEqualTo("reactor.core.scheduler.Scheduler"); + assertThat(Modifier.isPublic(field.getModifiers()) && field.getName().startsWith("DEFAULT_")) + .as(type.getSimpleName() + "." + field.getName()) + .isFalse(); + } + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerFailureAnswerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerFailureAnswerTest.java new file mode 100644 index 00000000..0b8a3adc --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/HandlerFailureAnswerTest.java @@ -0,0 +1,193 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * What the peer receives when a request handler fails, on both sides: an + * {@link AcpProtocolException} is the handler's intended answer and is sent as it is; an + * {@link AcpError} from a request the handler made passes the peer's error on; anything else + * is {@code -32603} with a generic message, its detail only in the handling side's log. + */ +class HandlerFailureAnswerTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SECRET = "jdbc:postgresql://db/prod?password=hunter2"; + + private final InMemoryTransportPair transportPair = InMemoryTransportPair.create(); + + private final AtomicReference agentRef = new AtomicReference<>(); + + private AcpAsyncClient client; + + private ListAppender appender; + + private Logger logger; + + @BeforeEach + void captureLogs() { + this.logger = (Logger) LoggerFactory.getLogger("com.agentclientprotocol.sdk.spec"); + this.appender = new ListAppender<>(); + this.appender.start(); + this.logger.addAppender(this.appender); + this.logger.setLevel(Level.DEBUG); + } + + @AfterEach + void tearDown() { + this.logger.detachAppender(this.appender); + this.logger.setLevel(null); + this.appender.stop(); + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (agentRef.get() != null) { + agentRef.get().closeGracefully().block(TIMEOUT); + } + } + + private void connect(AcpAgent.AsyncAgentBuilder agentBuilder, AcpClient.AsyncSpec clientSpec) { + AcpAsyncAgent agent = agentBuilder.requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + agentRef.set(agent); + agent.start().block(TIMEOUT); + client = clientSpec.requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + } + + private AcpAgent.AsyncAgentBuilder agent() { + return AcpAgent.async(transportPair.agentTransport()); + } + + private AcpClient.AsyncSpec clientSpec() { + return AcpClient.async(transportPair.clientTransport()); + } + + private static AcpError failure(Runnable call) { + AtomicReference thrown = new AtomicReference<>(); + try { + call.run(); + } + catch (Throwable t) { + thrown.set(t); + } + assertThat(thrown.get()).isInstanceOf(AcpError.class); + return (AcpError) thrown.get(); + } + + @Test + void anAgentHandlerExceptionIsAnsweredWithAGenericInternalErrorAndLoggedAtTheAgent() { + connect(agent().extRequestHandler("_x/plain", + params -> Mono.error(new IllegalStateException("cannot connect to " + SECRET))), clientSpec()); + + AcpError error = failure(() -> client.sendExtRequest("_x/plain", Map.of()).block(TIMEOUT)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getError().message()).isEqualTo("Internal error"); + assertThat(error.getData()).isNull(); + assertThat(error.toString()).doesNotContain("hunter2"); + assertThat(this.appender.list).anySatisfy(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("_x/plain"); + assertThat(event.getThrowableProxy().getMessage()).contains("hunter2"); + }); + } + + @Test + void aClientHandlerExceptionIsAnsweredWithAGenericInternalError() { + connect(agent(), clientSpec().extRequestHandler("_client/plain", params -> { + throw new IllegalArgumentException("no file " + SECRET); + })); + + AcpError error = failure(() -> agentRef.get().sendExtRequest("_client/plain", Map.of()).block(TIMEOUT)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + assertThat(error.getError().message()).isEqualTo("Internal error"); + assertThat(error.toString()).doesNotContain("hunter2"); + } + + @Test + void anAcpProtocolExceptionKeepsItsCodeMessageAndData() { + connect(agent().extRequestHandler("_x/protocol", + params -> Mono.error(new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND, "No such thing", + Map.of("id", 7)))), + clientSpec()); + + AcpError error = failure(() -> client.sendExtRequest("_x/protocol", Map.of()).block(TIMEOUT)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.RESOURCE_NOT_FOUND); + assertThat(error.getError().message()).isEqualTo("No such thing"); + assertThat(error.getData()).isEqualTo(Map.of("id", 7)); + } + + @Test + void aPeerErrorThatEscapesAHandlerPassesThePeersCodeMessageAndDataOn() { + // The agent's handler asks the client for something the client does not serve, and + // lets the client's -32601 escape. + connect(agent().extRequestHandler("_x/proxy", + params -> agentRef.get().sendExtRequest("_client/missing", Map.of())), clientSpec()); + + AcpError error = failure(() -> client.sendExtRequest("_x/proxy", Map.of()).block(TIMEOUT)); + + assertThat(error.getCode()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND); + assertThat(error.getError().message()).contains("_client/missing"); + } + + @Test + void aPeerErrorWithDataThatEscapesAHandlerKeepsItsData() { + AcpSchema.JSONRPCError peerError = new AcpSchema.JSONRPCError(AcpErrorCodes.RESOURCE_NOT_FOUND, + "Resource not found", Map.of("uri", "file:///missing.txt")); + connect(agent().extRequestHandler("_x/proxy", params -> Mono.error(new AcpError(peerError))), clientSpec()); + + AcpError error = failure(() -> client.sendExtRequest("_x/proxy", Map.of()).block(TIMEOUT)); + + assertThat(error.getError()).isEqualTo(peerError); + } + + @Test + void anUnknownExtensionNotificationIsIgnoredWithoutAWarning() { + connect(agent(), clientSpec()); + + client.sendExtNotification("_x/unknown", Map.of()).block(TIMEOUT); + // A request after it: the notification has been read by the time its answer arrives. + assertThatThrownBy(() -> client.sendExtRequest("_x/after", Map.of()).block(TIMEOUT)) + .isInstanceOf(AcpError.class); + + assertThat(this.appender.list).filteredOn(event -> event.getFormattedMessage().contains("_x/unknown")) + .isNotEmpty() + .allSatisfy(event -> assertThat(event.getLevel()).isEqualTo(Level.DEBUG)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/MultiPromptSyncSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/MultiPromptSyncSessionTest.java index 3dfa86ba..b9eb98f1 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/MultiPromptSyncSessionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/MultiPromptSyncSessionTest.java @@ -63,8 +63,7 @@ void threeSequentialStreamingPromptsOnOneSessionAllReturn() { .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-14", null, null)) .promptHandler((request, context) -> { for (int chunk = 0; chunk < 3; chunk++) { - context.sendUpdate(request.sessionId(), - new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent("chunk " + chunk))); + context.sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("chunk " + chunk))); } return new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN); }) @@ -74,7 +73,7 @@ void threeSequentialStreamingPromptsOnOneSessionAllReturn() { List updates = new CopyOnWriteArrayList<>(); AcpSyncClient client = AcpClient.sync(pair.clientTransport()) .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> updates.add(notification.update().getClass().getSimpleName())) + .sessionUpdateHandler(notification -> updates.add(notification.update().getClass().getSimpleName())) .build(); try { client.initialize(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptCancellationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptCancellationTest.java new file mode 100644 index 00000000..8e2a27b2 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptCancellationTest.java @@ -0,0 +1,267 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A prompt turn cancelled end to end, client to agent and back, as ACP v1 describes it + * (prompt turn, Cancellation): after {@code session/cancel} the agent may still send + * {@code session/update}s, which the client still accepts, and then answers the original + * {@code session/prompt} with stop reason {@code cancelled}; only once that turn has + * completed may the client send another prompt. + */ +class PromptCancellationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String SESSION = "session-cancel"; + + @Test + void theTurnEndsWithTheCancelledPromptsResponseNotWithTheCancel() { + Sinks.Empty cancelReceived = Sinks.empty(); + Sinks.Empty finishAborting = Sinks.empty(); + AtomicInteger prompts = new AtomicInteger(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .cancelHandler(notification -> Mono.fromRunnable(cancelReceived::tryEmitEmpty)) + .promptHandler((request, context) -> { + if (prompts.incrementAndGet() > 1) { + return Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + } + // The first prompt runs until cancelled, sends a last update, and answers cancelled. + return cancelReceived.asMono() + .then(finishAborting.asMono()) + .then(context.sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("aborted")))) + .thenReturn(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + }) + .build(); + agent.start().block(TIMEOUT); + + List updates = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.fromRunnable(() -> updates.add( + ((AcpSchema.TextContent) ((AcpSchema.AgentMessageChunk) notification.update()).content()).text()))) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + AcpSchema.PromptRequest prompt = new AcpSchema.PromptRequest(SESSION, + List.of(new AcpSchema.TextContent("work"))); + Mono cancelled = client.prompt(prompt).cache(); + cancelled.subscribe(response -> { + }, error -> { + }); + + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + cancelReceived.asMono().block(TIMEOUT); + + assertThatThrownBy(() -> client.prompt(prompt).block(TIMEOUT)) + .as("a prompt before the cancelled one has answered is rejected") + .isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.INVALID_REQUEST)); + + finishAborting.tryEmitEmpty(); + assertThat(cancelled.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(updates).as("the client accepts updates sent after its cancel").containsExactly("aborted"); + + assertThat(client.prompt(prompt).block(TIMEOUT).stopReason()) + .as("once the cancelled turn has completed, the next prompt is accepted") + .isEqualTo(AcpSchema.StopReason.END_TURN); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + + /** + * A handler that ignores the cancel: once the grace period passes the agent answers the + * prompt {@code cancelled} itself, after the update the handler had already sent. + */ + @Test + void aHandlerThatIgnoresTheCancelIsAnsweredCancelledAfterItsUpdates() { + Sinks.Empty updateSent = Sinks.empty(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(Duration.ofMillis(200)) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> context + .sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("working"))) + .doOnSuccess(v -> updateSent.tryEmitEmpty()) + .then(Mono.never())) + .build(); + agent.start().block(TIMEOUT); + + List received = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.fromRunnable(() -> received.add("update"))) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + AcpSchema.PromptRequest prompt = new AcpSchema.PromptRequest(SESSION, + List.of(new AcpSchema.TextContent("work"))); + Mono ignored = client.prompt(prompt) + .doOnNext(response -> received.add("answer " + response.stopReason())) + .cache(); + ignored.subscribe(response -> { + }, error -> { + }); + updateSent.asMono().block(TIMEOUT); + + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + assertThat(ignored.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(received).containsExactly("update", "answer cancelled"); + assertThatThrownBy(() -> client.prompt(prompt).block(Duration.ofMillis(300))) + .as("the turn has ended: the next prompt is not rejected but runs (forever, in this handler)") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Timeout on blocking read"); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + + /** + * ACP: after {@code session/cancel} the agent may send updates, but "MUST ensure that it does + * so before responding to the session/prompt request". A handler that keeps sending after + * the grace period passed and the agent answered {@code cancelled} has its updates dropped. + */ + @Test + void updatesFromAHandlerStillRunningAfterTheForcedAnswerAreDropped() throws Exception { + Sinks.Empty firstUpdateSent = Sinks.empty(); + java.util.concurrent.atomic.AtomicReference contextRef = new java.util.concurrent.atomic.AtomicReference<>(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(Duration.ofMillis(200)) + .initializeHandler(request -> Mono + .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> { + contextRef.set(context); + return context.sendMessage("before") + .doOnSuccess(v -> firstUpdateSent.tryEmitEmpty()) + .then(Mono.never()); + }) + .build(); + agent.start().block(TIMEOUT); + + java.util.concurrent.atomic.AtomicBoolean answered = new java.util.concurrent.atomic.AtomicBoolean(); + List afterAnswer = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.fromRunnable(() -> { + if (answered.get()) { + afterAnswer.add(notification.update().toString()); + } + })) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + Mono prompt = client.prompt(AcpSchema.PromptRequest.text(SESSION, "work")) + .doOnNext(response -> answered.set(true)) + .cache(); + prompt.subscribe(response -> { + }, error -> { + }); + firstUpdateSent.asMono().block(TIMEOUT); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + assertThat(prompt.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + + // The handler, still running, keeps sending through its prompt context. + PromptContext context = contextRef.get(); + context.sendMessage("late message").block(TIMEOUT); + context.sendSessionUpdate(new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent("late thought"))) + .block(TIMEOUT); + // Anything sent arrives before the answer to a later request on the same connection. + client.sendExtRequest("_x/probe", java.util.Map.of()).onErrorResume(e -> Mono.empty()).block(TIMEOUT); + + assertThat(afterAnswer).isEmpty(); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + } + + /** The same for a sync handler's context, and once the handler answered the prompt itself. */ + @Test + void updatesThroughASyncContextAfterThePromptWasAnsweredAreDropped() throws Exception { + java.util.concurrent.atomic.AtomicReference contextRef = new java.util.concurrent.atomic.AtomicReference<>(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + contextRef.set(context); + context.sendMessage("during"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + agent.start(); + + java.util.concurrent.atomic.AtomicBoolean answered = new java.util.concurrent.atomic.AtomicBoolean(); + List seen = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> Mono.fromRunnable( + () -> seen.add((answered.get() ? "after " : "before ") + notification.update().getClass().getSimpleName()))) + .build(); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + client.prompt(AcpSchema.PromptRequest.text(SESSION, "work")).doOnNext(r -> answered.set(true)).block(TIMEOUT); + + contextRef.get().sendMessage("after the answer"); + client.sendExtRequest("_x/probe", java.util.Map.of()).onErrorResume(e -> Mono.empty()).block(TIMEOUT); + + assertThat(seen).containsExactly("before AgentMessageChunk"); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.close(); + pair.closeGracefully().block(TIMEOUT); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptContextCancellationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptContextCancellationTest.java new file mode 100644 index 00000000..3b00ab3e --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/PromptContextCancellationTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A prompt handler learns that its prompt was cancelled from its context: by + * {@code session/cancel} for its session, or by {@code $/cancel_request} for its request. The + * grace period is long here, so a {@code cancelled} answer that arrives promptly is the + * handler's own (it carries the handler's {@code _meta}). + */ +class PromptContextCancellationTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final Duration LONG_GRACE = Duration.ofSeconds(30); + + private static final String SESSION = "session-signal"; + + private static final Map HANDLERS_OWN = Map.of("answeredBy", "handler"); + + private static final AcpSchema.PromptRequest PROMPT = new AcpSchema.PromptRequest(SESSION, + List.of(new AcpSchema.TextContent("work"))); + + @Test + void aSyncHandlerPollingIsCancelledAnswersCancelledOnSessionCancel() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(LONG_GRACE) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + while (!context.isCancelled()) { + sleep(10); + } + return new PromptResponse(AcpSchema.StopReason.CANCELLED, HANDLERS_OWN); + }) + .build(); + agent.start(); + AcpAsyncClient client = client(pair); + try { + Mono response = startPrompt(client); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + PromptResponse answer = response.block(TIMEOUT); + assertThat(answer.stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(answer.meta()).isEqualTo(HANDLERS_OWN); + } + finally { + close(client, pair); + agent.closeGracefully(); + } + } + + @Test + void anAsyncHandlerAnswersWhenCancelledCompletes() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = AcpAgent.async(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(LONG_GRACE) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse(SESSION, null, null))) + .promptHandler((request, context) -> context.whenCancelled() + .then(Mono.fromSupplier(() -> new PromptResponse(AcpSchema.StopReason.CANCELLED, + context.isCancelled() ? HANDLERS_OWN : Map.of())))) + .build(); + agent.start().block(TIMEOUT); + AcpAsyncClient client = client(pair); + try { + Mono response = startPrompt(client); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + + assertThat(response.block(TIMEOUT).meta()).isEqualTo(HANDLERS_OWN); + } + finally { + close(client, pair); + agent.closeGracefully().block(TIMEOUT); + } + } + + @Test + void aCancelRequestForThePromptSignalsItsHandler() throws Exception { + CountDownLatch running = new CountDownLatch(1); + CountDownLatch sawCancel = new CountDownLatch(1); + CountDownLatch callbackRan = new CountDownLatch(1); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(LONG_GRACE) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + context.onCancel(callbackRan::countDown); + running.countDown(); + while (!context.isCancelled()) { + // A sync handler is interrupted when its request is cancelled: keep polling + Thread.interrupted(); + sleep(10); + } + sawCancel.countDown(); + return PromptResponse.cancelled(); + }) + .build(); + agent.start(); + AcpAsyncClient client = client(pair); + try { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT); + Disposable prompt = client.prompt(PROMPT).subscribe(response -> { + }, error -> { + }); + assertThat(running.await(5, TimeUnit.SECONDS)).isTrue(); + + prompt.dispose(); // sends $/cancel_request for the prompt + + assertThat(sawCancel.await(5, TimeUnit.SECONDS)).as("isCancelled() turned true").isTrue(); + assertThat(callbackRan.await(5, TimeUnit.SECONDS)).as("onCancel callback ran").isTrue(); + } + finally { + close(client, pair); + agent.closeGracefully(); + } + } + + @Test + void anUncancelledPromptIsNotCancelledAndACallbackRegisteredAfterACancelRunsAtOnce() { + AtomicBoolean cancelledAtEnd = new AtomicBoolean(true); + AtomicBoolean lateCallbackRan = new AtomicBoolean(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(LONG_GRACE) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse(SESSION, null, null)) + .promptHandler((request, context) -> { + String text = ((AcpSchema.TextContent) request.prompt().get(0)).text(); + if (text.equals("work")) { + cancelledAtEnd.set(context.isCancelled()); + return PromptResponse.endTurn(); + } + while (!context.isCancelled()) { + sleep(10); + } + context.onCancel(() -> lateCallbackRan.set(true)); + return PromptResponse.cancelled(); + }) + .build(); + agent.start(); + AcpAsyncClient client = client(pair); + try { + assertThat(startPrompt(client).block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(cancelledAtEnd).isFalse(); + + Mono second = client + .prompt(new AcpSchema.PromptRequest(SESSION, List.of(new AcpSchema.TextContent("wait")))) + .cache(); + second.subscribe(response -> { + }, error -> { + }); + sleep(100); + client.cancel(new AcpSchema.CancelNotification(SESSION)).block(TIMEOUT); + assertThat(second.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(lateCallbackRan).isTrue(); + } + finally { + close(client, pair); + agent.closeGracefully(); + } + } + + /** session/close cancels the session's running prompt, which its context reports. */ + @Test + void closingTheSessionSignalsItsPrompt() throws Exception { + CountDownLatch sawCancel = new CountDownLatch(1); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(TIMEOUT) + .cancelGracePeriod(LONG_GRACE) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse(SESSION, null, null)) + .closeSessionHandler(request -> new AcpSchema.CloseSessionResponse(null)) + .promptHandler((request, context) -> { + while (!context.isCancelled()) { + sleep(10); + } + sawCancel.countDown(); + return PromptResponse.cancelled(); + }) + .build(); + agent.start(); + AcpAsyncClient client = client(pair); + try { + Mono response = startPrompt(client); + client.closeSession(new AcpSchema.CloseSessionRequest(SESSION, null)).block(TIMEOUT); + + assertThat(sawCancel.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(response.block(TIMEOUT).stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + } + finally { + close(client, pair); + agent.closeGracefully(); + } + } + + private static AcpAsyncClient client(InMemoryTransportPair pair) { + return AcpClient.async(pair.clientTransport()).requestTimeout(TIMEOUT).build(); + } + + /** Initializes, opens the session, and starts the prompt, returning its cached answer. */ + private static Mono startPrompt(AcpAsyncClient client) { + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/w", List.of())).block(TIMEOUT); + Mono response = client.prompt(PROMPT).cache(); + response.subscribe(r -> { + }, error -> { + }); + sleep(100); + return response; + } + + private static void close(AcpAsyncClient client, InMemoryTransportPair pair) { + client.closeGracefully().block(TIMEOUT); + pair.closeGracefully().block(TIMEOUT); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/SyncFacadeTimeoutTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/SyncFacadeTimeoutTest.java new file mode 100644 index 00000000..b4a46105 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/integration/SyncFacadeTimeoutTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.error.AcpException; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; + +/** + * A request that times out reaches the callers of the sync facades ({@link AcpSyncClient}, + * {@link AcpSyncAgent}, {@code SyncPromptContext}) as an SDK exception whose cause is the + * {@link TimeoutException}, never as Reactor's internal {@code Exceptions$ReactiveException}. + */ +class SyncFacadeTimeoutTest { + + private static final Duration SHORT = Duration.ofMillis(200); + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static void assertSdkTimeout(Throwable thrown) { + assertThat(thrown).isNotNull(); + assertThat(thrown.getClass().getName()).doesNotStartWith("reactor."); + assertThat(thrown).isInstanceOf(AcpException.class).hasCauseInstanceOf(TimeoutException.class); + } + + @Test + void syncClientRequestTimeoutIsAnSdkException() { + MockAcpClientTransport silentAgent = new MockAcpClientTransport(); + AcpSyncClient silent = AcpClient.sync(silentAgent).requestTimeout(SHORT).build(); + try { + assertSdkTimeout(catchThrowable(() -> silent.initialize())); + assertSdkTimeout(catchThrowable(() -> silent.sendExtRequest("_x/ping", Map.of()))); + } + finally { + silent.close(); + } + // An agent that answers initialize, then nothing else + MockAcpClientTransport initializedAgent = new MockAcpClientTransport((t, message) -> { + if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + t.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), + AcpSchema.InitializeResponse.ok(), null)); + } + }); + AcpSyncClient client = AcpClient.sync(initializedAgent).requestTimeout(SHORT).build(); + try { + client.initialize(); + assertSdkTimeout(catchThrowable( + () -> client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())))); + } + finally { + client.close(); + } + } + + @Test + void syncAgentAndPromptContextRequestTimeoutIsAnSdkException() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AtomicReference fromContext = new AtomicReference<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .requestTimeout(SHORT) + .initializeHandler(req -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(req -> new AcpSchema.NewSessionResponse("s1", null, null)) + .promptHandler((request, context) -> { + fromContext.set(catchThrowable(() -> context.readFile("/never"))); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + AcpAsyncClient client = AcpClient.async(pair.clientTransport()) + .requestTimeout(TIMEOUT) + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, false), null)) + .readTextFileHandler(req -> Mono.never()) + .extRequestHandler("_x/ping", params -> Mono.never()) + .build(); + try { + agent.start(); + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + client.prompt(new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("go")))) + .block(TIMEOUT); + + assertSdkTimeout(fromContext.get()); + assertSdkTimeout(catchThrowable(() -> agent.sendExtRequest("_x/ping", Map.of()))); + } + finally { + client.closeGracefully().block(TIMEOUT); + agent.closeGracefully(); + pair.closeGracefully().block(TIMEOUT); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TestJacksonAcpJsonMapperSupplier.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TestJacksonAcpJsonMapperSupplier.java index d3946327..f0b6c2e2 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TestJacksonAcpJsonMapperSupplier.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TestJacksonAcpJsonMapperSupplier.java @@ -23,7 +23,19 @@ public final class TestJacksonAcpJsonMapperSupplier implements AcpJsonMapperSupp @Override public AcpJsonMapper get() { - return new Mapper(JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build()); + // The coercion rules of the shipped mappers (JacksonAcpJsonMapper.defaultObjectMapper). + return new Mapper(JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(com.fasterxml.jackson.databind.MapperFeature.ALLOW_COERCION_OF_SCALARS) + .withCoercionConfig(com.fasterxml.jackson.databind.type.LogicalType.Textual, + config -> config + .setCoercion(com.fasterxml.jackson.databind.cfg.CoercionInputShape.Integer, + com.fasterxml.jackson.databind.cfg.CoercionAction.Fail) + .setCoercion(com.fasterxml.jackson.databind.cfg.CoercionInputShape.Float, + com.fasterxml.jackson.databind.cfg.CoercionAction.Fail) + .setCoercion(com.fasterxml.jackson.databind.cfg.CoercionInputShape.Boolean, + com.fasterxml.jackson.databind.cfg.CoercionAction.Fail)) + .build()); } private record Mapper(ObjectMapper om) implements AcpJsonMapper { diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TypeRefTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TypeRefTest.java index 5d494d8d..c158beef 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TypeRefTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/json/TypeRefTest.java @@ -64,4 +64,29 @@ void twoTypeRefsWithSameTypeAreDistinctButEqual() { assertThat(ref1.getType()).isEqualTo(ref2.getType()); } + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + void rawSubclassIsRejected() { + assertThatThrownBy(() -> new TypeRef() { + }).isInstanceOf(IllegalStateException.class).hasMessageContaining("without actual type information"); + } + + @Test + void ofCarriesARuntimeType() throws Exception { + Type listOfString = TypeRefTest.class.getDeclaredMethod("listOfString").getGenericReturnType(); + + assertThat(TypeRef.of(listOfString).getType()).isEqualTo(listOfString); + assertThat(TypeRef.of(String.class).getType()).isEqualTo(String.class); + } + + @Test + void ofRejectsNull() { + assertThatThrownBy(() -> TypeRef.of(null)).isInstanceOf(IllegalArgumentException.class); + } + + @SuppressWarnings("unused") + private static List listOfString() { + return List.of(); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionLifecycleTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionLifecycleTest.java new file mode 100644 index 00000000..a6db207e --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionLifecycleTest.java @@ -0,0 +1,362 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The agent session's failure, close and cancellation paths: a transport that fails to + * start, closing with requests to the client pending, and the single-turn prompt lock + * across session/cancel, cancellation and the {@code Map} params the JSON transports + * deliver. + */ +class AcpAgentSessionLifecycleTest { + + /** Long enough that a request which is not failed at once times out the assertion first. */ + private static final Duration LONG_TIMEOUT = Duration.ofMinutes(5); + + private static final Duration WAIT = Duration.ofSeconds(2); + + private static final TypeRef READ = new TypeRef<>() { + }; + + /** Hands the session's handler to the test; starts as the test says. */ + static class ControllableAgentTransport implements AcpAgentTransport { + + final AtomicReference, Mono>> handler = new AtomicReference<>(); + + final Sinks.One started = Sinks.one(); + + final List sent = new CopyOnWriteArrayList<>(); + + final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public Mono start(Function, Mono> handler) { + this.handler.set(handler); + return this.started.asMono(); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + this.sent.add(message); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.fromRunnable(() -> this.closed.set(true)); + } + + @Override + public void close() { + this.closed.set(true); + } + + @Override + public Mono awaitTermination() { + return Mono.never(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + throw new UnsupportedOperationException(); + } + + Mono deliver(AcpSchema.JSONRPCMessage message) { + return this.handler.get().apply(Mono.just(message)); + } + + } + + /** A prompt handler that answers only when the test opens the gate. */ + private final Sinks.One gate = Sinks.one(); + + private final ControllableAgentTransport transport = new ControllableAgentTransport(); + + private AcpAgentSession promptSession(Map notificationHandlers) { + AcpAgentSession.RequestHandler gated = params -> this.gate.asMono(); + return new AcpAgentSession(LONG_TIMEOUT, this.transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, gated), + notificationHandlers); + } + + private static AcpSchema.JSONRPCRequest prompt(String id, Object params) { + return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT, params); + } + + private static AcpSchema.JSONRPCNotification cancel(Object params) { + return new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, AcpSchema.METHOD_SESSION_CANCEL, params); + } + + private AcpSchema.JSONRPCResponse answer(AcpSchema.JSONRPCRequest request) { + return (AcpSchema.JSONRPCResponse) this.transport.deliver(request).block(WAIT); + } + + private Mono pendingRead(AcpAgentSession session) { + Mono pending = session + .sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, Map.of("path", "/tmp/a"), READ) + .cache(); + pending.subscribe(v -> { + }, e -> { + }); + return pending; + } + + @Test + void transportThatRefusesToStartFailsConstruction() { + this.transport.started.tryEmitError(new IllegalStateException("already started")); + + assertThatThrownBy(() -> new AcpAgentSession(LONG_TIMEOUT, this.transport, Map.of(), Map.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("ACP agent transport is not started: already started"); + } + + @Test + void transportThatFailsToStartLaterFailsPendingAndLaterRequestsAtOnce() { + AcpAgentSession session = new AcpAgentSession(LONG_TIMEOUT, this.transport, Map.of(), Map.of()); + Mono pending = pendingRead(session); + + IllegalStateException cause = new IllegalStateException("port in use"); + this.transport.started.tryEmitError(cause); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with client terminated").hasCause(cause); + assertThatThrownBy(() -> pendingRead(session).block(WAIT)).isInstanceOf(AcpConnectionException.class) + .hasMessage("ACP agent transport is not started: port in use"); + } + + @Test + void closeFailsPendingRequestsReleasesPromptsAndClosesTheTransport() { + AcpAgentSession session = promptSession(Map.of()); + Mono pending = pendingRead(session); + this.transport.deliver(prompt("p1", new AcpSchema.PromptRequest("s1", List.of()))).subscribe(); + assertThat(session.hasActivePrompt()).isTrue(); + + session.close(); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with client terminated"); + assertThat(session.hasActivePrompt()).isFalse(); + assertThat(this.transport.closed).isTrue(); + } + + @Test + void gracefulCloseFailsPendingRequestsReleasesPromptsAndClosesTheTransport() { + AcpAgentSession session = promptSession(Map.of()); + Mono pending = pendingRead(session); + this.transport.deliver(prompt("p1", new AcpSchema.PromptRequest("s1", List.of()))).subscribe(); + + session.closeGracefully().block(WAIT); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with client terminated"); + assertThat(session.hasActivePrompt()).isFalse(); + assertThat(this.transport.closed).isTrue(); + } + + @Test + void promptLockAppliesToMapParamsFromJsonTransports() { + AcpAgentSession session = promptSession(Map.of()); + this.transport.deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))).subscribe(); + + AcpSchema.JSONRPCResponse sameSession = answer(prompt("p2", Map.of("sessionId", "s1", "prompt", List.of()))); + assertThat(sameSession.error()).isNotNull(); + assertThat(sameSession.error().code()).isEqualTo(AcpErrorCodes.INVALID_REQUEST); + + this.transport.deliver(prompt("p3", Map.of("sessionId", "s2", "prompt", List.of()))).subscribe(); + assertThat(session.getActivePromptSessionIds()).containsExactlyInAnyOrder("s1", "s2"); + } + + /** + * ACP v1 prompt turn, Cancellation: after session/cancel the agent may still send + * updates and must then answer the original session/prompt with stopReason cancelled; + * "once a prompt turn completes, the Client may send another session/prompt". The turn + * ends with that response, not with the cancel. + */ + @Test + void cancelKeepsTheTurnUntilTheCancelledPromptAnswers() { + AtomicReference cancelParams = new AtomicReference<>(); + AcpAgentSession session = promptSession( + Map.of(AcpSchema.METHOD_SESSION_CANCEL, params -> Mono.fromRunnable(() -> cancelParams.set(params)))); + Mono cancelled = this.transport + .deliver(prompt("p1", new AcpSchema.PromptRequest("s1", List.of()))) + .cache(); + cancelled.subscribe(); + this.transport.deliver(prompt("p2", Map.of("sessionId", "s2", "prompt", List.of()))).subscribe(); + + // In-process transports deliver the typed record... + AcpSchema.CancelNotification typedCancel = new AcpSchema.CancelNotification("s1"); + assertThat(this.transport.deliver(cancel(typedCancel)).block(WAIT)).isNull(); + assertThat(cancelParams.get()).as("the agent's cancel handler still runs").isSameAs(typedCancel); + // ... and the JSON transports a Map. + this.transport.deliver(cancel(Map.of("sessionId", "s1"))).block(WAIT); + + assertThat(session.getActivePromptSessionIds()).containsExactlyInAnyOrder("s1", "s2"); + AcpSchema.JSONRPCResponse tooEarly = answer(prompt("p3", Map.of("sessionId", "s1", "prompt", List.of()))); + assertThat(tooEarly.error()).as("a prompt before the cancelled one answered is rejected").isNotNull(); + assertThat(tooEarly.error().code()).isEqualTo(AcpErrorCodes.INVALID_REQUEST); + + this.gate.tryEmitValue(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + AcpSchema.JSONRPCResponse cancelledAnswer = (AcpSchema.JSONRPCResponse) cancelled.block(WAIT); + assertThat(cancelledAnswer.error()).isNull(); + + AcpSchema.JSONRPCResponse next = answer(prompt("p4", Map.of("sessionId", "s1", "prompt", List.of()))); + assertThat(next.error()).as("once the cancelled turn has answered, the next prompt is accepted").isNull(); + } + + @Test + void aCancelledPromptWhoseHandlerFailsEndsItsTurn() { + Sinks.One failing = Sinks.one(); + AcpAgentSession.RequestHandler handler = params -> failing.asMono(); + AcpAgentSession session = new AcpAgentSession(LONG_TIMEOUT, this.transport, + Map.of(AcpSchema.METHOD_SESSION_PROMPT, handler), Map.of()); + Mono first = this.transport + .deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))) + .cache(); + first.subscribe(); + this.transport.deliver(cancel(Map.of("sessionId", "s1"))).block(WAIT); + assertThat(session.hasActivePrompt("s1")).isTrue(); + + failing.tryEmitError(new IllegalStateException("aborted")); + + // ACP spec 7628b153: prompt-turn.mdx:361, a failure after session/cancel answers cancelled + AcpSchema.JSONRPCResponse answer = (AcpSchema.JSONRPCResponse) first.block(WAIT); + assertThat(answer.error()).isNull(); + assertThat(answer.result()).isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + assertThat(session.hasActivePrompt("s1")).as("the failed handler's answer ends the turn").isFalse(); + } + + /** + * A timeout the handler applies is a failure like any other: after session/cancel it is + * answered cancelled, and ends the turn, well within the session's own cancel grace period + * (AcpAgentSessionPromptTimeoutsTest). + */ + @Test + void aCancelledPromptWhoseHandlerTimesOutEndsItsTurn() { + AcpAgentSession.RequestHandler handler = params -> Mono + .never() + .timeout(Duration.ofMillis(50)); + AcpAgentSession session = new AcpAgentSession(LONG_TIMEOUT, this.transport, + Map.of(AcpSchema.METHOD_SESSION_PROMPT, handler), Map.of()); + Mono first = this.transport + .deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))) + .cache(); + first.subscribe(); + this.transport.deliver(cancel(Map.of("sessionId", "s1"))).block(WAIT); + + AcpSchema.JSONRPCResponse answer = (AcpSchema.JSONRPCResponse) first.block(WAIT); + assertThat(answer.error()).isNull(); + assertThat(answer.result()).isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void closingTheSessionEndsACancelledTurnThatNeverAnswers() { + AcpAgentSession session = promptSession(Map.of()); + this.transport.deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))).subscribe(); + this.transport.deliver(cancel(Map.of("sessionId", "s1"))).block(WAIT); + assertThat(session.hasActivePrompt("s1")).as("busy while the handler has not answered").isTrue(); + + session.close(); + + assertThat(session.hasActivePrompt()).isFalse(); + } + + @Test + void otherRequestsOfASessionAreServedWhileItsPromptRuns() { + AcpAgentSession.RequestHandler gated = params -> this.gate.asMono(); + AcpAgentSession.RequestHandler setMode = params -> Mono + .just(new AcpSchema.SetSessionModeResponse()); + AcpAgentSession session = new AcpAgentSession(LONG_TIMEOUT, this.transport, + Map.of(AcpSchema.METHOD_SESSION_PROMPT, gated, AcpSchema.METHOD_SESSION_SET_MODE, setMode), Map.of()); + this.transport.deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))).subscribe(); + + for (String id : List.of("m1", "m2")) { + AcpSchema.JSONRPCResponse response = answer(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, + AcpSchema.METHOD_SESSION_SET_MODE, Map.of("sessionId", "s1", "modeId", "code"))); + assertThat(response.error()).as("only prompts take the single-turn lock").isNull(); + } + assertThat(session.hasActivePrompt("s1")).isTrue(); + } + + @Test + void onlyCancelReleasesThePromptLock() { + AcpAgentSession session = promptSession(Map.of()); + this.transport.deliver(prompt("p1", Map.of("sessionId", "s1", "prompt", List.of()))).subscribe(); + + this.transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_UPDATE, Map.of("sessionId", "s1"))).block(WAIT); + + assertThat(session.hasActivePrompt("s1")).isTrue(); + } + + @Test + void promptWithoutASessionIdStillReachesTheHandler() { + AcpAgentSession.RequestHandler answers = params -> Mono + .just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + new AcpAgentSession(LONG_TIMEOUT, this.transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, answers), Map.of()); + + assertThat(answer(prompt("p1", Map.of("prompt", List.of()))).error()).isNull(); + assertThat(answer(prompt("p2", new AcpSchema.PromptRequest(null, List.of()))).error()).isNull(); + } + + @Test + void cancelledPromptReleasesTheLock() { + AcpAgentSession session = promptSession(Map.of()); + Disposable inFlight = this.transport.deliver(prompt("p1", new AcpSchema.PromptRequest("s1", List.of()))) + .subscribe(); + assertThat(session.hasActivePrompt("s1")).isTrue(); + + inFlight.dispose(); + + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void notificationsReachTheirHandlerAndUnknownOnesAreIgnored() { + AtomicReference received = new AtomicReference<>(); + promptSession(Map.of(AcpSchema.METHOD_SESSION_UPDATE, params -> Mono.fromRunnable(() -> received.set(params)))); + + this.transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_UPDATE, Map.of("n", 1))).block(WAIT); + assertThat(received.get()).isEqualTo(Map.of("n", 1)); + + assertThat(this.transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + "_vendor/unknown", null)).block(WAIT)).isNull(); + } + + @Test + void responseToARequestOfTheAgentProducesNoReply() { + AcpAgentSession session = promptSession(Map.of()); + Mono pending = pendingRead(session); + var request = (AcpSchema.JSONRPCRequest) this.transport.sent.get(0); + + assertThat(this.transport.deliver(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, + new AcpSchema.JSONRPCError(AcpErrorCodes.INTERNAL_ERROR, "no such file", null))) + .block(WAIT)).isNull(); + assertThatThrownBy(() -> pending.block(WAIT)).isInstanceOf(AcpError.class).hasMessageContaining("no such file"); + } + + @Test + void hasActivePromptRejectsAnEmptySessionId() { + AcpAgentSession session = promptSession(Map.of()); + assertThatThrownBy(() -> session.hasActivePrompt("")).isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptLockTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptLockTest.java index 80613e79..5faab0a9 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptLockTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptLockTest.java @@ -164,7 +164,10 @@ void concurrentPromptOnTheSameConnectionIsStillRejectedWhileTheFirstRuns() { AcpSchema.JSONRPCResponse second = response(transport.deliver(prompt("2")).block(TIMEOUT)); assertThat(second.error()).isNotNull(); - assertThat(second.error().code()).isEqualTo(-32000); + // Not -32000: ACP v1 defines that as "Authentication required" (schema $defs.ErrorCode). + assertThat(second.error().code()).isEqualTo(-32600); + assertThat(second.error().message()).isEqualTo("There is already an active prompt on session session-14"); + assertThat(second.error().data()).isEqualTo(java.util.Map.of("sessionId", "session-14")); gate.tryEmitValue(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); assertThat(first.get()).isNotNull(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptTimeoutsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptTimeoutsTest.java new file mode 100644 index 00000000..417f6367 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionPromptTimeoutsTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpAgentSessionLifecycleTest.ControllableAgentTransport; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.test.scheduler.VirtualTimeScheduler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The cancel grace period and the maximum prompt duration ({@link PromptTimeouts}), on a + * virtual clock: the session's timer is a {@link VirtualTimeScheduler} the test advances. + */ +class AcpAgentSessionPromptTimeoutsTest { + + private static final Duration LONG_TIMEOUT = Duration.ofMinutes(5); + + private final VirtualTimeScheduler clock = VirtualTimeScheduler.create(); + + private final ControllableAgentTransport transport = new ControllableAgentTransport(); + + /** Set when the session cancels the prompt handler's subscription. */ + private final AtomicBoolean handlerCancelled = new AtomicBoolean(); + + /** The prompt handler answers when the test emits here, if ever. */ + private final Sinks.One handlerAnswer = Sinks.one(); + + { + this.transport.started.tryEmitEmpty(); + } + + private AcpAgentSession session(PromptTimeouts timeouts) { + AcpAgentSession.RequestHandler handler = params -> this.handlerAnswer.asMono() + .doOnCancel(() -> this.handlerCancelled.set(true)); + return new AcpAgentSession(LONG_TIMEOUT, this.transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, handler), + Map.of(), timeouts, delay -> Mono.delay(delay, this.clock)); + } + + /** Sends a prompt on session s1 and returns its response, cached so it can be read later. */ + private Mono prompt(String id) { + Mono response = this.transport + .deliver(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT, + Map.of("sessionId", "s1", "prompt", List.of()))) + .cache(); + response.subscribe(message -> { + }, error -> { + }); + return response; + } + + private void cancel() { + this.transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_CANCEL, Map.of("sessionId", "s1"))).block(Duration.ofSeconds(2)); + } + + private static AcpSchema.JSONRPCResponse answered(Mono response) { + return (AcpSchema.JSONRPCResponse) response.block(Duration.ofSeconds(2)); + } + + private static Object stopReason(AcpSchema.JSONRPCResponse response) { + assertThat(response.error()).isNull(); + return ((AcpSchema.PromptResponse) response.result()).stopReason(); + } + + @Test + void aHandlerThatDoesNotAnswerWithinTheGracePeriodIsAnsweredCancelled() { + AcpAgentSession session = session(PromptTimeouts.DEFAULTS); + Mono response = prompt("p1"); + cancel(); + + this.clock.advanceTimeBy(Duration.ofSeconds(59)); + assertThat(session.hasActivePrompt("s1")).as("still within the grace period").isTrue(); + assertThat(this.handlerCancelled).isFalse(); + + this.clock.advanceTimeBy(Duration.ofSeconds(1)); + assertThat(stopReason(answered(response))).isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(this.handlerCancelled).as("the handler's subscription is cancelled").isTrue(); + assertThat(session.hasActivePrompt("s1")).as("the turn has ended").isFalse(); + } + + @Test + void anAnswerWithinTheGracePeriodIsTheHandlers() { + AcpAgentSession session = session(PromptTimeouts.DEFAULTS); + Mono response = prompt("p1"); + cancel(); + this.clock.advanceTimeBy(Duration.ofSeconds(30)); + + // The handler's answer, its _meta kept; after session/cancel its stop reason is cancelled + // (ACP spec 7628b153: prompt-turn.mdx:354). + this.handlerAnswer + .tryEmitValue(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN, Map.of("by", "handler"))); + assertThat(answered(response).result()) + .isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED, Map.of("by", "handler"))); + assertThat(session.hasActivePrompt("s1")).isFalse(); + + Mono next = prompt("p2"); + this.clock.advanceTimeBy(Duration.ofMinutes(5)); + assertThat(stopReason(answered(next))).as("the old grace timer is gone; the next prompt is the handler's") + .isEqualTo(AcpSchema.StopReason.END_TURN); + } + + @Test + void theGracePeriodStartsWithTheCancelNotWithThePrompt() { + AcpAgentSession session = session(PromptTimeouts.DEFAULTS); + prompt("p1"); + this.clock.advanceTimeBy(Duration.ofHours(1)); + assertThat(session.hasActivePrompt("s1")).as("no cancel, no grace period").isTrue(); + + cancel(); + this.clock.advanceTimeBy(Duration.ofSeconds(60)); + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void aPromptThatRunsPastTheMaximumDurationIsAnsweredRequestCancelled() { + AcpAgentSession session = session(PromptTimeouts.NONE.withMaxPromptDuration(Duration.ofMinutes(10))); + Mono response = prompt("p1"); + + this.clock.advanceTimeBy(Duration.ofMinutes(10)); + + AcpSchema.JSONRPCError error = answered(response).error(); + assertThat(error).isNotNull(); + assertThat(error.code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + assertThat(error.message()).isEqualTo("Prompt exceeded maxPromptDuration of PT10M"); + assertThat(error.data()).isEqualTo(Map.of("maxPromptDuration", "PT10M")); + assertThat(this.handlerCancelled).isTrue(); + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void aCancelledPromptThatRunsPastTheMaximumDurationIsAnsweredCancelled() { + AcpAgentSession session = session(PromptTimeouts.DEFAULTS.withMaxPromptDuration(Duration.ofSeconds(10))); + Mono response = prompt("p1"); + this.clock.advanceTimeBy(Duration.ofSeconds(5)); + cancel(); + + this.clock.advanceTimeBy(Duration.ofSeconds(5)); + + assertThat(stopReason(answered(response))).as("a cancelled prompt is answered cancelled, not with an error") + .isEqualTo(AcpSchema.StopReason.CANCELLED); + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void zeroTurnsBothLimitsOff() { + AcpAgentSession session = session(PromptTimeouts.NONE); + prompt("p1"); + cancel(); + + this.clock.advanceTimeBy(Duration.ofDays(1)); + + assertThat(session.hasActivePrompt("s1")).as("as before the limits: busy until the handler answers").isTrue(); + assertThat(this.handlerCancelled).isFalse(); + } + + @Test + void withBothLimitsOffTheHandlerStillAnswers() { + session(PromptTimeouts.NONE); + Mono response = prompt("p1"); + this.handlerAnswer.tryEmitValue(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + assertThat(stopReason(answered(response))).isEqualTo(AcpSchema.StopReason.END_TURN); + } + + @Test + void anAnswerBeforeTheMaximumDurationIsTheHandlers() { + AcpAgentSession session = session(PromptTimeouts.NONE.withMaxPromptDuration(Duration.ofMinutes(10))); + Mono response = prompt("p1"); + this.clock.advanceTimeBy(Duration.ofMinutes(9)); + this.handlerAnswer.tryEmitValue(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); + + this.clock.advanceTimeBy(Duration.ofMinutes(5)); + assertThat(stopReason(answered(response))).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(session.hasActivePrompt("s1")).isFalse(); + } + + @Test + void defaultsAndValidation() { + assertThat(PromptTimeouts.DEFAULTS.cancelGracePeriod()).isEqualTo(Duration.ofSeconds(60)); + assertThat(PromptTimeouts.DEFAULTS.maxPromptDuration()).isZero(); + assertThatThrownBy(() -> PromptTimeouts.NONE.withCancelGracePeriod(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> PromptTimeouts.NONE.withMaxPromptDuration(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new PromptTimeouts(null, Duration.ZERO)).isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java index 43c98b1d..2f436bcc 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpAgentSessionTest.java @@ -13,10 +13,17 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.test.InMemoryTransportPair; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import reactor.test.StepVerifier; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -34,7 +41,7 @@ class AcpAgentSessionTest { private static final long CLIENT_TRANSPORT_SUBSCRIPTION_DELAY_MILLIS = 50; - private static final int ACTIVE_PROMPT_ERROR_CODE = -32000; + private static final int ACTIVE_PROMPT_ERROR_CODE = -32600; private static final String SESSION_1 = "session-1"; @@ -171,6 +178,46 @@ void handlesNotification() throws Exception { } } + /** + * An unhandled protocol notification is logged at WARN by its method only: its params can + * carry personal data. (An unhandled extension notification is logged at DEBUG.) + */ + @Test + void unhandledNotificationIsLoggedWithoutItsParams() throws Exception { + Logger sessionLogger = (Logger) LoggerFactory.getLogger(AcpAgentSession.class); + ListAppender logs = new ListAppender<>(); + logs.start(); + sessionLogger.addAppender(logs); + var transportPair = InMemoryTransportPair.create(); + try { + new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); + allowAgentTransportSubscription(); + transportPair.clientTransport().connect(mono -> mono.then(Mono.empty())).subscribe(); + allowClientTransportSubscription(); + transportPair.clientTransport() + .sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "session/status_update", + Map.of("email", "someone@example.com"))) + .block(TIMEOUT); + + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (logs.list.stream().noneMatch(event -> event.getLevel() == Level.WARN) + && System.nanoTime() < deadline) { + Thread.sleep(10); + } + assertThat(logs.list).filteredOn(event -> event.getLevel() == Level.WARN) + .extracting(ILoggingEvent::getFormattedMessage) + .anySatisfy(message -> assertThat(message).contains("session/status_update")); + assertThat(logs.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .extracting(ILoggingEvent::getFormattedMessage) + .allSatisfy(message -> assertThat(message).doesNotContain("someone@example.com")); + } + finally { + sessionLogger.detachAppender(logs); + logs.stop(); + transportPair.closeGracefully().block(TIMEOUT); + } + } + @Test void singleTurnEnforcementRejectsConcurrentPromptsForSameSession() throws Exception { var transportPair = InMemoryTransportPair.create(); @@ -382,13 +429,122 @@ void handlerErrorReturnsJsonRpcError() throws Exception { AcpSchema.JSONRPCResponse jsonResponse = (AcpSchema.JSONRPCResponse) response.get(); assertThat(jsonResponse.error()).isNotNull(); assertThat(jsonResponse.error().code()).isEqualTo(-32603); // Internal error - assertThat(jsonResponse.error().message()).isEqualTo("Handler error"); + assertThat(jsonResponse.error().message()).isEqualTo("Internal error"); } finally { transportPair.closeGracefully().block(TIMEOUT); } } + @Test + void nullResultForAnEmptyResponseTypeYieldsAnEmptyResponse() throws Exception { + // The Python SDK client answers fs/write_text_file with "result": null when its handler + // returns None. Like the Rust SDK, a response type that defaults on null reads it as {}. + var transportPair = InMemoryTransportPair.create(); + try { + var session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); + allowAgentTransportSubscription(); + answerEveryRequestWithNullResult(transportPair); + + AcpSchema.WriteTextFileResponse response = session + .sendRequest(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, new AcpSchema.WriteTextFileRequest("s", "/f", "x"), + new TypeRef() { + }) + .block(TIMEOUT); + + assertThat(response).isEqualTo(new AcpSchema.WriteTextFileResponse()); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void nullResultForANonEmptyResponseTypeFailsClearly() throws Exception { + var transportPair = InMemoryTransportPair.create(); + try { + var session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); + allowAgentTransportSubscription(); + answerEveryRequestWithNullResult(transportPair); + + Mono response = session.sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, + new AcpSchema.ReadTextFileRequest("s", "/f", null, null), + new TypeRef() { + }); + + StepVerifier.create(response) + .expectErrorSatisfies(error -> assertThat(error).hasMessageContaining("carried no result")) + .verify(TIMEOUT); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + + /** A client's {@code {}} must not read as file content whose required content is null. */ + @Test + void aResultWithoutARequiredFieldFailsTheRequest() throws Exception { + var transportPair = InMemoryTransportPair.create(); + try { + var session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); + allowAgentTransportSubscription(); + answerEveryRequestWith(transportPair, Map.of()); + + Mono response = session.sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, + new AcpSchema.ReadTextFileRequest("s", "/f", null, null), + new TypeRef() { + }); + + StepVerifier.create(response) + .expectErrorSatisfies(error -> assertThat(error) + .isInstanceOfSatisfying(AcpError.class, + e -> assertThat(e.getCode()).isEqualTo(-32603)) + .hasMessageContaining("The response to fs/read_text_file lacks the required field content")) + .verify(TIMEOUT); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void nullResultForAnExtensionMethodCompletesEmpty() throws Exception { + // An extension method's result is free-form, and null is a legal value of it. + var transportPair = InMemoryTransportPair.create(); + try { + var session = new AcpAgentSession(TIMEOUT, transportPair.agentTransport(), Map.of(), Map.of()); + allowAgentTransportSubscription(); + answerEveryRequestWithNullResult(transportPair); + + Mono response = session.sendRequest("_vendor/ping", Map.of(), new TypeRef() { + }); + + StepVerifier.create(response).verifyComplete(); + } + finally { + transportPair.closeGracefully().block(TIMEOUT); + } + } + + private static void answerEveryRequestWithNullResult(InMemoryTransportPair transportPair) + throws InterruptedException { + answerEveryRequestWith(transportPair, null); + } + + private static void answerEveryRequestWith(InMemoryTransportPair transportPair, Object result) + throws InterruptedException { + var clientTransport = transportPair.clientTransport(); + clientTransport.connect(mono -> mono.flatMap(msg -> { + if (msg instanceof AcpSchema.JSONRPCRequest request) { + return clientTransport + .sendMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)) + .then(Mono.empty()); + } + return Mono.empty(); + })).subscribe(); + allowClientTransportSubscription(); + } + private static AcpSchema.JSONRPCRequest promptRequest(String id, String sessionId, String text) { return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT, new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text)))); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionConnectTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionConnectTest.java index 38e9b9a6..552fe0eb 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionConnectTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionConnectTest.java @@ -4,6 +4,7 @@ package com.agentclientprotocol.sdk.spec; +import com.agentclientprotocol.sdk.spec.AcpSchema; import java.nio.file.Path; import java.time.Duration; import java.util.List; @@ -79,6 +80,7 @@ void syncFacadeOverAnExistingAsyncClientSharesOneSession() throws Exception { .requestTimeout(timeout) .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build(); agent.start().subscribe(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLifecycleTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLifecycleTest.java new file mode 100644 index 00000000..33f8bcf1 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLifecycleTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The client session's error, close and failure paths: a pending request fails at once + * when the session can no longer answer it, a failing notification handler does not stop + * the ones after it, and an unsupported agent request is answered with a precise error. + */ +class AcpClientSessionLifecycleTest { + + /** Long enough that a request which is not failed at once times out the assertion first. */ + private static final Duration LONG_TIMEOUT = Duration.ofMinutes(5); + + private static final Duration WAIT = Duration.ofSeconds(2); + + private static final TypeRef INITIALIZE = new TypeRef<>() { + }; + + /** A transport whose connection fails when the test says so. */ + static class FailingConnectTransport implements AcpClientTransport { + + final Sinks.One connection = Sinks.one(); + + @Override + public Mono connect(Function, Mono> handler) { + return this.connection.asMono(); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + throw new UnsupportedOperationException(); + } + + } + + private static Mono pendingInitialize(AcpClientSession session) { + Mono pending = session.sendRequest(AcpSchema.METHOD_INITIALIZE, + new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities()), INITIALIZE) + .cache(); + pending.subscribe(v -> { + }, e -> { + }); + return pending; + } + + @Test + void constructorRejectsMissingArguments() { + var transport = new MockAcpClientTransport(); + assertThatThrownBy(() -> new AcpClientSession(null, transport, Map.of(), Map.of(), Function.identity())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new AcpClientSession(LONG_TIMEOUT, null, Map.of(), Map.of(), Function.identity())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new AcpClientSession(LONG_TIMEOUT, transport, null, Map.of(), Function.identity())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), null, Function.identity())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void connectionThatFailsLaterFailsPendingAndLaterRequestsAtOnce() { + var transport = new FailingConnectTransport(); + var session = new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + Mono pending = pendingInitialize(session); + + IllegalStateException cause = new IllegalStateException("socket reset"); + transport.connection.tryEmitError(cause); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with agent terminated").hasCause(cause); + assertThatThrownBy(() -> pendingInitialize(session).block(WAIT)).isInstanceOf(AcpConnectionException.class) + .hasMessage("ACP client transport is not connected: socket reset"); + } + + @Test + void requestAfterThePeerClosedFailsAtOnce() { + var transport = new AcpClientSessionTerminationTest.TerminatingTransport(); + var session = new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + transport.termination.tryEmitEmpty(); + + assertThatThrownBy(() -> pendingInitialize(session).block(WAIT)).isInstanceOf(AcpConnectionException.class) + .hasMessage("ACP client transport is not connected: ACP client transport terminated"); + } + + @Test + void closeFailsPendingRequestsAtOnce() { + var session = new AcpClientSession(LONG_TIMEOUT, new MockAcpClientTransport(), Map.of(), Map.of(), + Function.identity()); + Mono pending = pendingInitialize(session); + + session.close(); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with agent terminated"); + } + + @Test + void gracefulCloseFailsPendingRequestsAtOnce() { + var session = new AcpClientSession(LONG_TIMEOUT, new MockAcpClientTransport(), Map.of(), Map.of(), + Function.identity()); + Mono pending = pendingInitialize(session); + + session.closeGracefully().block(WAIT); + + assertThatThrownBy(() -> pending.block(WAIT)).hasMessage("ACP session with agent terminated"); + } + + @Test + void failingNotificationHandlerDoesNotStopLaterNotifications() { + var transport = new MockAcpClientTransport(); + List delivered = new CopyOnWriteArrayList<>(); + AcpClientSession.NotificationHandler handler = params -> { + delivered.add(params); + return delivered.size() == 1 ? Mono.error(new IllegalStateException("handler failed")) : Mono.empty(); + }; + var session = new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), + Map.of(AcpSchema.METHOD_SESSION_UPDATE, handler), Function.identity()); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_UPDATE, Map.of("n", 1))); + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_UPDATE, Map.of("n", 2))); + session.closeGracefully().block(WAIT); + + assertThat(delivered).containsExactly(Map.of("n", 1), Map.of("n", 2)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', + value = { "fs/read_text_file | File system read not supported | fs.readTextFile capability", + "fs/write_text_file | File system write not supported | fs.writeTextFile capability", + "session/request_permission| Permission request not supported | requestPermissionHandler", + "terminal/create | Terminal not supported | terminal capability", + "terminal/output | Terminal not supported | terminal capability", + "terminal/release | Terminal not supported | terminal capability", + "terminal/wait_for_exit | Terminal not supported | terminal capability", + "terminal/kill | Terminal not supported | terminal capability" }) + void unsupportedAgentRequestIsAnsweredWithMethodNotFound(String method, String message, String reason) { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "req-1", method, Map.of())); + + var response = (AcpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response.id()).isEqualTo("req-1"); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND); + assertThat(response.error().message()).isEqualTo(message); + assertThat(response.error().data()).isInstanceOfSatisfying(Map.class, + data -> assertThat(data.get("reason").toString()).contains(reason)); + session.close(); + } + + @Test + void unknownAgentRequestIsAnsweredWithMethodNotFoundNamingIt() { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(LONG_TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "req-1", "_vendor/thing", null)); + + var response = (AcpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.METHOD_NOT_FOUND); + assertThat(response.error().message()).isEqualTo("Method not found: _vendor/thing"); + assertThat(response.error().data()).isNull(); + session.close(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLogTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLogTest.java index 62b8c7e8..6925981d 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLogTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionLogTest.java @@ -14,6 +14,7 @@ import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.json.TypeRef; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -175,6 +176,73 @@ void warnLogForReadTextFileMentionsCapability() throws InterruptedException { session.close(); } + /** Personal data an agent can send: _auth/status_update carries the account's email. */ + private static final String EMAIL = "someone@example.com"; + + /** + * An unhandled notification is logged with its method only: its params can carry personal + * data. Claude's and Codex's agents send {@code _auth/status_update}, whose params name the + * account's email address in login mode. Being an extension notification, which ACP asks to + * ignore, it is logged at DEBUG. + */ + @Test + void unhandledNotificationIsLoggedWithoutItsParams() throws InterruptedException { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + "_auth/status_update", Map.of("status", "logged_in", "email", EMAIL))); + session.closeGracefully().block(TIMEOUT); + + assertThat(listAppender.list).filteredOn(event -> event.getLevel() == Level.DEBUG) + .extracting(ILoggingEvent::getFormattedMessage) + .anySatisfy(message -> assertThat(message).contains("No handler registered") + .contains("_auth/status_update") + .doesNotContain(EMAIL)); + assertNoPersonalDataAboveDebug(listAppender.list); + } + + /** + * An error response fails the caller's request, which is the report: the session logs + * it at DEBUG only, and never its data, which is the peer's payload. + */ + @Test + void errorResponseIsLoggedWithoutItsData() { + Logger outboundLogger = (Logger) LoggerFactory.getLogger(OutboundMessages.class); + ListAppender outboundLogs = new ListAppender<>(); + outboundLogs.start(); + outboundLogger.addAppender(outboundLogs); + try { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + var response = session.sendRequest("test/method", Map.of(), new TypeRef() { + }).toFuture(); + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), + null, new AcpSchema.JSONRPCError(-32000, "Authentication required", Map.of("email", EMAIL)))); + + assertThat(response).failsWithin(TIMEOUT); + assertThat(outboundLogs.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)).isEmpty(); + assertThat(outboundLogs.list).filteredOn(event -> event.getLevel() == Level.DEBUG) + .extracting(ILoggingEvent::getFormattedMessage) + .anySatisfy(message -> assertThat(message).contains("-32000")) + .allSatisfy(message -> assertThat(message).doesNotContain(EMAIL)); + assertNoPersonalDataAboveDebug(outboundLogs.list); + session.close(); + } + finally { + outboundLogger.detachAppender(outboundLogs); + outboundLogs.stop(); + } + } + + private static void assertNoPersonalDataAboveDebug(List events) { + assertThat(events).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .extracting(ILoggingEvent::getFormattedMessage) + .allSatisfy(message -> assertThat(message).doesNotContain(EMAIL)); + } + /** * Verifies that DEBUG log is produced when a handler is invoked. */ diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionResponseOrderTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionResponseOrderTest.java new file mode 100644 index 00000000..0081fc61 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionResponseOrderTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.publisher.Sinks.Empty; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A response does not complete its caller until every notification that arrived before it on + * the session has been delivered to its handler: the prompt turn's updates come before its + * response, and a caller reading what its update handler collected once {@code prompt()} + * returns must find all of them. Driven by hand, one inbound message at a time, so every + * outcome is decided by the order of the messages rather than by timing. + */ +class AcpClientSessionResponseOrderTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String UPDATE = "session/update"; + + private static final TypeRef STRING = new TypeRef<>() { + }; + + private final MockAcpClientTransport transport = new MockAcpClientTransport(); + + /** The handler of the update being delivered; completing it finishes that delivery. */ + private final AtomicReference> handling = new AtomicReference<>(); + + private AcpClientSession session; + + @AfterEach + void close() { + if (this.session != null) { + this.session.close(); + } + } + + private AcpClientSession sessionWithUpdateHandler(Function> handler) { + this.session = new AcpClientSession(TIMEOUT, this.transport, Map.of(), Map.of(UPDATE, handler::apply), + Function.identity()); + return this.session; + } + + /** An update handler that finishes only when the test completes {@link #handling}. */ + private Mono slowHandler(Object params) { + Empty done = Sinks.empty(); + this.handling.set(done); + return done.asMono(); + } + + private void receiveUpdate() { + this.transport.simulateIncomingMessage( + new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, UPDATE, Map.of())); + } + + private void receiveResponse(Object id, String result) { + this.transport.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, result, null)); + } + + @Test + void aResponseWaitsForTheUpdateBeforeItToBeHandled() { + sessionWithUpdateHandler(this::slowHandler); + var response = this.session.sendRequest("session/prompt", Map.of(), STRING).toFuture(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + + receiveUpdate(); + receiveResponse(id, "end_turn"); + + assertThat(response).as("completed while the update before it was still being handled").isNotDone(); + this.handling.get().tryEmitEmpty(); + assertThat(response).isCompletedWithValue("end_turn"); + } + + @Test + void aResponseWithNoUpdateStillBeingHandledCompletesAtOnce() { + sessionWithUpdateHandler(params -> Mono.empty()); + var response = this.session.sendRequest("session/prompt", Map.of(), STRING).toFuture(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + + receiveUpdate(); + receiveResponse(id, "end_turn"); + + assertThat(response).isCompletedWithValue("end_turn"); + } + + @Test + void anUpdateAfterTheResponseDoesNotHoldIt() { + sessionWithUpdateHandler(this::slowHandler); + var response = this.session.sendRequest("session/prompt", Map.of(), STRING).toFuture(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + + receiveResponse(id, "end_turn"); + receiveUpdate(); + + assertThat(response).isCompletedWithValue("end_turn"); + } + + /** + * An update handler that sends a request and waits for its answer, while the agent sends + * another update first: holding that answer behind the updates would deadlock the handler. + */ + @Test + void aRequestSentByAHandlerStillRunningIsNotHeldBehindIt() { + AtomicReference> fromHandler = new AtomicReference<>(); + sessionWithUpdateHandler(params -> { + if (fromHandler.get() != null) { + return Mono.empty(); + } + Mono request = this.session.sendRequest("session/set_mode", Map.of(), STRING).cache(); + fromHandler.set(request.toFuture()); + return request.then(); + }); + + receiveUpdate(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + receiveUpdate(); + receiveResponse(id, "ok"); + + assertThat(fromHandler.get()).isCompletedWithValue("ok"); + } + + @Test + void closeReleasesAResponseHeldBehindAnUpdate() { + sessionWithUpdateHandler(this::slowHandler); + var response = this.session.sendRequest("session/prompt", Map.of(), STRING).toFuture(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + receiveUpdate(); + receiveResponse(id, "end_turn"); + + this.session.close(); + + assertThat(response).isCompletedWithValue("end_turn"); + } + + @Test + void closeGracefullyDeliversTheUpdateThenCompletesTheResponse() { + sessionWithUpdateHandler(this::slowHandler); + var response = this.session.sendRequest("session/prompt", Map.of(), STRING).toFuture(); + Object id = this.transport.getLastSentMessageAsRequest().id(); + receiveUpdate(); + receiveResponse(id, "end_turn"); + + var closed = this.session.closeGracefully().toFuture(); + assertThat(closed).isNotDone(); + assertThat(response).isNotDone(); + this.handling.get().tryEmitEmpty(); + + assertThat(response).isCompletedWithValue("end_turn"); + assertThat(closed).succeedsWithin(TIMEOUT); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionTest.java index 57110dcd..4249a0eb 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpClientSessionTest.java @@ -13,6 +13,7 @@ import java.util.function.Function; import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -92,7 +93,7 @@ void testSendRequestWithError() { AcpSchema.JSONRPCError error = new AcpSchema.JSONRPCError(-32601, "Method not found", null); transport.simulateIncomingMessage( new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, error)); - }).expectError(AcpClientSession.AcpError.class).verify(); + }).expectError(AcpError.class).verify(); session.close(); } @@ -250,7 +251,7 @@ void testRequestHandlerThrowsRuntimeException() { AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) sentMessage; assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(-32603); - assertThat(response.error().message()).isEqualTo("Something went wrong"); + assertThat(response.error().message()).isEqualTo("Internal error"); session.close(); } @@ -281,13 +282,13 @@ void testRequestHandlerThrowsExceptionWithCause() { Thread.currentThread().interrupt(); } - // Verify: The response should contain INTERNAL_ERROR with exception message + // Verify: The response should contain INTERNAL_ERROR, without the exception's message AcpSchema.JSONRPCMessage sentMessage = transport.getLastSentMessage(); assertThat(sentMessage).isInstanceOf(AcpSchema.JSONRPCResponse.class); AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) sentMessage; assertThat(response.error()).isNotNull(); assertThat(response.error().code()).isEqualTo(-32603); - assertThat(response.error().message()).isEqualTo("Top level message"); + assertThat(response.error().message()).isEqualTo("Internal error"); session.close(); } @@ -358,6 +359,45 @@ void testGracefulCloseDrainsQueuedNotifications() { assertThat(processedOrder).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); } + @Test + void gracefulCloseWhileANotificationIsBeingDeliveredDoesNotWaitOutTheRequestTimeout() throws Exception { + // The live case: an agent sends a notification just as the client closes after a turn. + // A handler that does its work synchronously runs on the inbound thread inside the + // notification sink's emission, so the close's completion of that sink collides with + // it. The completion must still happen: the close then waits only for the handler, + // not for the request timeout. The handler outlasts any bounded busy-loop retry. + Duration requestTimeout = Duration.ofSeconds(30); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + List delivered = new CopyOnWriteArrayList<>(); + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(requestTimeout, transport, Map.of(), + Map.of(TEST_NOTIFICATION, params -> { + entered.countDown(); + try { + release.await(); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + delivered.add(params); + return Mono.empty(); + }), Function.identity()); + + Thread inbound = new Thread(() -> transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification( + AcpSchema.JSONRPC_VERSION, TEST_NOTIFICATION, Map.of("index", 0))), "inbound"); + inbound.start(); + assertThat(entered.await(5, TimeUnit.SECONDS)).isTrue(); + + var closed = session.closeGracefully().toFuture(); + Thread.sleep(300); + release.countDown(); + + closed.get(5, TimeUnit.SECONDS); + inbound.join(5_000); + assertThat(delivered).containsExactly(Map.of("index", 0)); + } + @Test void testConcurrentRequests() { var transport = new MockAcpClientTransport(); @@ -397,4 +437,231 @@ void testConcurrentRequests() { session.close(); } + // --------------------------------------------------------------------------------- + // Absent values on the wire (found by the NullAway adoption) + // --------------------------------------------------------------------------------- + + private static AcpSchema.JSONRPCResponse awaitSentResponse(MockAcpClientTransport transport) throws Exception { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + for (AcpSchema.JSONRPCMessage message : transport.getSentMessages()) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + return response; + } + } + Thread.sleep(10); + } + throw new AssertionError("The session sent no JSON-RPC response"); + } + + @Test + void requestWithoutParamsReachesItsHandlerAsAnEmptyObject() throws Exception { + // JSON-RPC lets a request omit params; handlers used to receive null. + Map> requestHandlers = Map.of(ECHO_METHOD, + params -> Mono.just(String.valueOf(params))); + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "no-params", ECHO_METHOD, null)); + + AcpSchema.JSONRPCResponse response = awaitSentResponse(transport); + assertThat(response.error()).isNull(); + assertThat(response.result()).isEqualTo("{}"); + session.close(); + } + + @Test + void requestHandlerCompletingEmptyIsAnsweredWithAnError() throws Exception { + // An empty handler result used to send no response at all: the peer waited for its + // timeout. A JSON-RPC request always gets a response. + Map> requestHandlers = Map.of(ECHO_METHOD, + params -> Mono.empty()); + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "empty", ECHO_METHOD, Map.of())); + + AcpSchema.JSONRPCResponse response = awaitSentResponse(transport); + assertThat(response.id()).isEqualTo("empty"); + assertThat(response.error()).isNotNull(); + assertThat(response.error().message()).contains("produced no response"); + session.close(); + } + + @Test + void handlerErrorWithoutMessageStillSendsAnErrorMessage() throws Exception { + // JSON-RPC requires error.message; an exception without one used to send none. Every + // unexpected exception is now answered with the generic message. + Map> requestHandlers = Map.of(ECHO_METHOD, + params -> Mono.error(new IllegalStateException())); + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); + + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "no-message", ECHO_METHOD, Map.of())); + + AcpSchema.JSONRPCResponse response = awaitSentResponse(transport); + assertThat(response.error()).isNotNull(); + assertThat(response.error().message()).isEqualTo("Internal error"); + session.close(); + } + + @Test + void successResponseWithoutResultFailsTheRequestClearly() { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono responseMono = session.sendRequest(TEST_METHOD, "test", responseType); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, null)); + }).expectErrorSatisfies(error -> assertThat(error).hasMessageContaining("carried no result") + .isInstanceOfSatisfying(AcpError.class, + e -> assertThat(e.getCode()).isEqualTo(-32603))) + .verify(TIMEOUT); + + session.close(); + } + + + /** A peer's {@code {}} must not read as a response whose required stopReason is null. */ + @Test + void aPromptResultWithoutItsRequiredStopReasonFailsTheRequest() { + assertResultLacksField(AcpSchema.METHOD_SESSION_PROMPT, + new AcpSchema.PromptRequest("s", List.of(new AcpSchema.TextContent("hi"))), + new TypeRef() { + }, Map.of(), "stopReason"); + } + + @Test + void aNewSessionResultWithoutItsRequiredSessionIdFailsTheRequest() { + assertResultLacksField(AcpSchema.METHOD_SESSION_NEW, new AcpSchema.NewSessionRequest("/", List.of()), + new TypeRef() { + }, Map.of("modes", Map.of()), "sessionId"); + } + + @Test + void aRequiredFieldMissingInsideAResultIsNamedByItsPath() { + assertResultLacksField(AcpSchema.METHOD_SESSION_NEW, new AcpSchema.NewSessionRequest("/", List.of()), + new TypeRef() { + }, Map.of("sessionId", "s-1", "modes", Map.of("availableModes", List.of())), "modes.currentModeId"); + } + + private void assertResultLacksField(String method, Object params, TypeRef type, Object result, + String field) { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono responseMono = session.sendRequest(method, params, type); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), result, null)); + }) + .expectErrorSatisfies(error -> assertThat(error) + .isInstanceOfSatisfying(AcpError.class, + e -> assertThat(e.getCode()).isEqualTo(-32603)) + .hasMessageContaining("The response to " + method + " lacks the required field " + field)) + .verify(TIMEOUT); + + session.close(); + } + + @Test + void nullResultForAnEmptyResponseTypeYieldsAnEmptyResponse() { + // JSON-RPC allows "result": null, and the Python SDK sends it when a handler returns None. + // Like the Rust SDK, a response type that defaults on null reads it as {}. + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono responseMono = session.sendRequest(AcpSchema.METHOD_SESSION_SET_MODE, + new AcpSchema.SetSessionModeRequest("s", "m"), new TypeRef() { + }); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, null)); + }).expectNext(new AcpSchema.SetSessionModeResponse()).verifyComplete(); + + session.close(); + } + + @Test + void nullResultForAMetaOnlyResponseTypeYieldsAResponseWithoutMeta() { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono responseMono = session.sendRequest(AcpSchema.METHOD_LOGOUT, + new AcpSchema.LogoutRequest(), new TypeRef() { + }); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, null)); + }).expectNext(new AcpSchema.LogoutResponse()).verifyComplete(); + + session.close(); + } + + @Test + void nullResultForANonEmptyResponseTypeStillFailsClearly() { + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono responseMono = session.sendRequest(AcpSchema.METHOD_SESSION_NEW, + new AcpSchema.NewSessionRequest("/", List.of()), new TypeRef() { + }); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, null)); + }).expectErrorSatisfies(error -> assertThat(error).hasMessageContaining("carried no result")).verify(TIMEOUT); + + session.close(); + } + + @Test + void nullResultForAnExtensionMethodCompletesEmpty() { + // An extension method's result is free-form, and null is a legal value of it. + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), Function.identity()); + + Mono> responseMono = session.sendRequest("_vendor/ping", Map.of(), + new TypeRef>() { + }); + + StepVerifier.create(responseMono).then(() -> { + AcpSchema.JSONRPCRequest request = transport.getLastSentMessageAsRequest(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, null)); + }).verifyComplete(); + + session.close(); + } + + @Test + void emptyResponseFromAClientHandlerIsWrittenAsAnEmptyObjectResult() throws Exception { + // Java answers with "result": {} for an empty response type, never "result": null. + Map> requestHandlers = Map.of(AcpSchema.METHOD_FS_WRITE_TEXT_FILE, + params -> Mono.just(new AcpSchema.WriteTextFileResponse())); + var transport = new MockAcpClientTransport(); + var session = new AcpClientSession(TIMEOUT, transport, requestHandlers, Map.of(), Function.identity()); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, "w", + AcpSchema.METHOD_FS_WRITE_TEXT_FILE, Map.of("sessionId", "s", "path", "/f", "content", "x"))); + + AcpSchema.JSONRPCResponse response = awaitSentResponse(transport); + assertThat(AcpJsonMapper.createDefault().writeValueAsString(response)) + .isEqualTo("{\"jsonrpc\":\"2.0\",\"id\":\"w\",\"result\":{}}"); + session.close(); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpErrorTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpErrorTest.java new file mode 100644 index 00000000..6e57eff2 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpErrorTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.error.AcpException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The message an {@link AcpError} carries names the peer's message and code, and the + * most useful part of its data. + */ +class AcpErrorTest { + + private static AcpError error(Object data) { + return new AcpError(new AcpSchema.JSONRPCError(-32602, "Invalid params", data)); + } + + @Test + void exposesTheErrorItWasBuiltFrom() { + AcpSchema.JSONRPCError jsonRpcError = new AcpSchema.JSONRPCError(-32602, "Invalid params", List.of(1)); + AcpError error = new AcpError(jsonRpcError); + assertThat(error.getError()).isSameAs(jsonRpcError); + assertThat(error.getCode()).isEqualTo(-32602); + assertThat(error.getData()).isEqualTo(List.of(1)); + } + + @Test + void messageWithoutDataIsThePeersMessageAlone() { + assertThat(error(null)).hasMessage("Invalid params"); + } + + @Test + void messagePrefersTheDetailsOfMapData() { + assertThat(error(Map.of("details", "path must be absolute", "reason", "bad path"))) + .hasMessage("Invalid params: path must be absolute"); + } + + @Test + void messageFallsBackToTheReasonOfMapData() { + assertThat(error(Map.of("reason", "bad path"))).hasMessage("Invalid params: bad path"); + } + + @Test + void messageShowsOtherDataWhole() { + assertThat(error(Map.of("line", 3))).hasMessage("Invalid params: {line=3}"); + assertThat(error("plain")).hasMessage("Invalid params: plain"); + } + + @Test + void theCodeIsNamedOnceWhenPrintedWithTheMessage() { + AcpError error = error(null); + assertThat(error.getCode() + " " + error.getMessage()).isEqualTo("-32602 Invalid params"); + } + + @Test + void toStringNamesTheCode() { + assertThat(error("plain")) + .hasToString("com.agentclientprotocol.sdk.spec.AcpError: Invalid params [code=-32602]: plain"); + } + + @Test + void aPeerErrorIsAnAcpException() { + AcpError error = error(null); + assertThat(error).isInstanceOf(AcpException.class); + AcpException caught = null; + try { + fail(error); + } + catch (AcpException e) { + caught = e; + } + catch (RuntimeException e) { + // not an AcpException + } + assertThat(caught).isSameAs(error); + assertThat(caught.getMessage()).isEqualTo("Invalid params"); + } + + private static void fail(RuntimeException failure) { + throw failure; + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpJsonRpcMessageTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpJsonRpcMessageTest.java index 3a746622..1dd922a7 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpJsonRpcMessageTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpJsonRpcMessageTest.java @@ -142,13 +142,46 @@ void deserializeUnknownMessageTypeThrowsException() { String json = """ { "jsonrpc": "2.0", - "unknownField": "value" + "unknownField": "someone@example.com" } """; + // The transports log this exception: it must not carry the peer's payload. assertThatThrownBy(() -> AcpSchema.deserializeJsonRpcMessage(jsonMapper, json)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot deserialize JSONRPCMessage") + .hasMessageNotContaining("someone@example.com"); + } + + @Test + void deserializeJsonNullLiteralThrowsException() { + // "null" is valid JSON but no JSON-RPC message; it must be rejected like any other + // unrecognized structure, not fail with a NullPointerException. + assertThatThrownBy(() -> AcpSchema.deserializeJsonRpcMessage(jsonMapper, "null")) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Cannot deserialize JSONRPCMessage"); } + /** + * A request or notification JSON-RPC 2.0 does not allow is refused with an exception that + * every transport logs at WARN or ERROR: its message names the field at fault and never + * quotes the text, which is the peer's payload. + */ + @Test + void anInvalidRequestIsRefusedWithoutQuotingItsText() { + String params = "\"params\":{\"sessionId\":\"s\",\"prompt\":[{\"type\":\"text\",\"text\":\"SECRET-123\"}]}"; + java.util.Map invalid = java.util.Map.of( + "jsonrpc must be", "{\"id\":1,\"method\":\"session/prompt\"," + params + "}", + "jsonrpc must be \"2.0\"", "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"session/prompt\"," + params + "}", + "method must be a string", "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":42," + params + "}", + "id must be a string, number or null", + "{\"jsonrpc\":\"2.0\",\"id\":{\"x\":\"SECRET-123\"},\"method\":\"session/prompt\"," + params + "}"); + + invalid.forEach((fault, json) -> assertThatThrownBy(() -> AcpSchema.deserializeJsonRpcMessage(jsonMapper, json)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid Request") + .hasMessageContaining(fault) + .hasMessageNotContaining("SECRET-123")); + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpSchemaSerializationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpSchemaSerializationTest.java index 5335d284..6baf6adb 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpSchemaSerializationTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/AcpSchemaSerializationTest.java @@ -76,7 +76,6 @@ void newSessionRequestSerialization() throws IOException { } @Test - @SuppressWarnings("removal") // asserts on the deprecated-for-removal models field void newSessionResponseSerialization() throws IOException { AcpSchema.NewSessionResponse response = AcpTestFixtures.createNewSessionResponse(); @@ -87,7 +86,6 @@ void newSessionResponseSerialization() throws IOException { assertThat(deserialized.sessionId()).isEqualTo("test-session-id"); assertThat(deserialized.modes()).isNotNull(); - assertThat(deserialized.models()).isNotNull(); } @Test @@ -307,21 +305,6 @@ void sessionModeStateSerialization() throws IOException { assertThat(deserialized.availableModes().get(0).id()).isEqualTo("code"); } - @Test - @SuppressWarnings("removal") // exercises the deprecated-for-removal SessionModelState - void sessionModelStateSerialization() throws IOException { - AcpSchema.SessionModelState state = AcpTestFixtures.createSessionModelState(); - - String json = jsonMapper.writeValueAsString(state); - AcpSchema.SessionModelState deserialized = jsonMapper.readValue(json, - new TypeRef() { - }); - - assertThat(deserialized.currentModelId()).isEqualTo("test-model"); - assertThat(deserialized.availableModels()).hasSize(1); - assertThat(deserialized.availableModels().get(0).modelId()).isEqualTo("test-model"); - } - // --------------------------- // _meta Field Serialization Tests // --------------------------- @@ -418,8 +401,7 @@ void sessionUpdateWithMetaSerialization() throws IOException { @Test void messageChunkWithMessageIdSerialization() throws IOException { - AcpSchema.AgentMessageChunk update = new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Hello"), "msg-42"); + AcpSchema.AgentMessageChunk update = new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("Hello"), "msg-42"); String json = jsonMapper.writeValueAsString(update); assertThat(json).contains("\"messageId\":\"msg-42\""); @@ -433,8 +415,7 @@ void messageChunkWithMessageIdSerialization() throws IOException { @Test void messageChunkWithoutMessageIdOmitsField() throws IOException { - AcpSchema.AgentMessageChunk update = new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Hello")); + AcpSchema.AgentMessageChunk update = new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("Hello")); String json = jsonMapper.writeValueAsString(update); assertThat(json).doesNotContain("messageId"); @@ -512,7 +493,7 @@ void listProvidersResponseSerialization() throws IOException { assertThat(deserialized.providers()).hasSize(1); AcpSchema.ProviderInfo p = deserialized.providers().get(0); - assertThat(p.id()).isEqualTo("openai"); + assertThat(p.providerId()).isEqualTo("openai"); assertThat(p.supported()).containsExactly("openai", "azure"); assertThat(p.required()).isFalse(); assertThat(p.current().baseUrl()).isEqualTo("https://api.openai.com/v1"); @@ -524,18 +505,52 @@ void setProviderRequestSerialization() throws IOException { "https://api.anthropic.com", Map.of("x-api-version", "2026-01-01")); String json = jsonMapper.writeValueAsString(request); - assertThat(json).contains("\"id\":\"main\"").contains("\"apiType\":\"anthropic\"").contains("\"headers\""); + assertThat(json).contains("\"providerId\":\"main\"").contains("\"apiType\":\"anthropic\"").contains("\"headers\""); AcpSchema.SetProviderRequest deserialized = jsonMapper.readValue(json, new TypeRef() { }); - assertThat(deserialized.id()).isEqualTo("main"); + assertThat(deserialized.providerId()).isEqualTo("main"); assertThat(deserialized.apiType()).isEqualTo("anthropic"); assertThat(deserialized.baseUrl()).isEqualTo("https://api.anthropic.com"); assertThat(deserialized.headers()).containsEntry("x-api-version", "2026-01-01"); } + // The unstable schema (schema.unstable.json) names the provider identifier "providerId" in + // ProviderInfo, SetProviderRequest and DisableProviderRequest, and requires it. + + @Test + void providerInfoRoundTripsTheSchemaShape() throws IOException { + String json = "{\"providerId\":\"openai\",\"supported\":[\"openai\",\"azure\"],\"required\":false," + + "\"current\":{\"apiType\":\"openai\",\"baseUrl\":\"https://api.openai.com/v1\"}}"; + var expected = new AcpSchema.ProviderInfo("openai", List.of("openai", "azure"), false, + new AcpSchema.ProviderCurrentConfig("openai", "https://api.openai.com/v1")); + + assertThat(jsonMapper.readValue(json, AcpSchema.ProviderInfo.class)).isEqualTo(expected); + assertThat(jsonMapper.writeValueAsString(expected)).isEqualTo(json); + } + + @Test + void setProviderRequestRoundTripsTheSchemaShape() throws IOException { + String json = "{\"providerId\":\"main\",\"apiType\":\"anthropic\",\"baseUrl\":\"https://api.anthropic.com\"," + + "\"headers\":{\"x-api-version\":\"2026-01-01\"}}"; + var expected = new AcpSchema.SetProviderRequest("main", "anthropic", "https://api.anthropic.com", + Map.of("x-api-version", "2026-01-01")); + + assertThat(jsonMapper.readValue(json, AcpSchema.SetProviderRequest.class)).isEqualTo(expected); + assertThat(jsonMapper.writeValueAsString(expected)).isEqualTo(json); + } + + @Test + void disableProviderRequestRoundTripsTheSchemaShape() throws IOException { + String json = "{\"providerId\":\"openai\"}"; + var expected = new AcpSchema.DisableProviderRequest("openai"); + + assertThat(jsonMapper.readValue(json, AcpSchema.DisableProviderRequest.class)).isEqualTo(expected); + assertThat(jsonMapper.writeValueAsString(expected)).isEqualTo(json); + } + // --------------------------- // Elicitation Serialization // --------------------------- @@ -575,13 +590,13 @@ void elicitationPropertySchemaPolymorphism() throws IOException { // All 5 property types should round-trip correctly Map props = Map.of( "text", new AcpSchema.StringPropertySchema("string", "Name", null, "default", null, null, - null, null, null, null), + null, null, null, null, null), "count", new AcpSchema.IntegerPropertySchema("integer", "Count", null, 5L, 1L, 100L), "ratio", new AcpSchema.NumberPropertySchema("number", "Ratio", null, 0.5, 0.0, 1.0), "agree", new AcpSchema.BooleanPropertySchema("boolean", "Agree?", null, false), "tags", new AcpSchema.MultiSelectPropertySchema("array", "Tags", null, null, new AcpSchema.UntitledMultiSelectItems("string", List.of("a", "b", "c")), - null, null)); + null, null, null)); var schema = new AcpSchema.ElicitationSchema("object", props, null, null, null); String json = jsonMapper.writeValueAsString(schema); @@ -674,7 +689,7 @@ void sessionConfigOptionPolymorphism() throws IOException { assertThat(deserialized).isInstanceOf(AcpSchema.SessionConfigSelect.class); AcpSchema.SessionConfigSelect asSelect = (AcpSchema.SessionConfigSelect) deserialized; assertThat(asSelect.currentValue()).isEqualTo("code"); - assertThat(asSelect.options()).hasSize(2); + assertThat(asSelect.options().allOptions()).hasSize(2); } @Test @@ -708,8 +723,7 @@ void setSessionConfigOptionRequestSelectMode() throws IOException { @Test void configOptionUpdateAsSessionUpdate() throws IOException { - var update = new AcpSchema.ConfigOptionUpdate("config_option_update", - List.of(new AcpSchema.SessionConfigSelect("mode", "Mode", "code", + var update = new AcpSchema.ConfigOptionUpdate(List.of(new AcpSchema.SessionConfigSelect("mode", "Mode", "code", List.of(new AcpSchema.SessionConfigSelectOption("code", "Code"))))); String json = jsonMapper.writeValueAsString(update); @@ -724,7 +738,7 @@ void configOptionUpdateAsSessionUpdate() throws IOException { void elicitationCapabilitiesOnClientCapabilities() throws IOException { var caps = new AcpSchema.ClientCapabilities( new AcpSchema.FileSystemCapability(true, true), true, - new AcpSchema.ElicitationCapabilities(), null); + null, null, AcpSchema.ElicitationCapabilities.formOnly(), null); String json = jsonMapper.writeValueAsString(caps); assertThat(json).contains("\"elicitation\""); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsLincheckTest.java new file mode 100644 index 00000000..ecb6fc23 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsLincheckTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.QuietLoggers; +import org.jetbrains.lincheck.datastructures.IntGen; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Model checks the single-turn rule of {@link ActivePrompts} with Lincheck: every + * interleaving of the operations below, on two threads plus a sequential prefix and suffix, + * must produce results some sequential execution of the specification could produce. + * + *

    + * {@link Turns} checks the rule itself (at most one active prompt per session; a cancel + * does not end the turn, the cancelled prompt's response does) and the views of it: it found + * the active-session count reading ConcurrentHashMap's size counters, which can show no + * active prompt while one is active. {@link Wire} + * checks #14: an agent that ends a prompt and a client that sends its next prompt as soon as + * it sees the response must never see that prompt rejected. In both, a cancel does not end + * the turn: a prompt sent after session/cancel but before the cancelled prompt has answered + * is rejected (ACP v1, prompt turn, Cancellation). + *

    + */ +class ActivePromptsLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static @Nullable QuietLoggers quiet; + + @BeforeAll + static void quietLogs() { + quiet = QuietLoggers.of(ActivePrompts.class); + } + + @AfterAll + static void restoreLogs() { + if (quiet != null) { + quiet.close(); + } + } + + + /** Bounded, so CI time is predictable; scenarios and interleavings are seeded. */ + private static ModelCheckingOptions modelChecking() { + return new ModelCheckingOptions().iterations(20 * SCALE) + .invocationsPerIteration(200) + .threads(2) + .actorsPerThread(3) + .actorsBefore(1) + .actorsAfter(1); + } + + @Test + void turnsAreLinearizable() { + modelChecking().sequentialSpecification(TurnsSpec.class).check(Turns.class); + } + + + @Test + void aClientThatSawTheResponseIsNeverRejected() { + modelChecking().sequentialSpecification(WireSpec.class).check(Wire.class); + } + + /** Two logical sessions on one connection; a prompt ends through the session's own pipeline. */ + @Param(name = "session", gen = IntGen.class, conf = "0:1") + public static class Turns { + + private final ActivePrompts prompts = new ActivePrompts(); + + @Operation + public boolean start(@Param(name = "session") int session) { + return prompts.tryStart("s" + session, session) != null; + } + + /** The prompt holding the session publishes its response, which ends its turn. */ + @Operation + public void finish(@Param(name = "session") int session) { + ActivePrompts.Turn turn = prompts.current("s" + session); + if (turn != null) { + prompts.endBeforePublishing(turn, Mono.just("response")).subscribe(); + } + } + + @Operation + public boolean cancel(@Param(name = "session") int session) { + return prompts.cancel("s" + session); + } + + /** A client that sends session/cancel and then a prompt without waiting for the answer. */ + @Operation + public boolean cancelThenStart(@Param(name = "session") int session) { + prompts.cancel("s" + session); + return prompts.tryStart("s" + session, session) != null; + } + + @Operation + public boolean isActive(@Param(name = "session") int session) { + return prompts.isActive("s" + session); + } + + @Operation + public int activeCount() { + return prompts.sessionIds().size(); + } + + } + + /** Sequential specification of {@link Turns}: a set of the sessions with an active prompt. */ + public static class TurnsSpec { + + private final Set active = new HashSet<>(); + + public boolean start(int session) { + return active.add(session); + } + + public void finish(int session) { + active.remove(session); + } + + /** session/cancel: the turn stays until its prompt answers (ACP v1, prompt turn, Cancellation). */ + public boolean cancel(int session) { + return active.contains(session); + } + + /** Rejected while the cancelled prompt has not answered. */ + public boolean cancelThenStart(int session) { + return active.add(session); + } + + public boolean isActive(int session) { + return active.contains(session); + } + + public int activeCount() { + return active.size(); + } + + } + + /** + * One agent thread ends the active prompt and publishes its response on the wire; one + * client thread sends the next prompt only once it has read a response off the wire. + * A prompt is active at the start. + */ + public static class Wire { + + private final ActivePrompts prompts = new ActivePrompts(); + + private final AtomicBoolean responseOnWire = new AtomicBoolean(); + + public Wire() { + prompts.tryStart("s", 0); + } + + @Operation(nonParallelGroup = "agent") + public void respond() { + ActivePrompts.Turn turn = prompts.current("s"); + if (turn != null) { + prompts.endBeforePublishing(turn, Mono.just("response")) + .subscribe(response -> responseOnWire.set(true)); + } + } + + @Operation(nonParallelGroup = "client") + public String nextPrompt() { + if (!responseOnWire.getAndSet(false)) { + return "WAITING"; + } + return prompts.tryStart("s", 1) != null ? "ACCEPTED" : "REJECTED"; + } + + } + + /** Sequential specification of {@link Wire}: the response is published after the turn ends. */ + public static class WireSpec { + + private boolean active = true; + + private boolean responseOnWire; + + public void respond() { + if (active) { + active = false; + responseOnWire = true; + } + } + + public String nextPrompt() { + if (!responseOnWire) { + return "WAITING"; + } + responseOnWire = false; + if (active) { + return "REJECTED"; + } + active = true; + return "ACCEPTED"; + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsTest.java new file mode 100644 index 00000000..3cf94f0e --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ActivePromptsTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The single-turn rule, one operation at a time. ActivePromptsLincheckTest checks the same + * rule under every interleaving; these cover what each operation reports. + */ +class ActivePromptsTest { + + private final ActivePrompts prompts = new ActivePrompts(); + + @Test + void aTurnEndsOnlyWhileItHoldsItsSession() { + ActivePrompts.Turn turn = prompts.tryStart("s", 1); + assertThat(turn).isNotNull(); + assertThat(prompts.current("s")).isSameAs(turn); + assertThat(turn.requestId()).isEqualTo(1); + + assertThat(prompts.end(turn, "response")).isTrue(); + assertThat(prompts.end(turn, "onComplete")).as("idempotent").isFalse(); + assertThat(prompts.current("s")).isNull(); + } + + @Test + void cancelKeepsTheTurnUntilTheCancelledPromptEnds() { + ActivePrompts.Turn cancelled = prompts.tryStart("s", 1); + assertThat(cancelled).isNotNull(); + + assertThat(prompts.cancel("s")).as("a prompt was active").isTrue(); + assertThat(prompts.current("s")).as("the turn ends with its response, not the cancel").isSameAs(cancelled); + assertThat(prompts.tryStart("s", 2)).as("the session is still busy").isNull(); + + assertThat(prompts.end(cancelled, "response")).isTrue(); + assertThat(prompts.cancel("s")).as("nothing left to cancel").isFalse(); + assertThat(prompts.tryStart("s", 2)).isNotNull(); + } + + @Test + void aTurnFromBeforeClearDoesNotReleaseTheNextPromptWithTheSameRequestId() { + ActivePrompts.Turn stale = prompts.tryStart("s", 1); + assertThat(stale).isNotNull(); + prompts.clear(); + + ActivePrompts.Turn next = prompts.tryStart("s", 1); + assertThat(next).isNotNull(); + assertThat(prompts.end(stale, "response")).isFalse(); + assertThat(prompts.current("s")).isSameAs(next); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/BooleanConfigOptionTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/BooleanConfigOptionTest.java new file mode 100644 index 00000000..b271f5bd --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/BooleanConfigOptionTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Boolean config options (stable in ACP v1 since 2026-07-06) and the client capability + * {@code session.configOptions.boolean} that allows an agent to send them. + */ +class BooleanConfigOptionTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + @Test + void booleanOptionIsStable() { + assertThat(AcpSchema.SessionConfigBoolean.class.isAnnotationPresent(UnstableAcpApi.class)).isFalse(); + } + + @Test + void booleanOptionRoundTrips() throws Exception { + String json = """ + {"type":"boolean","id":"web","name":"Web search","description":"Search the web",\ + "category":"_tools","currentValue":true,"_meta":{"k":"v"}}"""; + + var option = mapper.readValue(json, AcpSchema.SessionConfigOption.class); + + assertThat(option).isEqualTo(new AcpSchema.SessionConfigBoolean("boolean", "web", "Web search", + "Search the web", "_tools", true, Map.of("k", "v"))); + assertWritesBack(option, json); + } + + @Test + void setBooleanOptionRequestRoundTrips() throws Exception { + String json = """ + {"sessionId":"s","configId":"web","value":false,"type":"boolean"}"""; + + assertWritesBack(AcpSchema.SetSessionConfigOptionRequest.bool("s", "web", false), json); + assertThat(mapper.readValue(json, AcpSchema.SetSessionConfigOptionRequest.class)) + .isEqualTo(AcpSchema.SetSessionConfigOptionRequest.bool("s", "web", false)); + } + + @Test + void clientAdvertisesBooleanConfigOptions() throws Exception { + String json = """ + {"session":{"configOptions":{"boolean":{}}}}"""; + + var caps = mapper.readValue(json, AcpSchema.ClientCapabilities.class); + + assertThat(caps.session()).isEqualTo( + new AcpSchema.ClientSessionCapabilities(AcpSchema.SessionConfigOptionsCapabilities.withBoolean())); + assertWritesBack(caps, json); + var negotiated = NegotiatedCapabilities.fromClient(caps); + assertThat(negotiated.supportsBooleanConfigOptions()).isTrue(); + negotiated.requireBooleanConfigOptions(); + } + + @Test + void booleanConfigOptionsAreOffUnlessAdvertised() throws Exception { + for (String json : new String[] { "{}", "{\"session\":{}}", "{\"session\":{\"configOptions\":{}}}" }) { + var negotiated = NegotiatedCapabilities.fromClient(mapper.readValue(json, AcpSchema.ClientCapabilities.class)); + + assertThat(negotiated.supportsBooleanConfigOptions()).as(json).isFalse(); + assertThatThrownBy(negotiated::requireBooleanConfigOptions).isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("session.configOptions.boolean"); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelRequestTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelRequestTest.java new file mode 100644 index 00000000..eee950d2 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelRequestTest.java @@ -0,0 +1,732 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BooleanSupplier; +import java.util.function.Function; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.MockAcpClientTransport; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentSessionPromptLockTest.CapturingAgentTransport; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code $/cancel_request} (ACP v1, Cancellation) at the session level, both as the side that + * cancels a request it sent and as the side that answers a cancelled request. The receiver + * cancels the handler and answers exactly once: with the handler's result when that came + * first, otherwise with {@code -32800}. + */ +class CancelRequestTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + /** Waits, at most {@link #TIMEOUT}, for a condition another thread makes true. */ + private static void eventually(BooleanSupplier condition) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("condition not met within " + TIMEOUT); + } + try { + Thread.sleep(5); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + } + + private static AcpSchema.JSONRPCRequest request(Object id, String method, Object params) { + return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, method, params); + } + + /** A {@code $/cancel_request} as it arrives off the wire: params are a map. */ + private static AcpSchema.JSONRPCNotification cancelRequest(Object requestId) { + return new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, AcpSchema.METHOD_CANCEL_REQUEST, + Map.of("requestId", requestId)); + } + + private static AcpSchema.JSONRPCRequest prompt(String id) { + return request(id, AcpSchema.METHOD_SESSION_PROMPT, + new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("prompt " + id)))); + } + + /** Subscribes to the session's answer to one inbound message and collects what it emits. */ + private static List answers(CapturingAgentTransport transport, + AcpSchema.JSONRPCMessage message) { + List answers = new CopyOnWriteArrayList<>(); + transport.deliver(message).subscribe(answers::add); + return answers; + } + + private static AcpSchema.JSONRPCResponse single(List answers) { + eventually(() -> !answers.isEmpty()); + assertThat(answers).hasSize(1); + assertThat(answers.get(0)).isInstanceOf(AcpSchema.JSONRPCResponse.class); + return (AcpSchema.JSONRPCResponse) answers.get(0); + } + + // --------------------------------------------------------------------------- + // Receiving side: the agent answers a cancelled request + // --------------------------------------------------------------------------- + + @Test + void theAgentCancelsTheHandlerAndAnswersRequestCancelled() { + AtomicBoolean handlerCancelled = new AtomicBoolean(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_slow", params -> Mono.never().doOnCancel(() -> handlerCancelled.set(true))), Map.of()); + + List answers = answers(transport, request("r1", "_slow", Map.of())); + assertThat(answers).isEmpty(); + transport.deliver(cancelRequest("r1")).block(TIMEOUT); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.id()).isEqualTo("r1"); + assertThat(response.result()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + assertThat(response.error().message()).isEqualTo("Request cancelled"); + assertThat(handlerCancelled).isTrue(); + } + + @Test + void aNumericIdMatchesWhateverIntegerTypeTheMapperChose() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of("_slow", params -> Mono.never()), Map.of()); + + List answers = answers(transport, request(7, "_slow", Map.of())); + transport.deliver(cancelRequest(7L)).block(TIMEOUT); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.id()).isEqualTo(7); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + } + + @Test + void aStringIdDoesNotMatchTheSameDigitsAsANumber() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of("_slow", params -> Mono.never()), Map.of()); + + List answers = answers(transport, request(7, "_slow", Map.of())); + transport.deliver(cancelRequest("7")).block(TIMEOUT); + + assertThat(answers).as("\"7\" and 7 are different JSON-RPC ids").isEmpty(); + } + + @Test + void aCancelAfterTheResponseIsIgnored() { + AtomicInteger calls = new AtomicInteger(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_fast", params -> Mono.fromSupplier(() -> Map.of("n", calls.incrementAndGet()))), Map.of()); + + List answers = answers(transport, request("r1", "_fast", Map.of())); + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.error()).isNull(); + + List afterCancel = answers(transport, cancelRequest("r1")); + assertThat(afterCancel).isEmpty(); + assertThat(answers).hasSize(1); + assertThat(transport.sent).isEmpty(); + } + + @Test + void aCancelForAnUnknownIdIsIgnored() { + AtomicBoolean handlerCancelled = new AtomicBoolean(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_slow", params -> Mono.never().doOnCancel(() -> handlerCancelled.set(true))), Map.of()); + + List answers = answers(transport, request("r1", "_slow", Map.of())); + assertThat(answers(transport, cancelRequest("other"))).isEmpty(); + + assertThat(answers).isEmpty(); + assertThat(handlerCancelled).isFalse(); + assertThat(transport.sent).isEmpty(); + } + + @Test + void aCancelRequestWithInvalidParamsIsIgnored() { + AtomicBoolean handlerCancelled = new AtomicBoolean(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_slow", params -> Mono.never().doOnCancel(() -> handlerCancelled.set(true))), Map.of()); + + List answers = answers(transport, request("r1", "_slow", Map.of())); + for (Object params : List.of(Map.of(), Map.of("requestId", 1.5), Map.of("requestId", true), + Map.of("requestId", Map.of("id", "r1")), List.of("r1"))) { + transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_CANCEL_REQUEST, params)) + .block(TIMEOUT); + } + + assertThat(answers).isEmpty(); + assertThat(handlerCancelled).isFalse(); + assertThat(transport.sent).isEmpty(); + } + + @Test + void aCancelRequestIsNotOfferedToTheNotificationHandlers() { + AtomicBoolean offered = new AtomicBoolean(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(), Map.of(AcpSchema.METHOD_CANCEL_REQUEST, params -> { + offered.set(true); + return Mono.empty(); + })); + + transport.deliver(cancelRequest("r1")).block(TIMEOUT); + + assertThat(offered).isFalse(); + } + + @Test + void aCancelledPromptEndsItsTurnBeforeTheCancelledAnswerIsPublished() { + AtomicInteger prompts = new AtomicInteger(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transport, + Map.of(AcpSchema.METHOD_SESSION_PROMPT, params -> prompts.incrementAndGet() == 1 ? Mono.never() + : Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))), + Map.of()); + + AtomicReference next = new AtomicReference<>(); + List answers = new CopyOnWriteArrayList<>(); + transport.deliver(prompt("1")) + // The client sends its next prompt the instant the cancelled answer arrives. + .doOnNext(answer -> next.set(transport.deliver(prompt("2")).block(TIMEOUT))) + .subscribe(answers::add); + assertThat(session.hasActivePrompt("s1")).isTrue(); + + transport.deliver(cancelRequest("1")).block(TIMEOUT); + + AcpSchema.JSONRPCResponse cancelled = single(answers); + assertThat(cancelled.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + assertThat(next.get()).isInstanceOf(AcpSchema.JSONRPCResponse.class); + assertThat(((AcpSchema.JSONRPCResponse) next.get()).error()) + .as("the next prompt must not be rejected as concurrent") + .isNull(); + assertThat(session.hasActivePrompt()).isFalse(); + } + + @Test + void aHandlerThatFailsWithCancellationExceptionIsAnsweredRequestCancelled() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_gone", params -> Mono.error(new CancellationException("context limit reached"))), Map.of()); + + AcpSchema.JSONRPCResponse response = single(answers(transport, request("r1", "_gone", Map.of()))); + + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + assertThat(response.error().message()).isEqualTo("context limit reached"); + } + + @Test + void aRequestIdIsCancellableAgainOnceItsFirstUseAnswered() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + AtomicInteger calls = new AtomicInteger(); + new AcpAgentSession(TIMEOUT, transport, + Map.of("_x", params -> calls.incrementAndGet() == 1 ? Mono.just(Map.of()) : Mono.never()), Map.of()); + + single(answers(transport, request("r1", "_x", Map.of()))); + List second = answers(transport, request("r1", "_x", Map.of())); + transport.deliver(cancelRequest("r1")).block(TIMEOUT); + + assertThat(single(second).error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + } + + @Test + void aPromptAlreadyCancelledBySessionCancelAnswersStopReasonCancelled() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, params -> Mono.never()), + Map.of()); + + List answers = answers(transport, prompt("1")); + transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_CANCEL, Map.of("sessionId", "s1"))) + .block(TIMEOUT); + transport.deliver(cancelRequest("1")).block(TIMEOUT); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.error()).isNull(); + assertThat(response.result()).isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + } + + @Test + void aPromptHandlerAbortedAfterSessionCancelAnswersStopReasonCancelled() { + Sinks.Empty abort = Sinks.empty(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> abort.asMono().then(Mono.error(new CancellationException("aborted")))), Map.of()); + + List answers = answers(transport, prompt("1")); + transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_CANCEL, Map.of("sessionId", "s1"))) + .block(TIMEOUT); + abort.tryEmitEmpty(); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.error()).isNull(); + assertThat(response.result()).isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED)); + } + + @Test + void anInterruptedBlockingHandlerIsAnsweredRequestCancelledNotInternalError() throws InterruptedException { + ExecutorService pool = Executors.newCachedThreadPool(); + Scheduler blocking = Schedulers.fromExecutorService(pool, "blocking-handler"); + try { + CapturingAgentTransport transport = new CapturingAgentTransport(); + AtomicReference started = new AtomicReference<>(); + AcpAgentSession.RequestHandler handler = params -> Mono.fromCallable(() -> { + started.get().countDown(); + Thread.sleep(TIMEOUT.toMillis()); + return Map.of(); + }).subscribeOn(blocking); + new AcpAgentSession(TIMEOUT, transport, Map.of("_blocking", handler), Map.of()); + + // The interrupt the cancel causes races the cancel itself; run it often. + for (int i = 0; i < 50; i++) { + CountDownLatch running = new CountDownLatch(1); + started.set(running); + List answers = answers(transport, request("b" + i, "_blocking", Map.of())); + assertThat(running.await(TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)).isTrue(); + transport.deliver(cancelRequest("b" + i)).block(TIMEOUT); + assertThat(single(answers).error().code()).as("attempt %d", i) + .isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + } + } + finally { + blocking.dispose(); + } + } + + @Test + void closingTheAgentSessionCancelsTheRequestsItIsHandling() { + AtomicBoolean handlerCancelled = new AtomicBoolean(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transport, + Map.of("_slow", params -> Mono.never().doOnCancel(() -> handlerCancelled.set(true))), Map.of()); + + List answers = answers(transport, request("r1", "_slow", Map.of())); + session.close(); + + assertThat(handlerCancelled).isTrue(); + assertThat(single(answers).error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + // A request arriving after the close is cancelled at once. + assertThat(single(answers(transport, request("r2", "_slow", Map.of()))).error().code()) + .isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + } + + // --------------------------------------------------------------------------- + // Receiving side: the client answers a cancelled request + // --------------------------------------------------------------------------- + + @Test + void theClientAnswersACancelledPermissionRequestWithoutWaitingForItsNotificationDrain() { + // The request waits behind the slow session/update before it (InboundOrder); a cancel + // still answers it at once, and its handler is never called. + AtomicBoolean handlerCalled = new AtomicBoolean(); + Sinks.Empty releaseUpdate = Sinks.empty(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, + Map.of(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, + params -> Mono.never().doOnSubscribe(s -> handlerCalled.set(true))), + // A slow session/update handler holds the ordered notification drain. + Map.of(AcpSchema.METHOD_SESSION_UPDATE, params -> releaseUpdate.asMono()), Function.identity()); + try { + transport.simulateIncomingMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_UPDATE, Map.of())); + transport.simulateIncomingMessage( + request("p1", AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, Map.of("sessionId", "s1"))); + transport.simulateIncomingMessage(cancelRequest("p1")); + + eventually(() -> !transport.getSentMessages().isEmpty()); + assertThat(transport.getSentMessages()).hasSize(1); + AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response.id()).isEqualTo("p1"); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + + releaseUpdate.tryEmitEmpty(); + assertThat(handlerCalled).isFalse(); + assertThat(transport.getSentMessages()).hasSize(1); + } + finally { + releaseUpdate.tryEmitEmpty(); + session.close(); + } + } + + @Test + void theClientCancelsARunningPermissionHandler() { + AtomicBoolean handlerCancelled = new AtomicBoolean(); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, + Map.of(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, + params -> Mono.never().doOnCancel(() -> handlerCancelled.set(true))), + Map.of(), Function.identity()); + try { + transport.simulateIncomingMessage( + request("p1", AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, Map.of("sessionId", "s1"))); + transport.simulateIncomingMessage(cancelRequest("p1")); + + eventually(() -> !transport.getSentMessages().isEmpty()); + AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) transport.getLastSentMessage(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED); + assertThat(handlerCancelled).isTrue(); + } + finally { + session.close(); + } + } + + @Test + void theClientIgnoresACancelForAnUnknownId() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + transport.simulateIncomingMessage(cancelRequest("nope")); + assertThat(transport.getSentMessages()).isEmpty(); + } + finally { + session.close(); + } + } + + // --------------------------------------------------------------------------- + // Sending side: cancelling a request sent to the peer + // --------------------------------------------------------------------------- + + private static List cancelsSent(MockAcpClientTransport transport) { + return transport.getSentMessages() + .stream() + .filter(AcpSchema.JSONRPCNotification.class::isInstance) + .map(AcpSchema.JSONRPCNotification.class::cast) + .filter(n -> AcpSchema.METHOD_CANCEL_REQUEST.equals(n.method())) + .toList(); + } + + private static Object requestIdOf(AcpSchema.JSONRPCNotification cancel) { + assertThat(cancel.params()).isInstanceOf(AcpSchema.CancelRequestNotification.class); + return ((AcpSchema.CancelRequestNotification) cancel.params()).requestId(); + } + + @Test + void disposingARequestSendsOneCancelRequestWithItsId() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Disposable request = session.sendRequest("_slow", Map.of(), MAP).subscribe(); + Object id = transport.getLastSentMessageAsRequest().id(); + + request.dispose(); + request.dispose(); + + eventually(() -> !cancelsSent(transport).isEmpty()); + assertThat(cancelsSent(transport)).hasSize(1); + assertThat(requestIdOf(cancelsSent(transport).get(0))).isEqualTo(id); + } + finally { + session.close(); + } + } + + @Test + void aRequestCancelledBeforeItIsCreatedIsNeitherSentNorCancelled() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + // takeUntilOther with a trigger that has already fired cancels the request's + // subscription before Mono.create runs its callback. + session.sendRequest("_slow", Map.of(), MAP).takeUntilOther(Mono.just("now")).subscribe(); + + assertThat(transport.getSentMessages()).isEmpty(); + } + finally { + session.close(); + } + } + + @Test + void eachSubscriptionToARequestIsANewRequestWithItsOwnId() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Mono> request = session.sendRequest("_x", Map.of(), MAP); + request.subscribe(); + request.subscribe(); + + assertThat(transport.getSentMessages()).hasSize(2) + .extracting(message -> ((AcpSchema.JSONRPCRequest) message).id()) + .doesNotHaveDuplicates(); + } + finally { + session.close(); + } + } + + @Test + void theCancelIsSentOnlyAfterItsRequestWasWritten() { + Sinks.Empty gate = Sinks.empty(); + List written = new CopyOnWriteArrayList<>(); + CapturingAgentTransport transport = new CapturingAgentTransport() { + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCRequest) { + return gate.asMono().doOnSuccess(v -> written.add(message)); + } + written.add(message); + return Mono.empty(); + } + }; + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transport, Map.of(), Map.of()); + + session.sendRequest("_slow", Map.of(), MAP).subscribe().dispose(); + gate.tryEmitEmpty(); + + eventually(() -> written.size() == 2); + assertThat(written.get(0)).isInstanceOf(AcpSchema.JSONRPCRequest.class); + assertThat(((AcpSchema.JSONRPCNotification) written.get(1)).method()) + .isEqualTo(AcpSchema.METHOD_CANCEL_REQUEST); + } + + @Test + void aRequestThatFailedToSendIsNotCancelled() throws InterruptedException { + CapturingAgentTransport transport = new CapturingAgentTransport() { + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + sent.add(message); + if (message instanceof AcpSchema.JSONRPCRequest) { + throw new IllegalStateException("cannot write"); + } + return Mono.empty(); + } + }; + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transport, Map.of(), Map.of()); + AtomicReference failure = new AtomicReference<>(); + + Disposable request = session.sendRequest("_x", Map.of(), MAP).subscribe(v -> { + }, failure::set); + request.dispose(); + + Thread.sleep(100); + assertThat(failure.get()).hasMessage("cannot write"); + assertThat(transport.sent).hasSize(1); + } + + @Test + void aGracefullyCancelledRequestSendsTheCancelAndEndsWithThePeersAnswer() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Sinks.Empty stop = Sinks.empty(); + AtomicReference failure = new AtomicReference<>(); + Disposable request = session.sendRequest("_slow", Map.of(), MAP) + .contextWrite(RequestCancellation.cancelWhen(stop.asMono())) + .subscribe(v -> { + }, failure::set); + Object id = transport.getLastSentMessageAsRequest().id(); + + stop.tryEmitEmpty(); + eventually(() -> !cancelsSent(transport).isEmpty()); + assertThat(failure.get()).as("still waiting for the peer's answer").isNull(); + transport.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, + new AcpSchema.JSONRPCError(AcpErrorCodes.REQUEST_CANCELLED, "Request cancelled", null))); + + eventually(() -> failure.get() != null); + assertThat(failure.get()).isInstanceOfSatisfying(AcpError.class, + error -> assertThat(error.getCode()).isEqualTo(AcpErrorCodes.REQUEST_CANCELLED)); + request.dispose(); + assertThat(cancelsSent(transport)).hasSize(1); + assertThat(requestIdOf(cancelsSent(transport).get(0))).isEqualTo(id); + } + finally { + session.close(); + } + } + + @Test + void aGracefulCancelThenADisposeSendsOneCancel() throws InterruptedException { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Disposable request = session.sendRequest("_slow", Map.of(), MAP) + .contextWrite(RequestCancellation.cancelWhen(Mono.just("now"))) + .subscribe(); + eventually(() -> !cancelsSent(transport).isEmpty()); + request.dispose(); + + Thread.sleep(100); + assertThat(cancelsSent(transport)).hasSize(1); + } + finally { + session.close(); + } + } + + @Test + void aGracefulTriggerAfterTheAnswerSendsNothing() throws InterruptedException { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Sinks.Empty stop = Sinks.empty(); + AtomicReference> result = new AtomicReference<>(); + session.sendRequest("_fast", Map.of(), MAP) + .contextWrite(RequestCancellation.cancelWhen(stop.asMono())) + .subscribe(result::set); + Object id = transport.getLastSentMessageAsRequest().id(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, Map.of("ok", true), null)); + eventually(() -> result.get() != null); + + stop.tryEmitEmpty(); + + Thread.sleep(100); + assertThat(cancelsSent(transport)).isEmpty(); + } + finally { + session.close(); + } + } + + @Test + void aRequestAlreadyAnsweredSendsNoCancel() throws InterruptedException { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + AtomicReference> result = new AtomicReference<>(); + Disposable request = session.sendRequest("_fast", Map.of(), MAP).subscribe(result::set); + Object id = transport.getLastSentMessageAsRequest().id(); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, Map.of("ok", true), null)); + eventually(() -> result.get() != null); + + request.dispose(); + + Thread.sleep(100); + assertThat(cancelsSent(transport)).isEmpty(); + } + finally { + session.close(); + } + } + + @Test + void aRequestThatTimesOutSendsOneCancelRequest() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(Duration.ofMillis(100), transport, Map.of(), Map.of(), + Function.identity()); + try { + AtomicReference failure = new AtomicReference<>(); + session.sendRequest("_slow", Map.of(), MAP).subscribe(v -> { + }, failure::set); + Object id = transport.getLastSentMessageAsRequest().id(); + + eventually(() -> failure.get() != null); + + eventually(() -> !cancelsSent(transport).isEmpty()); + assertThat(cancelsSent(transport)).hasSize(1); + assertThat(requestIdOf(cancelsSent(transport).get(0))).isEqualTo(id); + } + finally { + session.close(); + } + } + + @Test + void closingTheSessionSendsNoCancel() { + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + AtomicReference failure = new AtomicReference<>(); + session.sendRequest("_slow", Map.of(), MAP).subscribe(v -> { + }, failure::set); + + session.close(); + + assertThat(failure.get()).isNotNull(); + assertThat(cancelsSent(transport)).isEmpty(); + } + + @Test + void theAgentSideCancelsItsOwnRequestsToo() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + AcpAgentSession session = new AcpAgentSession(TIMEOUT, transport, Map.of(), Map.of()); + + Disposable request = session.sendRequest(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, Map.of(), MAP) + .subscribe(); + Object id = ((AcpSchema.JSONRPCRequest) transport.sent.get(0)).id(); + request.dispose(); + + eventually(() -> transport.sent.size() == 2); + AcpSchema.JSONRPCNotification cancel = (AcpSchema.JSONRPCNotification) transport.sent.get(1); + assertThat(cancel.method()).isEqualTo(AcpSchema.METHOD_CANCEL_REQUEST); + assertThat(requestIdOf(cancel)).isEqualTo(id); + } + + @Test + void aLateAnswerToACancelledRequestIsExpectedNotAWarning() { + Logger logger = (Logger) LoggerFactory.getLogger(PendingResponses.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + MockAcpClientTransport transport = new MockAcpClientTransport(); + AcpClientSession session = new AcpClientSession(TIMEOUT, transport, Map.of(), Map.of(), + Function.identity()); + try { + Disposable request = session.sendRequest("_slow", Map.of(), MAP).subscribe(); + Object id = transport.getLastSentMessageAsRequest().id(); + request.dispose(); + + transport.simulateIncomingMessage(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, null, + new AcpSchema.JSONRPCError(AcpErrorCodes.REQUEST_CANCELLED, "Request cancelled", null))); + transport.simulateIncomingMessage( + new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, "never-sent", Map.of(), null)); + + assertThat(appender.list).filteredOn(event -> event.getLevel() == Level.WARN) + .extracting(ILoggingEvent::getFormattedMessage) + .containsExactly("Unexpected response for unknown id never-sent"); + } + finally { + logger.detachAppender(appender); + session.close(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelledPromptAnswerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelledPromptAnswerTest.java new file mode 100644 index 00000000..fd3d260d --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CancelledPromptAnswerTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpAgentSessionPromptLockTest.CapturingAgentTransport; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Once {@code session/cancel} arrived for a prompt, the session answers that prompt with stop + * reason {@code cancelled}, whatever its handler then returns or fails with. + * + *

    + * ACP spec 7628b153 (agentclientprotocol/agent-client-protocol), docs/protocol/v1/prompt-turn.mdx:354, + * "the Agent MUST respond to the original session/prompt request with the cancelled stop + * reason", and :361, "Agents MUST catch these errors and return the semantically meaningful + * cancelled stop reason". + *

    + */ +class CancelledPromptAnswerTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final AcpSchema.PromptResponse CANCELLED = new AcpSchema.PromptResponse( + AcpSchema.StopReason.CANCELLED); + + // ACP spec 7628b153: prompt-turn.mdx:354, "MUST respond ... with the cancelled stop reason" + @Test + void aHandlerThatAnswersEndTurnAfterSessionCancelIsAnsweredCancelledKeepingMeta() { + Map meta = Map.of("trace", "t-1"); + Sinks.Empty release = Sinks.empty(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> release.asMono().then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN, meta)))), + Map.of()); + + List answers = answers(transport, prompt("1")); + sessionCancel(transport); + release.tryEmitEmpty(); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.error()).isNull(); + assertThat(response.result()) + .isEqualTo(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED, meta)); + } + + // ACP spec 7628b153: prompt-turn.mdx:354, "MUST respond ... with the cancelled stop reason" + @Test + void anyOtherStopReasonAfterSessionCancelIsAnsweredCancelled() { + Sinks.Empty release = Sinks.empty(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> release.asMono().then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.MAX_TOKENS)))), + Map.of()); + + List answers = answers(transport, prompt("1")); + sessionCancel(transport); + release.tryEmitEmpty(); + + assertThat(single(answers).result()).isEqualTo(CANCELLED); + } + + // ACP spec 7628b153: prompt-turn.mdx:354; without a cancel the handler's stop reason stands + @Test + void withoutSessionCancelTheHandlersStopReasonIsSentUnchanged() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, + Map.of(AcpSchema.METHOD_SESSION_PROMPT, params -> Mono.just(AcpSchema.PromptResponse.endTurn())), + Map.of()); + + assertThat(single(answers(transport, prompt("1"))).result()).isEqualTo(AcpSchema.PromptResponse.endTurn()); + } + + // ACP spec 7628b153: prompt-turn.mdx:361, "Agents MUST catch these errors and return the + // semantically meaningful cancelled stop reason" + @Test + void aHandlerThatFailsWithAnyExceptionAfterSessionCancelIsAnsweredCancelled() { + Sinks.Empty release = Sinks.empty(); + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> release.asMono().then(Mono.error(new IllegalStateException("stream closed")))), Map.of()); + + List answers = answers(transport, prompt("1")); + sessionCancel(transport); + release.tryEmitEmpty(); + + AcpSchema.JSONRPCResponse response = single(answers); + assertThat(response.error()).isNull(); + assertThat(response.result()).isEqualTo(CANCELLED); + } + + // ACP spec 7628b153: prompt-turn.mdx:361; a failure before any cancel keeps today's -32603 + @Test + void aHandlerThatFailsWithoutSessionCancelIsStillAnInternalError() { + CapturingAgentTransport transport = new CapturingAgentTransport(); + new AcpAgentSession(TIMEOUT, transport, Map.of(AcpSchema.METHOD_SESSION_PROMPT, + params -> Mono.error(new IllegalStateException("stream closed"))), Map.of()); + + AcpSchema.JSONRPCResponse response = single(answers(transport, prompt("1"))); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.INTERNAL_ERROR); + } + + private static void sessionCancel(CapturingAgentTransport transport) { + transport.deliver(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_SESSION_CANCEL, Map.of("sessionId", "s1"))) + .block(TIMEOUT); + } + + private static AcpSchema.JSONRPCRequest prompt(String id) { + return new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, AcpSchema.METHOD_SESSION_PROMPT, + new AcpSchema.PromptRequest("s1", List.of(new AcpSchema.TextContent("prompt " + id)))); + } + + private static List answers(CapturingAgentTransport transport, + AcpSchema.JSONRPCMessage message) { + List answers = new CopyOnWriteArrayList<>(); + transport.deliver(message).subscribe(answers::add); + return answers; + } + + private static AcpSchema.JSONRPCResponse single(List answers) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (answers.isEmpty()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("no answer within " + TIMEOUT); + } + Thread.onSpinWait(); + } + assertThat(answers).hasSize(1); + assertThat(answers.get(0)).isInstanceOf(AcpSchema.JSONRPCResponse.class); + return (AcpSchema.JSONRPCResponse) answers.get(0); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CapabilitiesBuilderTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CapabilitiesBuilderTest.java new file mode 100644 index 00000000..b2cf06c9 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/CapabilitiesBuilderTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link AcpSchema.ClientCapabilities#builder()} and + * {@link AcpSchema.AgentCapabilities#builder()} reach every field without positional + * {@code null}s, and start from the defaults of the no-argument constructors. + */ +class CapabilitiesBuilderTest { + + @Test + void emptyClientBuilderIsTheDefaultCapabilities() { + assertThat(AcpSchema.ClientCapabilities.builder().build()).isEqualTo(new AcpSchema.ClientCapabilities()); + } + + @Test + void clientBuilderAdvertisesBooleanConfigOptions() { + var caps = AcpSchema.ClientCapabilities.builder() + .session(AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions()) + .build(); + + assertThat(caps).isEqualTo(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(), false, + new AcpSchema.ClientSessionCapabilities(AcpSchema.SessionConfigOptionsCapabilities.withBoolean()), null, + null, null)); + } + + @Test + void clientBuilderReachesEveryField() { + var fs = new AcpSchema.FileSystemCapability(true, true); + var session = AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions(); + var auth = new AcpSchema.AuthCapabilities(true); + var elicitation = AcpSchema.ElicitationCapabilities.formOnly(); + + var caps = AcpSchema.ClientCapabilities.builder() + .fs(fs) + .terminal(true) + .session(session) + .auth(auth) + .elicitation(elicitation) + .meta(Map.of("k", "v")) + .build(); + + assertThat(caps) + .isEqualTo(new AcpSchema.ClientCapabilities(fs, true, session, auth, elicitation, Map.of("k", "v"))); + } + + @Test + void emptyAgentBuilderIsTheDefaultCapabilities() { + assertThat(AcpSchema.AgentCapabilities.builder().build()).isEqualTo(new AcpSchema.AgentCapabilities()); + } + + @Test + void agentBuilderReachesEveryField() { + var session = new AcpSchema.SessionCapabilities(Map.of(), Map.of(), null); + var mcp = new AcpSchema.McpCapabilities(true, false); + var prompt = new AcpSchema.PromptCapabilities(true, true, true); + var auth = AcpSchema.AgentAuthCapabilities.withLogout(); + var providers = new AcpSchema.ProvidersCapabilities(); + + var caps = AcpSchema.AgentCapabilities.builder() + .loadSession(true) + .sessionCapabilities(session) + .mcpCapabilities(mcp) + .promptCapabilities(prompt) + .auth(auth) + .providers(providers) + .meta(Map.of("k", "v")) + .build(); + + assertThat(caps).isEqualTo( + new AcpSchema.AgentCapabilities(true, session, mcp, prompt, auth, providers, Map.of("k", "v"))); + } + + @Test + void clientBuilderLeadsToEachCapability() { + var caps = AcpSchema.ClientCapabilities.builder() + .readTextFile() + .writeTextFile() + .terminal() + .elicitationForm() + .elicitationUrl() + .build(); + + assertThat(caps).isEqualTo(AcpSchema.ClientCapabilities.builder() + .fs(new AcpSchema.FileSystemCapability(true, true)) + .terminal(true) + .elicitation(AcpSchema.ElicitationCapabilities.formAndUrl()) + .build()); + } + + @Test + void clientGuidedSettersKeepTheOtherFlags() { + var readOnly = AcpSchema.ClientCapabilities.builder().readTextFile().build(); + assertThat(readOnly.fs()).isEqualTo(new AcpSchema.FileSystemCapability(true, false)); + assertThat(readOnly.terminal()).isFalse(); + assertThat(readOnly.elicitation()).isNull(); + + var fsMeta = new AcpSchema.FileSystemCapability(false, true, Map.of("k", "v")); + var merged = AcpSchema.ClientCapabilities.builder() + .fs(fsMeta) + .readTextFile() + .elicitation(AcpSchema.ElicitationCapabilities.urlOnly()) + .elicitationForm() + .build(); + assertThat(merged.fs()).isEqualTo(new AcpSchema.FileSystemCapability(true, true, Map.of("k", "v"))); + assertThat(merged.elicitation()).isEqualTo(AcpSchema.ElicitationCapabilities.formAndUrl()); + } + + @Test + void agentBuilderLeadsToEachCapability() { + var caps = AcpSchema.AgentCapabilities.builder() + .loadSession() + .promptImage() + .promptEmbeddedContext() + .mcpHttp() + .build(); + + assertThat(caps).isEqualTo(AcpSchema.AgentCapabilities.builder() + .loadSession(true) + .promptCapabilities(new AcpSchema.PromptCapabilities(false, true, true)) + .mcpCapabilities(new AcpSchema.McpCapabilities(true, false)) + .build()); + + var all = AcpSchema.AgentCapabilities.builder().promptAudio().mcpSse().build(); + assertThat(all.promptCapabilities()).isEqualTo(new AcpSchema.PromptCapabilities(true, false, false)); + assertThat(all.mcpCapabilities()).isEqualTo(new AcpSchema.McpCapabilities(false, true)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConfigOptionBuildersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConfigOptionBuildersTest.java new file mode 100644 index 00000000..6086c2fb --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConfigOptionBuildersTest.java @@ -0,0 +1,207 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Building select and boolean config options with a category, description and + * {@code _meta} without the canonical constructor, and the spec's category values. + */ +class ConfigOptionBuildersTest { + + private static final AcpSchema.SessionConfigSelectOption A = new AcpSchema.SessionConfigSelectOption("model-a", + "Model A"); + + private static final AcpSchema.SessionConfigSelectOption B = new AcpSchema.SessionConfigSelectOption("model-b", + "Model B"); + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + @Test + void modelFactorySetsTheModelCategory() throws Exception { + var model = AcpSchema.SessionConfigSelect.model("model", "Model", "model-a", List.of(A, B)); + + assertThat(model).isEqualTo(new AcpSchema.SessionConfigSelect("select", "model", "Model", null, "model", + "model-a", AcpSchema.SessionConfigSelectOptions.ungrouped(List.of(A, B)), null)); + assertThat(mapper.writeValueAsString(model)).isEqualTo("{\"type\":\"select\",\"id\":\"model\"," + + "\"name\":\"Model\",\"category\":\"model\",\"currentValue\":\"model-a\",\"options\":[" + + "{\"value\":\"model-a\",\"name\":\"Model A\"},{\"value\":\"model-b\",\"name\":\"Model B\"}]}"); + } + + @Test + void modelFactoryTakesGroupedOptions() { + var grouped = AcpSchema.SessionConfigSelectOptions + .grouped(List.of(new AcpSchema.SessionConfigSelectGroup("fast", "Fast", List.of(A)))); + + var model = AcpSchema.SessionConfigSelect.model("model", "Model", "model-a", grouped); + + assertThat(model.category()).isEqualTo(AcpSchema.SessionConfigOptionCategory.MODEL); + assertThat(model.options()).isEqualTo(grouped); + } + + @Test + void selectBuilderReachesEveryField() { + var select = AcpSchema.SessionConfigSelect.builder() + .id("effort") + .name("Effort") + .description("How hard to think") + .category(AcpSchema.SessionConfigOptionCategory.THOUGHT_LEVEL) + .currentValue("model-b") + .options(List.of(A, B)) + .meta(Map.of("k", "v")) + .build(); + + assertThat(select).isEqualTo(new AcpSchema.SessionConfigSelect("select", "effort", "Effort", + "How hard to think", "thought_level", "model-b", + AcpSchema.SessionConfigSelectOptions.ungrouped(List.of(A, B)), Map.of("k", "v"))); + } + + @Test + void selectBuilderTakesGroups() { + var groups = List.of(new AcpSchema.SessionConfigSelectGroup("fast", "Fast", List.of(A)), + new AcpSchema.SessionConfigSelectGroup("smart", "Smart", List.of(B))); + + var select = AcpSchema.SessionConfigSelect.builder() + .id("model") + .name("Model") + .currentValue("model-a") + .groups(groups) + .build(); + + assertThat(select.options()).isEqualTo(AcpSchema.SessionConfigSelectOptions.grouped(groups)); + assertThat(select.category()).isNull(); + assertThat(select.description()).isNull(); + assertThat(select.meta()).isNull(); + } + + @Test + void selectBuilderRequiresIdNameCurrentValueAndOptions() { + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder().name("n").currentValue("v").options(List.of(A)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("id"); + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder().id("i").currentValue("v").options(List.of(A)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name"); + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder().id("i").name("n").options(List.of(A)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("currentValue"); + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder().id("i").name("n").currentValue("v").build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("options"); + } + + @Test + void selectBuilderRequiresOptions() { + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder() + .id("model") + .name("Model") + .currentValue("model-a") + .options(List.of()) + .build()).isInstanceOf(IllegalStateException.class).hasMessageContaining("no options"); + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder() + .id("model") + .name("Model") + .currentValue("model-a") + .groups(List.of(new AcpSchema.SessionConfigSelectGroup("empty", "Empty", List.of()))) + .build()).isInstanceOf(IllegalStateException.class).hasMessageContaining("no options"); + } + + @Test + void selectBuilderRequiresTheCurrentValueAmongTheOptions() { + assertThatThrownBy(() -> AcpSchema.SessionConfigSelect.builder() + .id("model") + .name("Model") + .currentValue("model-c") + .options(List.of(A, B)) + .build()).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("model-c") + .hasMessageContaining("model-a") + .hasMessageContaining("model-b"); + var groups = List.of(new AcpSchema.SessionConfigSelectGroup("fast", "Fast", List.of(A)), + new AcpSchema.SessionConfigSelectGroup("smart", "Smart", List.of(B))); + assertThat(AcpSchema.SessionConfigSelect.builder() + .id("model") + .name("Model") + .currentValue("model-b") + .groups(groups) + .build() + .currentValue()).isEqualTo("model-b"); + } + + @Test + void theRecordConstructorStaysLenientForOtherAgentsOutput() { + var select = new AcpSchema.SessionConfigSelect("model", "Model", "model-c", List.of(A, B)); + + assertThat(select.currentValue()).isEqualTo("model-c"); + } + + @Test + void booleanBuilderReachesEveryField() { + var toggle = AcpSchema.SessionConfigBoolean.builder() + .id("web") + .name("Web search") + .description("Search the web") + .category("_tools") + .currentValue(true) + .meta(Map.of("k", "v")) + .build(); + + assertThat(toggle).isEqualTo(new AcpSchema.SessionConfigBoolean("boolean", "web", "Web search", + "Search the web", "_tools", true, Map.of("k", "v"))); + } + + @Test + void booleanBuilderRequiresIdNameAndCurrentValue() { + assertThatThrownBy(() -> AcpSchema.SessionConfigBoolean.builder().name("n").currentValue(true).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("id"); + assertThatThrownBy(() -> AcpSchema.SessionConfigBoolean.builder().id("i").currentValue(true).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("name"); + assertThatThrownBy(() -> AcpSchema.SessionConfigBoolean.builder().id("i").name("n").build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("currentValue"); + } + + @Test + void categoryConstantsAreTheSchemasReservedValues() throws Exception { + Set constants = Set.of(AcpSchema.SessionConfigOptionCategory.MODE, + AcpSchema.SessionConfigOptionCategory.MODEL, AcpSchema.SessionConfigOptionCategory.MODEL_CONFIG, + AcpSchema.SessionConfigOptionCategory.THOUGHT_LEVEL); + + assertThat(constants).isEqualTo(schemaCategories()); + } + + @SuppressWarnings("unchecked") + private static Set schemaCategories() throws Exception { + String text; + try (InputStream in = ConfigOptionBuildersTest.class.getResourceAsStream("/schema/v1/schema.json")) { + assertThat(in).as("schema copy on the test classpath").isNotNull(); + text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + Map schema = AcpJsonMapper.createDefault().readValue(text, new TypeRef>() { + }); + Map category = (Map) ((Map) schema.get("$defs")) + .get("SessionConfigOptionCategory"); + return ((List>) category.get("anyOf")).stream() + .filter(branch -> branch.containsKey("const")) + .map(branch -> (String) branch.get("const")) + .collect(Collectors.toSet()); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConnectionLossTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConnectionLossTest.java new file mode 100644 index 00000000..169459f6 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ConnectionLossTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Every failure that means "the connection is gone" is an {@link AcpConnectionException}, on + * both session sides: a request waiting when the transport ends, and a request sent after it. + * The transport's own failure stays the cause. + */ +class ConnectionLossTest { + + private static final Duration WAIT = Duration.ofSeconds(2); + + private static final TypeRef ANY = new TypeRef<>() { + }; + + @Test + void aClientRequestWaitingWhenThePeerGoesAwayFailsWithAcpConnectionException() { + AcpClientSessionTerminationTest.TerminatingTransport transport = new AcpClientSessionTerminationTest.TerminatingTransport(); + AcpClientSession session = new AcpClientSession(Duration.ofMinutes(5), transport, Map.of(), Map.of(), + Function.identity()); + Mono pending = session.sendRequest("_x/slow", Map.of(), ANY).cache(); + pending.subscribe(v -> { + }, e -> { + }); + + IllegalStateException cause = new IllegalStateException("agent process exited with code 0"); + transport.termination.tryEmitError(cause); + + assertThatThrownBy(() -> pending.block(WAIT)).isInstanceOf(AcpConnectionException.class) + .hasCause(cause) + .hasMessageContaining("terminated"); + } + + @Test + void aClientRequestSentAfterThePeerWentAwayFailsWithAcpConnectionException() { + AcpClientSessionTerminationTest.TerminatingTransport transport = new AcpClientSessionTerminationTest.TerminatingTransport(); + AcpClientSession session = new AcpClientSession(Duration.ofMinutes(5), transport, Map.of(), Map.of(), + Function.identity()); + + IllegalStateException cause = new IllegalStateException("agent process exited with code 0"); + transport.termination.tryEmitError(cause); + + assertThatThrownBy(() -> session.sendRequest("_x/later", Map.of(), ANY).block(WAIT)) + .isInstanceOf(AcpConnectionException.class) + .hasCause(cause); + assertThatThrownBy(() -> session.sendNotification("_x/note", Map.of()).block(WAIT)) + .isInstanceOf(AcpConnectionException.class) + .hasCause(cause); + } + + @Test + void aClientRequestSentAfterAPeerCloseFailsWithAcpConnectionException() { + AcpClientSessionTerminationTest.TerminatingTransport transport = new AcpClientSessionTerminationTest.TerminatingTransport(); + AcpClientSession session = new AcpClientSession(Duration.ofMinutes(5), transport, Map.of(), Map.of(), + Function.identity()); + + transport.termination.tryEmitEmpty(); + + assertThatThrownBy(() -> session.sendRequest("_x/later", Map.of(), ANY).block(WAIT)) + .isInstanceOf(AcpConnectionException.class); + } + + @Test + void anAgentRequestWaitingWhenTheTransportFailsFailsWithAcpConnectionException() { + FailingLaterAgentTransport transport = new FailingLaterAgentTransport(); + AcpAgentSession session = new AcpAgentSession(Duration.ofMinutes(5), transport, Map.of(), Map.of()); + Mono pending = session.sendRequest("_x/slow", Map.of(), ANY).cache(); + pending.subscribe(v -> { + }, e -> { + }); + + IllegalStateException cause = new IllegalStateException("client went away"); + transport.started.tryEmitError(cause); + + assertThatThrownBy(() -> pending.block(WAIT)).isInstanceOf(AcpConnectionException.class).hasCause(cause); + assertThatThrownBy(() -> session.sendRequest("_x/later", Map.of(), ANY).block(WAIT)) + .isInstanceOf(AcpConnectionException.class) + .hasCause(cause); + } + + @Test + void anAgentRequestWaitingWhenTheSessionClosesFailsWithAcpConnectionException() { + FailingLaterAgentTransport transport = new FailingLaterAgentTransport(); + AcpAgentSession session = new AcpAgentSession(Duration.ofMinutes(5), transport, Map.of(), Map.of()); + Mono pending = session.sendRequest("_x/slow", Map.of(), ANY).cache(); + pending.subscribe(v -> { + }, e -> { + }); + + session.close(); + + assertThatThrownBy(() -> pending.block(WAIT)).isInstanceOf(AcpConnectionException.class); + } + + /** Starts, accepts every message, answers none, and fails when told. */ + static class FailingLaterAgentTransport implements AcpAgentTransport { + + final Sinks.One started = Sinks.one(); + + @Override + public Mono start(Function, Mono> handler) { + return started.asMono(); + } + + @Override + public Mono awaitTermination() { + return Mono.never(); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return null; + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/DefaultOnNullTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/DefaultOnNullTest.java new file mode 100644 index 00000000..a210152e --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/DefaultOnNullTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The response types that read {@code "result": null} as {@code {}}, as the Rust SDK's + * {@code default_on_null} payloads do. Every JSON module runs this class. + */ +class DefaultOnNullTest { + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + @Test + void theDefaultOnNullResponsesAreExactlyThoseOfTheRustSchema() { + // agent-client-protocol-schema 1.9.1 v1: every response declared with default_on_null!. + assertThat(defaultOnNullTypes()).extracting(Class::getSimpleName) + .containsExactlyInAnyOrder("AuthenticateResponse", "LogoutResponse", "LoadSessionResponse", + "ResumeSessionResponse", "CloseSessionResponse", "DeleteSessionResponse", "SetSessionModeResponse", + "WriteTextFileResponse", "ReleaseTerminalResponse", + "KillTerminalCommandResponse", "WaitForTerminalExitResponse", "SetProviderResponse", + "DisableProviderResponse"); + } + + @Test + void aResponseDefaultsOnNullExactlyWhenEveryComponentIsOptional() { + for (Class type : AcpSchema.class.getDeclaredClasses()) { + if (!type.isRecord() || !type.getSimpleName().endsWith("Response") || type == AcpSchema.JSONRPCResponse.class) { + continue; + } + boolean allOptional = Arrays.stream(type.getRecordComponents()) + .allMatch(component -> component.getAnnotatedType().isAnnotationPresent(Nullable.class)); + assertThat(AcpSchema.DefaultOnNull.class.isAssignableFrom(type)).as(type.getSimpleName()) + .isEqualTo(allOptional); + } + } + + @Test + void everyEmptyResponseWritesExactlyAnEmptyObject() throws Exception { + for (Class type : defaultOnNullTypes()) { + Object empty = emptyInstance(type); + assertThat(mapper.writeValueAsString(empty)).as(type.getSimpleName()).isEqualTo("{}"); + assertThat(mapper.convertValue(Map.of(), type)).as(type.getSimpleName()).isEqualTo(empty); + } + } + + @Test + void aNullResultAndAMissingResultBothReadAsAResponseWithoutResult() throws IOException { + for (String json : List.of("{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":null}", "{\"jsonrpc\":\"2.0\",\"id\":7}")) { + AcpSchema.JSONRPCMessage message = AcpSchema.deserializeJsonRpcMessage(mapper, json); + assertThat(message).as(json).isInstanceOf(AcpSchema.JSONRPCResponse.class); + AcpSchema.JSONRPCResponse response = (AcpSchema.JSONRPCResponse) message; + assertThat(response.id()).isEqualTo(7); + assertThat(response.result()).isNull(); + assertThat(response.error()).isNull(); + } + } + + private static List> defaultOnNullTypes() { + return Arrays.stream(AcpSchema.class.getDeclaredClasses()) + .filter(AcpSchema.DefaultOnNull.class::isAssignableFrom) + .filter(type -> type != AcpSchema.DefaultOnNull.class) + .toList(); + } + + private static Object emptyInstance(Class type) throws ReflectiveOperationException { + RecordComponent[] components = type.getRecordComponents(); + Class[] parameterTypes = Arrays.stream(components).map(RecordComponent::getType).toArray(Class[]::new); + return type.getDeclaredConstructor(parameterTypes).newInstance(new Object[components.length]); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ElicitationWireTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ElicitationWireTest.java new file mode 100644 index 00000000..188b00a8 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ElicitationWireTest.java @@ -0,0 +1,205 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema.ElicitationCapabilities; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The elicitation records against the stable ACP v1.9.1 schema and the examples in + * {@code docs/protocol/v1/elicitation.mdx}. + */ +class ElicitationWireTest { + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + private T read(String json, TypeRef type) throws IOException { + return jsonMapper.readValue(json, type); + } + + @Test + void specFormExampleParses() throws IOException { + AcpSchema.CreateElicitationRequest request = read(""" + {"sessionId":"sess_abc123","mode":"form","message":"How should I approach this refactoring?", + "requestedSchema":{"type":"object","properties":{"strategy":{"type":"string", + "enum":["conservative","balanced","aggressive"]}},"required":["strategy"]}} + """, new TypeRef<>() { + }); + + assertThat(request.mode()).isEqualTo(AcpSchema.CreateElicitationRequest.MODE_FORM); + assertThat(request.sessionId()).isEqualTo("sess_abc123"); + assertThat(request.requestedSchema()).isNotNull(); + assertThat(request.requestedSchema().required()).containsExactly("strategy"); + assertThat(request.requestedSchema().properties()).containsKey("strategy"); + AcpSchema.StringPropertySchema strategy = (AcpSchema.StringPropertySchema) request.requestedSchema() + .properties() + .get("strategy"); + assertThat(strategy.enumValues()).containsExactly("conservative", "balanced", "aggressive"); + } + + @Test + void specUrlExampleParsesWithARequestScope() throws IOException { + AcpSchema.CreateElicitationRequest request = read(""" + {"requestId":12,"mode":"url","elicitationId":"github-oauth-001", + "url":"https://agent.example.com/connect?elicitationId=github-oauth-001", + "message":"Please authorize access to your repositories."} + """, new TypeRef<>() { + }); + + assertThat(request.mode()).isEqualTo(AcpSchema.CreateElicitationRequest.MODE_URL); + assertThat(request.requestId()).isEqualTo(12); + assertThat(request.sessionId()).isNull(); + assertThat(request.elicitationId()).isEqualTo("github-oauth-001"); + } + + @Test + void capabilityModesAreTypedObjects() throws IOException { + ElicitationCapabilities both = read("{\"form\":{},\"url\":{\"_meta\":{\"k\":1}}}", new TypeRef<>() { + }); + assertThat(both.form()).isEqualTo(new AcpSchema.ElicitationFormCapabilities()); + assertThat(both.url()).isNotNull(); + assertThat(both.url().meta()).containsEntry("k", 1); + + assertThat(jsonMapper.writeValueAsString(ElicitationCapabilities.formOnly())).isEqualTo("{\"form\":{}}"); + assertThat(jsonMapper.writeValueAsString(ElicitationCapabilities.urlOnly())).isEqualTo("{\"url\":{}}"); + assertThat(jsonMapper.writeValueAsString(ElicitationCapabilities.formAndUrl())) + .isEqualTo("{\"form\":{},\"url\":{}}"); + } + + @Test + void anEmptyOrNullCapabilityAdvertisesNoMode() throws IOException { + ElicitationCapabilities empty = read("{}", new TypeRef<>() { + }); + ElicitationCapabilities nulls = read("{\"form\":null,\"url\":null}", new TypeRef<>() { + }); + for (ElicitationCapabilities caps : List.of(empty, nulls)) { + assertThat(caps.form()).isNull(); + assertThat(caps.url()).isNull(); + } + } + + @Test + void schemaPropertiesItemsAndOptionsCarryMeta() throws IOException { + AcpSchema.ElicitationSchema schema = read(""" + {"type":"object","title":"T","description":"D","_meta":{"s":1}, + "properties":{ + "a":{"type":"string","title":"A","oneOf":[{"const":"x","title":"X","description":"the x", + "_meta":{"o":1}}],"_meta":{"p":1}}, + "b":{"type":"number","_meta":{"p":2}}, + "c":{"type":"integer","_meta":{"p":3}}, + "d":{"type":"boolean","_meta":{"p":4}}, + "e":{"type":"array","items":{"type":"string","enum":["u"],"_meta":{"i":1}},"_meta":{"p":5}}, + "f":{"type":"array","items":{"anyOf":[{"const":"v","title":"V"}],"_meta":{"i":2}}} + }} + """, new TypeRef<>() { + }); + + assertThat(schema.meta()).containsEntry("s", 1); + Map p = schema.properties(); + AcpSchema.StringPropertySchema a = (AcpSchema.StringPropertySchema) p.get("a"); + assertThat(a.meta()).containsEntry("p", 1); + assertThat(a.oneOf()).containsExactly(new AcpSchema.EnumOption("x", "X", "the x", Map.of("o", 1))); + assertThat(((AcpSchema.NumberPropertySchema) p.get("b")).meta()).containsEntry("p", 2); + assertThat(((AcpSchema.IntegerPropertySchema) p.get("c")).meta()).containsEntry("p", 3); + assertThat(((AcpSchema.BooleanPropertySchema) p.get("d")).meta()).containsEntry("p", 4); + AcpSchema.MultiSelectPropertySchema e = (AcpSchema.MultiSelectPropertySchema) p.get("e"); + assertThat(e.meta()).containsEntry("p", 5); + assertThat(((AcpSchema.UntitledMultiSelectItems) e.items()).meta()).containsEntry("i", 1); + AcpSchema.MultiSelectPropertySchema f = (AcpSchema.MultiSelectPropertySchema) p.get("f"); + assertThat(((AcpSchema.TitledMultiSelectItems) f.items()).meta()).containsEntry("i", 2); + + String json = jsonMapper.writeValueAsString(schema); + assertThat(read(json, new TypeRef() { + })).isEqualTo(schema); + } + + @Test + void responsesFollowTheSchemasThreeActions() throws IOException { + AcpSchema.CreateElicitationResponse accept = read("{\"action\":\"accept\"}", new TypeRef<>() { + }); + assertThat(accept).isEqualTo(AcpSchema.CreateElicitationResponse.accept()); + assertThat(jsonMapper.writeValueAsString(AcpSchema.CreateElicitationResponse.accept())) + .isEqualTo("{\"action\":\"accept\"}"); + + AcpSchema.CreateElicitationResponse withContent = read( + "{\"action\":\"accept\",\"content\":{\"s\":\"x\",\"i\":3,\"n\":1.5,\"b\":true,\"l\":[\"a\"]}}", + new TypeRef<>() { + }); + assertThat(withContent.content()).containsEntry("s", "x") + .containsEntry("i", 3) + .containsEntry("n", 1.5) + .containsEntry("b", true) + .containsEntry("l", List.of("a")); + + assertThat(jsonMapper.writeValueAsString(AcpSchema.CreateElicitationResponse.decline())) + .isEqualTo("{\"action\":\"decline\"}"); + assertThat(jsonMapper.writeValueAsString(AcpSchema.CreateElicitationResponse.cancel())) + .isEqualTo("{\"action\":\"cancel\"}"); + } + + @Test + void completeNotificationCarriesTheElicitationId() throws IOException { + AcpSchema.CompleteElicitationNotification notification = read("{\"elicitationId\":\"github-oauth-001\"}", + new TypeRef<>() { + }); + assertThat(notification).isEqualTo(new AcpSchema.CompleteElicitationNotification("github-oauth-001")); + assertThat(jsonMapper.writeValueAsString(notification)).isEqualTo("{\"elicitationId\":\"github-oauth-001\"}"); + } + + @Test + void anUnknownActionIsKeptAndWrittenBack() throws IOException { + String json = "{\"action\":\"defer\",\"_meta\":{\"k\":1}}"; + AcpSchema.CreateElicitationResponse response = read(json, new TypeRef<>() { + }); + + assertThat(response.action().value()).isEqualTo("defer"); + assertThat(response.action().isKnown()).isFalse(); + assertThat(jsonMapper.writeValueAsString(response)).isEqualTo(json); + assertThat(AcpSchema.ElicitationAction.of("accept")).isSameAs(AcpSchema.ElicitationAction.ACCEPT); + assertThat(AcpSchema.ElicitationAction.known()).containsExactly(AcpSchema.ElicitationAction.ACCEPT, + AcpSchema.ElicitationAction.DECLINE, AcpSchema.ElicitationAction.CANCEL); + assertThat(AcpSchema.ElicitationAction.CANCEL.isKnown()).isTrue(); + assertThat(AcpSchema.ElicitationAction.DECLINE).hasToString("decline"); + } + + @Test + void anUnknownPropertyTypeAndItemShapeAreKeptAndWrittenBack() throws IOException { + String json = "{\"type\":\"object\",\"properties\":{" + + "\"when\":{\"type\":\"date\",\"title\":\"When\"}," + + "\"tags\":{\"type\":\"array\",\"items\":{\"oneOf\":[{\"const\":\"a\"}]}}}}"; + AcpSchema.ElicitationSchema schema = read(json, new TypeRef<>() { + }); + + AcpSchema.UnknownElicitationPropertySchema when = (AcpSchema.UnknownElicitationPropertySchema) schema + .properties() + .get("when"); + assertThat(when.type()).isEqualTo("date"); + assertThat(when.fields()).containsEntry("title", "When"); + AcpSchema.MultiSelectPropertySchema tags = (AcpSchema.MultiSelectPropertySchema) schema.properties() + .get("tags"); + assertThat(tags.items()).isInstanceOf(AcpSchema.UnknownMultiSelectItems.class); + assertThat(read(jsonMapper.writeValueAsString(schema), new TypeRef() { + })).isEqualTo(schema); + } + + @Test + void aKnownPropertyWritesItsOwnDiscriminator() throws IOException { + assertThat(jsonMapper.writeValueAsString(AcpSchema.StringPropertySchema.text("Name"))) + .isEqualTo("{\"type\":\"string\",\"title\":\"Name\"}"); + assertThat(new AcpSchema.BooleanPropertySchema(null, "Agree?", null, null).type()).isEqualTo("boolean"); + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> new AcpSchema.NumberPropertySchema("integer", null, null, null, null, null)) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/EnumForwardCompatibilityTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/EnumForwardCompatibilityTest.java new file mode 100644 index 00000000..c2235644 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/EnumForwardCompatibilityTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A value of a closed schema enum that this SDK does not know never fails the message that + * carries it (see {@link AcpSchema} on forward compatibility). {@code ToolKind} reads it as + * the schema's catch-all {@code other}; every other enum keeps the wire value. The JSON + * modules run this class against Jackson 2 and Jackson 3. + */ +class EnumForwardCompatibilityTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + private AcpSchema.SessionUpdate update(String json) throws Exception { + return mapper.readValue(json, new TypeRef() { + }); + } + + @Test + void unknownStopReasonIsKeptAndKnownOnesAreTheConstants() throws Exception { + var unknown = mapper.readValue("{\"stopReason\":\"paused\"}", AcpSchema.PromptResponse.class); + var known = mapper.readValue("{\"stopReason\":\"end_turn\"}", AcpSchema.PromptResponse.class); + + assertThat(unknown.stopReason().value()).isEqualTo("paused"); + assertThat(unknown.stopReason().isKnown()).isFalse(); + assertWritesBack(unknown, "{\"stopReason\":\"paused\"}"); + assertThat(known.stopReason()).isSameAs(AcpSchema.StopReason.END_TURN); + assertThat(known.stopReason().isKnown()).isTrue(); + assertThat(AcpSchema.StopReason.known()).extracting(AcpSchema.StopReason::value) + .containsExactly("end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"); + } + + @Test + void unknownToolKindReadsAsOther() throws Exception { + var call = (AcpSchema.ToolCall) update( + "{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"t\",\"title\":\"x\",\"kind\":\"teleport\"}"); + + assertThat(call.kind()).isEqualTo(AcpSchema.ToolKind.OTHER); + assertThat(mapper.writeValueAsString(AcpSchema.ToolKind.SWITCH_MODE)).isEqualTo("\"switch_mode\""); + assertThat(mapper.readValue("\"switch_mode\"", AcpSchema.ToolKind.class)).isEqualTo(AcpSchema.ToolKind.SWITCH_MODE); + } + + @Test + void unknownToolCallStatusIsKept() throws Exception { + String json = "{\"sessionUpdate\":\"tool_call_update\",\"toolCallId\":\"t\",\"status\":\"paused\"}"; + + var update = (AcpSchema.ToolCallUpdateNotification) update(json); + + assertThat(update.status()).isEqualTo(AcpSchema.ToolCallStatus.of("paused")); + assertThat(update.status().isKnown()).isFalse(); + assertWritesBack(update, json); + assertThat(AcpSchema.ToolCallStatus.of("in_progress")).isSameAs(AcpSchema.ToolCallStatus.IN_PROGRESS); + } + + @Test + void unknownPermissionOptionKindIsKept() throws Exception { + String json = """ + {"sessionId":"s","toolCall":{"toolCallId":"t"},\ + "options":[{"optionId":"a","name":"A","kind":"allow_once"},{"optionId":"b","name":"B","kind":"allow_session"}]}"""; + + var request = mapper.readValue(json, AcpSchema.RequestPermissionRequest.class); + + assertThat(request.options()).extracting(AcpSchema.PermissionOption::kind) + .containsExactly(AcpSchema.PermissionOptionKind.ALLOW_ONCE, AcpSchema.PermissionOptionKind.of("allow_session")); + assertWritesBack(request, json); + } + + @Test + void unknownPlanEntryStatusAndPriorityAreKept() throws Exception { + String json = """ + {"sessionUpdate":"plan","entries":[{"content":"c","priority":"urgent","status":"blocked"},\ + {"content":"d","priority":"low","status":"completed"}]}"""; + + var plan = (AcpSchema.Plan) update(json); + + assertThat(plan.entries().get(0).priority().value()).isEqualTo("urgent"); + assertThat(plan.entries().get(0).status().value()).isEqualTo("blocked"); + assertThat(plan.entries().get(1).priority()).isSameAs(AcpSchema.PlanEntryPriority.LOW); + assertThat(plan.entries().get(1).status()).isSameAs(AcpSchema.PlanEntryStatus.COMPLETED); + assertWritesBack(plan, json); + } + + @Test + void unknownRoleIsKept() throws Exception { + String json = "{\"audience\":[\"user\",\"critic\"]}"; + + var annotations = mapper.readValue(json, AcpSchema.Annotations.class); + + assertThat(annotations.audience()).containsExactly(AcpSchema.Role.USER, AcpSchema.Role.of("critic")); + assertWritesBack(annotations, json); + } + + @Test + void valuesPrintAsTheirWireNameAndRejectNull() { + assertThat(AcpSchema.StopReason.CANCELLED).hasToString("cancelled"); + assertThat(List.of(AcpSchema.PlanEntryPriority.HIGH, AcpSchema.Role.ASSISTANT, AcpSchema.PermissionOptionKind.REJECT_ALWAYS, + AcpSchema.PlanEntryStatus.IN_PROGRESS, AcpSchema.ToolCallStatus.FAILED)) + .allMatch(v -> v.toString().equals(v.toString().toLowerCase())); + assertThatThrownBy(() -> AcpSchema.StopReason.of(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new AcpSchema.Role(null)).isInstanceOf(NullPointerException.class); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ForwardCompatibilityTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ForwardCompatibilityTest.java new file mode 100644 index 00000000..89a9eda8 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ForwardCompatibilityTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A variant this SDK does not know never fails the message that carries it: it reads as + * the union's {@code Unknown*} record and writes back unchanged (see {@link AcpSchema}). + * The JSON modules run this class against Jackson 2 and Jackson 3. + */ +class ForwardCompatibilityTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private T read(String json, Class type) throws Exception { + return mapper.readValue(json, type); + } + + /** Writes {@code value} and reads both texts as maps, so key order does not matter. */ + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + @Test + void unknownSessionUpdateReadsAsAValueAndWritesBackUnchanged() throws Exception { + String json = """ + {"sessionId":"s1","update":{"sessionUpdate":"future_update","n":1,"nested":{"a":[1,2]},"_meta":{"k":"v"}}}"""; + + var notification = read(json, AcpSchema.SessionNotification.class); + + assertThat(notification.sessionId()).isEqualTo("s1"); + assertThat(notification.update()).isInstanceOf(AcpSchema.UnknownSessionUpdate.class); + var unknown = (AcpSchema.UnknownSessionUpdate) notification.update(); + assertThat(unknown.sessionUpdate()).isEqualTo("future_update"); + assertThat(unknown.fields()).containsEntry("n", 1) + .containsEntry("nested", Map.of("a", List.of(1, 2))) + .containsEntry("_meta", Map.of("k", "v")) + .doesNotContainKey("sessionUpdate"); + assertWritesBack(notification, json); + } + + @Test + void updateWithoutDiscriminatorReadsAsUnknownWithNullType() throws Exception { + var update = mapper.readValue("{\"title\":\"x\"}", new TypeRef() { + }); + + assertThat(update).isEqualTo(new AcpSchema.UnknownSessionUpdate(null, Map.of("title", "x"))); + assertThat(mapper.writeValueAsString(update)).isEqualTo("{\"title\":\"x\"}"); + } + + @Test + void unknownFieldsAreUnmodifiable() { + var unknown = new AcpSchema.UnknownSessionUpdate("x", new java.util.HashMap<>(Map.of("a", 1))); + + assertThatThrownBy(() -> unknown.fields().put("b", 2)).isInstanceOf(UnsupportedOperationException.class); + assertThat(new AcpSchema.UnknownContentBlock("x", null).fields()).isEmpty(); + } + + @Test + void sessionInfoUpdateRoundTrips() throws Exception { + String json = """ + {"sessionUpdate":"session_info_update","title":"Fix the build","updatedAt":"2026-10-01T10:00:00Z","_meta":{"k":"v"}}"""; + + var update = mapper.readValue(json, new TypeRef() { + }); + + assertThat(update).isEqualTo(new AcpSchema.SessionInfoUpdate("session_info_update", "Fix the build", + "2026-10-01T10:00:00Z", Map.of("k", "v"))); + assertWritesBack(update, json); + assertThat(mapper.writeValueAsString(new AcpSchema.SessionInfoUpdate("T", null))) + .isEqualTo("{\"sessionUpdate\":\"session_info_update\",\"title\":\"T\"}"); + } + + @Test + void emptySessionInfoUpdateIsLegal() throws Exception { + var update = read("{\"sessionId\":\"s\",\"update\":{\"sessionUpdate\":\"session_info_update\"}}", + AcpSchema.SessionNotification.class) + .update(); + + assertThat(update).isEqualTo(new AcpSchema.SessionInfoUpdate(null, null)); + } + + @Test + void unknownContentBlockInsideAKnownUpdate() throws Exception { + String json = """ + {"sessionUpdate":"agent_message_chunk","content":{"type":"hologram","frames":3}}"""; + + var update = (AcpSchema.AgentMessageChunk) mapper.readValue(json, new TypeRef() { + }); + + assertThat(update.content()).isEqualTo(new AcpSchema.UnknownContentBlock("hologram", Map.of("frames", 3))); + assertWritesBack(update, json); + } + + @Test + void unknownContentBlockInAPromptKeepsTheKnownOnes() throws Exception { + String json = """ + {"sessionId":"s","prompt":[{"type":"text","text":"hi"},{"type":"hologram"}]}"""; + + var request = read(json, AcpSchema.PromptRequest.class); + + assertThat(request.prompt()).containsExactly(new AcpSchema.TextContent("hi"), + new AcpSchema.UnknownContentBlock("hologram", Map.of())); + assertThat(request.text()).isEqualTo("hi"); + assertWritesBack(request, json); + } + + @Test + void unknownToolCallContentKeepsTheKnownItems() throws Exception { + String json = """ + {"sessionUpdate":"tool_call","toolCallId":"t1","title":"Edit", + "content":[{"type":"terminal","terminalId":"term"},{"type":"patch","hunks":[]}]}"""; + + var call = (AcpSchema.ToolCall) mapper.readValue(json, new TypeRef() { + }); + + assertThat(call.content()).containsExactly(new AcpSchema.ToolCallTerminal("terminal", "term"), + new AcpSchema.UnknownToolCallContent("patch", Map.of("hunks", List.of()))); + assertWritesBack(call, json); + } + + @Test + void unknownConfigOptionTypeKeepsTheKnownOptions() throws Exception { + String json = """ + {"sessionUpdate":"config_option_update","configOptions":[ + {"type":"select","id":"model","name":"Model","currentValue":"a","options":[{"value":"a","name":"A"}]}, + {"type":"slider","id":"temp","name":"Temperature","min":0,"max":2}]}"""; + + var update = (AcpSchema.ConfigOptionUpdate) mapper.readValue(json, new TypeRef() { + }); + + assertThat(update.configOptions()).hasSize(2); + assertThat(update.configOptions().get(0)).isInstanceOf(AcpSchema.SessionConfigSelect.class); + assertThat(update.configOptions().get(1)).isEqualTo(new AcpSchema.UnknownSessionConfigOption("slider", + Map.of("id", "temp", "name", "Temperature", "min", 0, "max", 2))); + assertWritesBack(update, json); + } + + @Test + void unknownPermissionOutcomeReadsAsAValue() throws Exception { + String json = """ + {"outcome":{"outcome":"deferred","until":"later"}}"""; + + var response = read(json, AcpSchema.RequestPermissionResponse.class); + + assertThat(response.outcome()) + .isEqualTo(new AcpSchema.UnknownPermissionOutcome("deferred", Map.of("until", "later"))); + assertWritesBack(response, json); + } + + @Test + void knownVariantsWriteTheirDiscriminatorOnce() throws Exception { + assertThat(mapper.writeValueAsString(new AcpSchema.TextContent("hi"))) + .isEqualTo("{\"type\":\"text\",\"text\":\"hi\"}"); + assertThat(mapper.writeValueAsString(new AcpSchema.PermissionSelected("allow"))) + .isEqualTo("{\"outcome\":\"selected\",\"optionId\":\"allow\"}"); + } + + @Test + void aKnownVariantNamesItselfWhenGivenNoDiscriminator() throws Exception { + var chunk = new AcpSchema.AgentMessageChunk(null, new AcpSchema.TextContent(null, "hi", null, null), null, null); + + assertThat(chunk.sessionUpdate()).isEqualTo("agent_message_chunk"); + assertThat(mapper.writeValueAsString(chunk)) + .isEqualTo("{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"hi\"}}"); + } + + @Test + void aKnownVariantRejectsAnotherVariantsDiscriminator() { + assertThatThrownBy(() -> new AcpSchema.AgentMessageChunk("agentMessage", new AcpSchema.TextContent("hi"), null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("agentMessage") + .hasMessageContaining("agent_message_chunk"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/GroupedSelectOptionsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/GroupedSelectOptionsTest.java new file mode 100644 index 00000000..c6f85562 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/GroupedSelectOptionsTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * A select config option's {@code options} is the schema's {@code SessionConfigSelectOptions}: + * a flat list of options, or a list of {@code SessionConfigSelectGroup}s. Both shapes read + * and write; the JSON modules run this class against Jackson 2 and Jackson 3. + */ +class GroupedSelectOptionsTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + private static final AcpSchema.SessionConfigSelectOption FAST = new AcpSchema.SessionConfigSelectOption("fast", + "Fast"); + + private static final AcpSchema.SessionConfigSelectOption SMART = new AcpSchema.SessionConfigSelectOption("smart", + "Smart", "Slower", Map.of("k", "v")); + + @Test + void groupedOptionsRoundTrip() throws Exception { + String json = """ + {"type":"select","id":"model","name":"Model","currentValue":"fast","options":[ + {"group":"anthropic","name":"Anthropic","options":[{"value":"fast","name":"Fast"}],"_meta":{"g":1}}, + {"group":"openai","name":"OpenAI","options":[{"value":"smart","name":"Smart","description":"Slower","_meta":{"k":"v"}}]}]}"""; + + var select = (AcpSchema.SessionConfigSelect) mapper.readValue(json, AcpSchema.SessionConfigOption.class); + + assertThat(select.options()).isEqualTo(AcpSchema.SessionConfigSelectOptions.grouped(List.of( + new AcpSchema.SessionConfigSelectGroup("anthropic", "Anthropic", List.of(FAST), Map.of("g", 1)), + new AcpSchema.SessionConfigSelectGroup("openai", "OpenAI", List.of(SMART))))); + assertThat(select.options().allOptions()).containsExactly(FAST, SMART); + assertWritesBack(select, json); + } + + @Test + void ungroupedOptionsRoundTrip() throws Exception { + String json = """ + {"type":"select","id":"model","name":"Model","currentValue":"fast","options":[ + {"value":"fast","name":"Fast"},{"value":"smart","name":"Smart","description":"Slower","_meta":{"k":"v"}}]}"""; + + var select = (AcpSchema.SessionConfigSelect) mapper.readValue(json, AcpSchema.SessionConfigOption.class); + + assertThat(select.options()).isEqualTo(AcpSchema.SessionConfigSelectOptions.ungrouped(List.of(FAST, SMART))); + assertThat(select.options().allOptions()).containsExactly(FAST, SMART); + assertThat(select).isEqualTo(new AcpSchema.SessionConfigSelect("model", "Model", "fast", List.of(FAST, SMART))); + assertWritesBack(select, json); + } + + @Test + void anEmptyListIsUngrouped() throws Exception { + var select = (AcpSchema.SessionConfigSelect) mapper.readValue( + "{\"type\":\"select\",\"id\":\"m\",\"name\":\"M\",\"currentValue\":\"x\",\"options\":[]}", + AcpSchema.SessionConfigOption.class); + + assertThat(select.options()).isEqualTo(AcpSchema.SessionConfigSelectOptions.ungrouped(List.of())); + } + + @Test + void aListMixingOptionsAndGroupsIsRejected() { + assertThatThrownBy(() -> AcpSchema.SessionConfigSelectOptions.of(List.of(FAST, + new AcpSchema.SessionConfigSelectGroup("g", "G", List.of(SMART))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("mixes"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundMessagesLogTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundMessagesLogTest.java new file mode 100644 index 00000000..fb62ba7a --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundMessagesLogTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; +import java.util.function.BiFunction; + +import com.agentclientprotocol.sdk.CapturedLogs; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * How {@link InboundMessages} logs a notification whose handler failed. + */ +class InboundMessagesLogTest { + + /** + * A notification handler that called the peer and let its {@link AcpError} escape: the + * error's message and data are the peer's text, so at INFO and above the failure is + * logged by its code and the SDK's description of the code only. + */ + @Test + void anEscapedAcpErrorIsLoggedWithoutThePeersMessageOrDataAboveDebug() { + AcpError peerError = new AcpError( + new AcpSchema.JSONRPCError(-32603, "MESSAGE-SECRET", Map.of("details", "DATA-SECRET"))); + BiFunction> handle = (handler, params) -> Mono.error(peerError); + + try (CapturedLogs logs = CapturedLogs.open()) { + InboundMessages + .deliver(LoggerFactory.getLogger(AcpClientSession.class), + new AcpSchema.JSONRPCNotification("session/update", Map.of()), Map.of("session/update", "h"), + handle) + .block(); + + assertThat(logs.events()).anySatisfy(event -> assertThat(event.getFormattedMessage()) + .contains("session/update") + .contains("-32603") + .contains("Internal error")); + logs.assertNoneAtInfoOrAboveContains("MESSAGE-SECRET"); + logs.assertNoneAtInfoOrAboveContains("DATA-SECRET"); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundOrderLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundOrderLincheckTest.java new file mode 100644 index 00000000..e229c0cd --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundOrderLincheckTest.java @@ -0,0 +1,184 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Validate; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * Model checks {@link InboundOrder}: notifications and responses arriving on the inbound + * thread, while handlers finish on another thread and the session closes on a third, in any + * interleaving. Each handler finishes only when the {@code finish} operation completes it, as + * an asynchronous handler does. + * + *

    + * The invariant, checked after every interleaving by {@link Inbound#orderedAndReleased()}: + * every response, and every request from the peer, is released exactly once; one released + * while the session was open was released only after every notification that arrived before + * it was handled; and once the remaining handlers finish (or the session is closed), none is + * still held. Responses and requests share the bookkeeping below (a "release"). + *

    + */ +class InboundOrderLincheckTest { + + /** Multiplies the number of scenarios explored; CI's lincheck job raises it. */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + @Test + void aResponseOrRequestIsReleasedOnceAfterTheNotificationsBeforeItWereHandled() { + new ModelCheckingOptions().iterations(20 * SCALE) + .invocationsPerIteration(200) + .threads(3) + .actorsPerThread(3) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(InboundSpec.class) + .check(Inbound.class); + } + + public static class Inbound { + + private final InboundOrder order = new InboundOrder<>(); + + /** The handler being run; completing it finishes that notification's delivery. */ + private final AtomicReference> running = new AtomicReference<>(); + + private final AtomicInteger handled = new AtomicInteger(); + + private final Disposable drain; + + private volatile boolean closing; + + /** Notifications the queue accepted; the inbound thread's own. */ + private int accepted; + + /** Per response: how many notifications arrived before it; the inbound thread's own. */ + private final List arrivedBefore = new ArrayList<>(); + + /** Per response: how many times it was released. */ + private final AtomicInteger[] releases = new AtomicInteger[8]; + + /** Per response: whether it was released while notifications before it were unhandled. */ + private final boolean[] early = new boolean[8]; + + public Inbound() { + for (int i = 0; i < this.releases.length; i++) { + this.releases[i] = new AtomicInteger(); + } + this.drain = this.order.drain(notification -> { + Sinks.Empty done = Sinks.empty(); + this.running.set(done); + return done.asMono().doOnSuccess(v -> this.handled.incrementAndGet()); + }).subscribe(); + } + + @Operation(nonParallelGroup = "inbound") + public void notification() { + if (this.order.offer("n" + this.accepted).isSuccess()) { + this.accepted++; + } + } + + @Operation(nonParallelGroup = "inbound") + public void response() { + int index = this.arrivedBefore.size(); + int before = this.accepted; + this.arrivedBefore.add(before); + // Sent before every notification, as a prompt is before its turn's updates. + this.order.release(0, () -> { + if (!this.closing && this.handled.get() < before) { + this.early[index] = true; + } + this.releases[index].incrementAndGet(); + }); + } + + @Operation(nonParallelGroup = "inbound") + public void request() { + int index = this.arrivedBefore.size(); + int before = this.accepted; + this.arrivedBefore.add(before); + this.order.inOrder(() -> { + if (!this.closing && this.handled.get() < before) { + this.early[index] = true; + } + this.releases[index].incrementAndGet(); + }); + } + + @Operation + public void finish() { + Sinks.Empty done = this.running.getAndSet(null); + if (done != null) { + done.tryEmitEmpty(); + } + } + + @Operation + public void closeGracefully() { + this.closing = true; + this.order.complete(); + } + + @Operation + public void close() { + this.closing = true; + this.order.complete(); + this.drain.dispose(); + this.order.releaseHeld(); + } + + @Validate + public void orderedAndReleased() { + // Finish the handlers still running: every response must then be released. + for (int i = 0; i < 16; i++) { + finish(); + } + for (int i = 0; i < this.arrivedBefore.size(); i++) { + if (this.early[i]) { + throw new IllegalStateException("release " + i + " released before the " + this.arrivedBefore.get(i) + + " notifications before it were handled; handled " + this.handled.get()); + } + if (this.releases[i].get() != 1) { + throw new IllegalStateException("release " + i + " released " + this.releases[i].get() + " times"); + } + } + } + + } + + public static class InboundSpec { + + public void notification() { + } + + public void response() { + } + + public void request() { + } + + public void finish() { + } + + public void closeGracefully() { + } + + public void close() { + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundRequestsLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundRequestsLincheckTest.java new file mode 100644 index 00000000..7d22b812 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/InboundRequestsLincheckTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicIntegerArray; +import java.util.concurrent.atomic.AtomicReferenceArray; + +import com.agentclientprotocol.sdk.QuietLoggers; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import org.jetbrains.lincheck.datastructures.IntGen; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.ThreadIdGen; +import org.jetbrains.lincheck.datastructures.Validate; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Sinks; + +/** + * Model checks {@link InboundRequests}, the table of inbound requests a + * {@code $/cancel_request} can cancel, with Lincheck. Requests start (their handler is + * subscribed), their handler answers, and the peer cancels them, in any interleaving. + * + *

    + * The invariant, checked after every interleaving by {@link Requests#exactlyOneResponse()}: + * a request gets at most one response, and exactly one once its handler answered or a cancel + * reached it; a {@code -32800} only when a cancel reached it; and the table holds exactly the + * requests that have not been answered (no leak). + *

    + */ +class InboundRequestsLincheckTest { + + /** See {@link PendingResponsesLincheckTest}: CI's lincheck job raises it. */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static @Nullable QuietLoggers quiet; + + @BeforeAll + static void quietLogs() { + quiet = QuietLoggers.of(InboundRequests.class); + } + + @AfterAll + static void restoreLogs() { + if (quiet != null) { + quiet.close(); + } + } + + private static final int THREADS = 2; + + /** Request ids are thread ids, which Lincheck numbers from 1 (0 is its own thread). */ + private static final int REQUESTS = THREADS + 1; + + @Test + void everyRequestIsAnsweredExactlyOnce() { + new ModelCheckingOptions().iterations(30 * SCALE) + .invocationsPerIteration(100) + .threads(THREADS) + .actorsPerThread(3) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(RequestsSpec.class) + .check(Requests.class); + } + + @Param(name = "id", gen = IntGen.class, conf = "1:2") + public static class Requests { + + private final InboundRequests inbound = new InboundRequests(); + + /** Each request's handler: answers when {@link #respond} completes it. */ + private final AtomicReferenceArray> handlers = new AtomicReferenceArray<>( + REQUESTS); + + private final AtomicIntegerArray responses = new AtomicIntegerArray(REQUESTS); + + private final AtomicIntegerArray cancelledResponses = new AtomicIntegerArray(REQUESTS); + + private final AtomicIntegerArray handlerAnswered = new AtomicIntegerArray(REQUESTS); + + private final AtomicIntegerArray cancelReached = new AtomicIntegerArray(REQUESTS); + + private final AtomicBoolean[] started = { new AtomicBoolean(), new AtomicBoolean(), new AtomicBoolean() }; + + /** The peer's request with this thread's id arrives; the session subscribes its handler. */ + @Operation + public void start(@Param(gen = ThreadIdGen.class) int id) { + if (!started[id].compareAndSet(false, true)) { + return; + } + Sinks.One handler = Sinks.one(); + handlers.set(id, handler); + AcpSchema.JSONRPCRequest request = new AcpSchema.JSONRPCRequest(AcpSchema.JSONRPC_VERSION, id, "_work", + Map.of()); + inbound.track(request, handler.asMono().map(result -> InboundMessages.result(request, result))) + .subscribe(response -> { + responses.incrementAndGet(id); + AcpSchema.JSONRPCError error = response.error(); + if (error != null && error.code() == AcpErrorCodes.REQUEST_CANCELLED) { + cancelledResponses.incrementAndGet(id); + } + }); + } + + /** The handler answers. */ + @Operation + public void respond(@Param(name = "id") int id) { + Sinks.One handler = handlers.get(id); + if (handler != null && handler.tryEmitValue("result").isSuccess()) { + handlerAnswered.set(id, 1); + } + } + + /** The peer sends {@code $/cancel_request}; the id arrives as whatever type the mapper chose. */ + @Operation + public void cancel(@Param(name = "id") int id) { + if (inbound.cancel((long) id)) { + cancelReached.set(id, 1); + } + } + + @Validate + public void exactlyOneResponse() { + int unanswered = 0; + for (int id = 1; id < REQUESTS; id++) { + if (handlers.get(id) == null) { + continue; + } + int count = responses.get(id); + if (count > 1) { + throw new IllegalStateException("request " + id + " got " + count + " responses"); + } + boolean due = handlerAnswered.get(id) == 1 || cancelReached.get(id) == 1; + if (due && count == 0) { + throw new IllegalStateException("request " + id + " was answered or cancelled but got no response"); + } + if (!due && count == 1) { + throw new IllegalStateException("request " + id + " got a response nobody produced"); + } + if (cancelledResponses.get(id) == 1 && cancelReached.get(id) == 0) { + throw new IllegalStateException("request " + id + " got -32800 without a cancel"); + } + if (count == 0) { + unanswered++; + } + } + if (inbound.size() != unanswered) { + throw new IllegalStateException( + "requests unanswered: " + unanswered + ", but the table holds " + inbound.size()); + } + } + + } + + /** Sequential specification: the operations return nothing; the validator checks the state. */ + public static class RequestsSpec { + + public void start(int id) { + } + + public void respond(int id) { + } + + public void cancel(int id) { + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/LogoutCapabilityTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/LogoutCapabilityTest.java new file mode 100644 index 00000000..fd78ed5b --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/LogoutCapabilityTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; + +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The agent capability {@code auth.logout} (ACP v1, schema v1.9.1): the agent supports the + * {@code logout} method. + */ +class LogoutCapabilityTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + @Test + void agentAdvertisesLogout() throws Exception { + String json = """ + {"loadSession":true,"auth":{"logout":{"_meta":{"k":"v"}}}}"""; + + var caps = mapper.readValue(json, AcpSchema.AgentCapabilities.class); + + assertThat(caps.auth()).isEqualTo( + new AcpSchema.AgentAuthCapabilities(new AcpSchema.LogoutCapabilities(Map.of("k", "v")))); + assertThat(mapper.readValue(mapper.writeValueAsString(caps), MAP)).isEqualTo(mapper.readValue(json, MAP)); + var negotiated = NegotiatedCapabilities.fromAgent(caps); + assertThat(negotiated.supportsLogout()).isTrue(); + negotiated.requireLogout(); + } + + @Test + void emptyLogoutObjectMeansSupported() throws Exception { + var caps = new AcpSchema.AgentCapabilities(null, null, null, null, AcpSchema.AgentAuthCapabilities.withLogout(), + null, null); + + assertThat(mapper.writeValueAsString(caps)).isEqualTo("{\"auth\":{\"logout\":{}}}"); + assertThat(NegotiatedCapabilities.fromAgent(caps).supportsLogout()).isTrue(); + } + + @Test + void logoutIsOffUnlessAdvertised() throws Exception { + for (String json : new String[] { "{}", "{\"auth\":{}}", "{\"auth\":{\"logout\":null}}" }) { + var negotiated = NegotiatedCapabilities.fromAgent(mapper.readValue(json, AcpSchema.AgentCapabilities.class)); + + assertThat(negotiated.supportsLogout()).as(json).isFalse(); + assertThatThrownBy(negotiated::requireLogout).isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("auth.logout"); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/McpServerConfigurationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/McpServerConfigurationTest.java index d8c2e1dd..14215da2 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/McpServerConfigurationTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/McpServerConfigurationTest.java @@ -26,7 +26,6 @@ *
  • HTTP transport: type="http"
  • *
  • SSE transport: type="sse"
  • * - *

    * * @author Mark Pollack */ diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/MetaCoverageTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/MetaCoverageTest.java new file mode 100644 index 00000000..1c190199 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/MetaCoverageTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.InputStream; +import java.lang.reflect.RecordComponent; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every object the ACP schema gives a {@code _meta} property has it in Java. + * + *

    + * The test walks {@code schema/v1/schema.json} (a copy of the spec's stable schema, v1.9.1, + * revision {@code 7628b15}): each definition with a {@code _meta} property must map to one + * or more {@link AcpSchema} records that declare a {@code @JsonProperty("_meta")} + * component, or be listed in {@link #NOT_MODELLED_AS_A_RECORD} with the reason. A + * definition added to the schema copy fails the test until it is mapped or classified, so + * the coverage stays complete when the copy is updated. + *

    + */ +class MetaCoverageTest { + + /** Schema definitions whose Java record has another name, or several records. */ + private static final Map> RECORDS = Map.ofEntries( + Map.entry("FileSystemCapabilities", List.of("FileSystemCapability")), + Map.entry("KillTerminalRequest", List.of("KillTerminalCommandRequest")), + Map.entry("KillTerminalResponse", List.of("KillTerminalCommandResponse")), + Map.entry("ContentChunk", List.of("UserMessageChunk", "AgentMessageChunk", "AgentThoughtChunk")), + Map.entry("Content", List.of("ToolCallContentBlock")), Map.entry("Diff", List.of("ToolCallDiff")), + Map.entry("Terminal", List.of("ToolCallTerminal")), Map.entry("EmbeddedResource", List.of("Resource")), + Map.entry("SelectedPermissionOutcome", List.of("PermissionSelected")), + Map.entry("UnstructuredCommandInput", List.of("AvailableCommandInput")), + Map.entry("ToolCallUpdate", List.of("ToolCallUpdate", "ToolCallUpdateNotification")), + Map.entry("SessionConfigOption", List.of("SessionConfigSelect", "SessionConfigBoolean")), + Map.entry("StringMultiSelectItems", List.of("UntitledMultiSelectItems"))); + + /** + * Schema definitions with {@code _meta} that have no record of their own, and why. + */ + private static final Map NOT_MODELLED_AS_A_RECORD = Map.ofEntries( + Map.entry("SessionListCapabilities", "presence marker typed Object: keeps _meta as a map entry"), + Map.entry("SessionCloseCapabilities", "presence marker typed Object: keeps _meta as a map entry"), + Map.entry("SessionResumeCapabilities", "presence marker typed Object: keeps _meta as a map entry"), + Map.entry("SessionDeleteCapabilities", "presence marker typed Object: keeps _meta as a map entry"), + Map.entry("SessionAdditionalDirectoriesCapabilities", + "presence marker typed Object: keeps _meta as a map entry")); + + @Test + void everySchemaObjectWithMetaHasItInJava() throws Exception { + Map> definitions = definitionsWithMeta(); + List missing = new ArrayList<>(); + int checked = 0; + for (String definition : new TreeSet<>(definitions.keySet())) { + if (NOT_MODELLED_AS_A_RECORD.containsKey(definition)) { + continue; + } + for (String record : RECORDS.getOrDefault(definition, List.of(definition))) { + checked++; + Class type = nested(record); + if (type == null || !type.isRecord()) { + missing.add(definition + " -> no record " + record); + } + else if (!hasMeta(type)) { + missing.add(definition + " -> " + record + " has no _meta component"); + } + } + } + + assertThat(missing).as("schema objects with _meta whose Java record lacks it").isEmpty(); + assertThat(checked).as("records checked").isGreaterThan(100); + } + + @Test + void everyExclusionIsStillAnObjectWithMeta() throws Exception { + assertThat(definitionsWithMeta().keySet()).containsAll(NOT_MODELLED_AS_A_RECORD.keySet()) + .containsAll(RECORDS.keySet()); + } + + @Test + void metaRoundTripsOnARecordThatGainedIt() throws Exception { + AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + String json = "{\"sessionId\":\"s\",\"path\":\"/a\",\"line\":3,\"_meta\":{\"trace\":\"t\"}}"; + + var request = mapper.readValue(json, AcpSchema.ReadTextFileRequest.class); + + assertThat(request.meta()).isEqualTo(Map.of("trace", "t")); + assertThat(mapper.writeValueAsString(request)).isEqualTo(json); + assertThat(new AcpSchema.ReadTextFileRequest("s", "/a", 3, null).meta()).isNull(); + } + + @SuppressWarnings("unchecked") + private static Map> definitionsWithMeta() throws Exception { + String text; + try (InputStream in = MetaCoverageTest.class.getResourceAsStream("/schema/v1/schema.json")) { + assertThat(in).as("schema copy on the test classpath").isNotNull(); + text = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + Map schema = AcpJsonMapper.createDefault().readValue(text, new TypeRef>() { + }); + Map> definitions = (Map>) schema.get("$defs"); + Map> withMeta = new java.util.TreeMap<>(); + definitions.forEach((name, definition) -> { + Object properties = definition.get("properties"); + if (properties instanceof Map map && map.containsKey("_meta")) { + withMeta.put(name, definition); + } + }); + return withMeta; + } + + private static Class nested(String simpleName) { + return Arrays.stream(AcpSchema.class.getDeclaredClasses()) + .filter(c -> c.getSimpleName().equals(simpleName)) + .findFirst() + .orElse(null); + } + + private static boolean hasMeta(Class record) throws NoSuchFieldException { + for (RecordComponent component : record.getRecordComponents()) { + // JsonProperty does not target record components; it lands on the field + JsonProperty property = record.getDeclaredField(component.getName()).getAnnotation(JsonProperty.class); + if (property != null && "_meta".equals(property.value()) && Map.class.equals(component.getType())) { + return true; + } + } + return false; + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/NotificationQueueLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/NotificationQueueLincheckTest.java new file mode 100644 index 00000000..195f7710 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/NotificationQueueLincheckTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.ThreadIdGen; +import org.jetbrains.lincheck.datastructures.Validate; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Sinks; + +/** + * Model checks {@link NotificationQueue}: notifications offered while the queue is completed, + * in any interleaving. The drain subscribes as the session's does, and handles each + * notification inside the offer's emission, as a synchronous handler does. + * + *

    + * The invariant, checked after every interleaving by {@link Notifications#completedAndDelivered()}: + * once the queue was completed, the drain has received the completion (a lost completion + * left {@code closeGracefully()} waiting out the request timeout); and every notification the + * queue accepted is delivered exactly once, in each thread's order. + *

    + */ +class NotificationQueueLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static final int THREADS = 3; + + /** Lincheck numbers its threads from 1. */ + private static final int IDS = THREADS + 1; + + @Test + void completionIsNeverLostAndAcceptedNotificationsAreDeliveredInOrder() { + new ModelCheckingOptions().iterations(20 * SCALE) + .invocationsPerIteration(200) + .threads(THREADS) + .actorsPerThread(2) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(QueueSpec.class) + .check(Notifications.class); + } + + public static class Notifications { + + private final NotificationQueue notifications = new NotificationQueue<>(); + + /** What the drain received, in order. */ + private final Queue delivered = new ConcurrentLinkedQueue<>(); + + private volatile boolean drainCompleted; + + private volatile boolean completeCalled; + + /** Notifications each thread offered and the queue accepted; each slot is its thread's. */ + private final int[] accepted = new int[IDS]; + + /** Notifications each thread offered; each slot is its thread's. */ + private final int[] offered = new int[IDS]; + + public Notifications() { + notifications.asFlux().subscribe(delivered::add, error -> { + }, () -> drainCompleted = true); + } + + @Operation + public void offer(@Param(gen = ThreadIdGen.class) int thread) { + String notification = thread + ":" + offered[thread]++; + if (notifications.offer(notification) == Sinks.EmitResult.OK) { + accepted[thread]++; + } + } + + @Operation + public void complete() { + completeCalled = true; + notifications.complete(); + } + + @Validate + public void completedAndDelivered() { + if (completeCalled && !drainCompleted) { + throw new IllegalStateException("completed, but the drain never received the completion; delivered " + + delivered); + } + int[] next = new int[IDS]; + for (String notification : delivered) { + int thread = Integer.parseInt(notification.substring(0, notification.indexOf(':'))); + int index = Integer.parseInt(notification.substring(notification.indexOf(':') + 1)); + if (index < next[thread]) { + throw new IllegalStateException("delivered " + delivered + ": out of order or twice"); + } + next[thread] = index + 1; + } + for (int thread = 1; thread < IDS; thread++) { + long count = 0; + for (String notification : delivered) { + if (notification.startsWith(thread + ":")) { + count++; + } + } + if (count != accepted[thread]) { + throw new IllegalStateException("thread " + thread + " had " + accepted[thread] + + " notifications accepted, delivered " + delivered); + } + } + } + + } + + public static class QueueSpec { + + public void offer(int thread) { + } + + public void complete() { + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesLogTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesLogTest.java new file mode 100644 index 00000000..47f2c0f7 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesLogTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A peer's error response fails the caller's request, and that failure is the report: the + * session logs it at DEBUG only, without the error's data, which is the peer's payload. + */ +class OutboundMessagesLogTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private final OutboundMessagesTest.RecordingTransport transport = new OutboundMessagesTest.RecordingTransport(); + + private ListAppender appender; + + private Logger logger; + + @BeforeEach + void captureLogs() { + this.logger = (Logger) LoggerFactory.getLogger(OutboundMessages.class); + this.appender = new ListAppender<>(); + this.appender.start(); + this.logger.addAppender(this.appender); + this.logger.setLevel(Level.TRACE); + } + + @AfterEach + void releaseLogs() { + this.logger.detachAppender(this.appender); + this.logger.setLevel(null); + this.appender.stop(); + } + + @Test + void aPeerErrorResponseIsLoggedAtDebugWithoutItsData() { + OutboundMessages outbound = new OutboundMessages(this.transport, TIMEOUT, () -> null, + cause -> new IllegalStateException(cause), "agent"); + + StepVerifier.create(outbound.sendRequest("session/set_config_option", "params", new TypeRef() { + })) + .then(() -> outbound.complete(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, + this.transport.lastRequest().id(), null, + new AcpSchema.JSONRPCError(-32602, "Invalid params", Map.of("secret", "payload-value"))))) + .expectError(AcpError.class) + .verify(TIMEOUT); + + assertThat(this.appender.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)).isEmpty(); + assertThat(this.appender.list).extracting(ILoggingEvent::getFormattedMessage) + .anySatisfy(message -> assertThat(message).contains("session/set_config_option").contains("-32602")) + .allSatisfy(message -> assertThat(message).doesNotContain("payload-value")); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesTest.java new file mode 100644 index 00000000..56949385 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/OutboundMessagesTest.java @@ -0,0 +1,216 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The request/response plumbing both session sides share: which requests are waiting, what + * a response or a failure does to them, and what is never sent. + */ +class OutboundMessagesTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final TypeRef STRING = new TypeRef<>() { + }; + + /** Records what is sent; delivers nothing. */ + static class RecordingTransport implements AcpTransport { + + final List sent = new CopyOnWriteArrayList<>(); + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + this.sent.add(message); + return Mono.empty(); + } + + @Override + public Mono closeGracefully() { + return Mono.empty(); + } + + @Override + @SuppressWarnings("unchecked") + public T unmarshalFrom(Object data, TypeRef typeRef) { + return (T) data; + } + + AcpSchema.JSONRPCRequest lastRequest() { + return (AcpSchema.JSONRPCRequest) this.sent.get(this.sent.size() - 1); + } + + } + + private final RecordingTransport transport = new RecordingTransport(); + + private final AtomicReference<@Nullable Throwable> failure = new AtomicReference<>(); + + private OutboundMessages outbound(Duration requestTimeout) { + return new OutboundMessages(this.transport, requestTimeout, this.failure::get, + cause -> new IllegalStateException("not connected: " + cause.getMessage(), cause), "agent"); + } + + private static AcpSchema.JSONRPCResponse success(@Nullable Object id, Object result) { + return new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, result, null); + } + + @Test + void timedOutRequestNoLongerWaitsForAResponse() { + OutboundMessages outbound = outbound(Duration.ofMillis(50)); + + StepVerifier.create(outbound.sendRequest("slow", "params", STRING)) + .expectError(TimeoutException.class) + .verify(TIMEOUT); + + assertThat(outbound.pendingRequests()).as("a timed-out request must not stay registered").isZero(); + } + + @Test + void cancelledRequestNoLongerWaitsForAResponse() { + OutboundMessages outbound = outbound(TIMEOUT); + + Disposable subscription = outbound.sendRequest("slow", "params", STRING).subscribe(); + assertThat(outbound.pendingRequests()).isEqualTo(1); + + subscription.dispose(); + + assertThat(outbound.pendingRequests()).as("a cancelled request must not stay registered").isZero(); + } + + @Test + void answeredRequestNoLongerWaitsForAResponse() { + OutboundMessages outbound = outbound(TIMEOUT); + + StepVerifier.create(outbound.sendRequest("echo", "params", STRING)) + .then(() -> outbound.complete(success(this.transport.lastRequest().id(), "answer"))) + .expectNext("answer") + .verifyComplete(); + + assertThat(outbound.pendingRequests()).isZero(); + } + + @Test + void requestAfterTheTransportFailedIsRefusedWithoutBeingSent() { + OutboundMessages outbound = outbound(TIMEOUT); + this.failure.set(new IllegalStateException("peer gone")); + + assertThatThrownBy(() -> outbound.sendRequest("echo", "params", STRING).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("not connected: peer gone"); + + assertThat(this.transport.sent).as("nothing goes on the wire of a failed transport").isEmpty(); + assertThat(outbound.pendingRequests()).isZero(); + } + + @Test + void failureRecordedWhileRegisteringRefusesTheRequestWithoutSendingIt() { + // The failure lands between the first check and the registration: the second + // check must catch it, or the request waits out its timeout on a dead transport. + IllegalStateException late = new IllegalStateException("peer gone"); + AtomicInteger checks = new AtomicInteger(); + OutboundMessages outbound = new OutboundMessages(this.transport, TIMEOUT, + () -> checks.getAndIncrement() == 0 ? null : late, + cause -> new IllegalStateException("not connected: " + cause.getMessage(), cause), "agent"); + + assertThatThrownBy(() -> outbound.sendRequest("echo", "params", STRING).block(Duration.ofSeconds(2))) + .hasMessage("not connected: peer gone"); + + assertThat(this.transport.sent).isEmpty(); + assertThat(outbound.pendingRequests()).isZero(); + } + + @Test + void requestTheTransportFailsToSendFailsWithThatError() { + IllegalStateException sendFailure = new IllegalStateException("broken pipe"); + RecordingTransport failingTransport = new RecordingTransport() { + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return Mono.error(sendFailure); + } + }; + OutboundMessages outbound = new OutboundMessages(failingTransport, TIMEOUT, () -> null, + IllegalStateException::new, "agent"); + + assertThatThrownBy(() -> outbound.sendRequest("echo", "params", STRING).block(Duration.ofSeconds(2))) + .isSameAs(sendFailure); + assertThat(outbound.pendingRequests()).isZero(); + } + + @Test + void notificationAfterTheTransportFailedIsRefusedWithoutBeingSent() { + OutboundMessages outbound = outbound(TIMEOUT); + this.failure.set(new IllegalStateException("peer gone")); + + assertThatThrownBy(() -> outbound.sendNotification("note", Map.of()).block(TIMEOUT)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("not connected: peer gone"); + + assertThat(this.transport.sent).isEmpty(); + } + + @Test + void responseWithoutIdOrForAnUnknownIdLeavesPendingRequestsAlone() { + OutboundMessages outbound = outbound(TIMEOUT); + + StepVerifier.create(outbound.sendRequest("echo", "params", STRING)).then(() -> { + outbound.complete(success(null, "stray")); + outbound.complete(success("no-such-request", "stray")); + assertThat(outbound.pendingRequests()).isEqualTo(1); + outbound.complete(success(this.transport.lastRequest().id(), "answer")); + }).expectNext("answer").verifyComplete(); + } + + @Test + void voidRequestCompletesEmptyWhateverTheResult() { + OutboundMessages outbound = outbound(TIMEOUT); + + StepVerifier.create(outbound.sendRequest("fire", "params", new TypeRef() { + })).then(() -> outbound.complete(success(this.transport.lastRequest().id(), Map.of()))) + .verifyComplete(); + } + + @Test + void dismissFailsEveryPendingRequestNamingThePeerAndCause() { + OutboundMessages outbound = outbound(TIMEOUT); + IllegalStateException cause = new IllegalStateException("stream closed"); + + Mono first = outbound.sendRequest("a", "params", STRING).cache(); + Mono second = outbound.sendRequest("b", "params", STRING).cache(); + first.subscribe(v -> { + }, e -> { + }); + second.subscribe(v -> { + }, e -> { + }); + assertThat(outbound.pendingRequests()).isEqualTo(2); + + outbound.dismissPending(cause); + + for (Mono request : List.of(first, second)) { + assertThatThrownBy(() -> request.block(TIMEOUT)).hasMessage("ACP session with agent terminated") + .hasCause(cause); + } + assertThat(outbound.pendingRequests()).isZero(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLincheckTest.java new file mode 100644 index 00000000..b07b1514 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLincheckTest.java @@ -0,0 +1,379 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicIntegerArray; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.function.LongConsumer; + +import com.agentclientprotocol.sdk.QuietLoggers; +import org.jetbrains.lincheck.datastructures.IntGen; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.ThreadIdGen; +import org.jetbrains.lincheck.datastructures.Validate; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscription; +import reactor.core.Disposable; +import reactor.core.publisher.BaseSubscriber; +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; +import reactor.util.context.Context; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Model checks {@link PendingResponses}, the table of requests waiting for a response, with + * Lincheck. Requests are registered, answered, cancelled (their sink is disposed, as a + * disposed subscription or {@code Mono.timeout} cancels the {@code Mono.create} source), + * failed by a transport failure and dismissed by close, in any interleaving. + * + *

    + * The sequential specification covers what {@code send} returns. The invariant that matters, + * checked after every interleaving by {@link Requests#noLostOrDoubleCompletion()}: every + * request is signalled at most once, and a request that was neither signalled nor cancelled + * is still waiting (its response can still complete it), while a cancelled one is not + * (nothing leaks until the session ends); and a {@code $/cancel_request} is sent for a request + * exactly when its caller gave up before any signal, never twice and never after a response, + * a dismissal or a transport failure. + *

    + */ +class PendingResponsesLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static @Nullable QuietLoggers quiet; + + @BeforeAll + static void quietLogs() { + quiet = QuietLoggers.of(PendingResponses.class); + } + + @AfterAll + static void restoreLogs() { + if (quiet != null) { + quiet.close(); + } + } + + + private static final int THREADS = 2; + + /** Request ids are thread ids, which Lincheck numbers from 1 (0 is its own thread). */ + private static final int REQUESTS = THREADS + 1; + + @Test + void noRequestIsLostOrCompletedTwice() { + new ModelCheckingOptions().iterations(50 * SCALE) + .invocationsPerIteration(100) + .threads(THREADS) + .actorsPerThread(3) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(RequestsSpec.class) + .check(Requests.class); + } + + /** + * The Reactor contract the model's sink relies on: a cancel that arrives while another is + * still in the cancel callback does nothing, so the dispose callback (which removes the + * entry) cannot overtake the cancel callback (which reports the cancel only if it removes + * the entry itself). + */ + @Test + void reactorRunsTheCallbacksOfOnlyTheFirstCancel() throws Exception { + CountDownLatch inCancelCallback = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicInteger cancelCallbacks = new AtomicInteger(); + AtomicBoolean disposed = new AtomicBoolean(); + AtomicReference<@Nullable Subscription> subscription = new AtomicReference<>(); + Mono.create(sink -> { + sink.onCancel(() -> { + cancelCallbacks.incrementAndGet(); + inCancelCallback.countDown(); + awaitQuietly(release); + }); + sink.onDispose(() -> disposed.set(true)); + }).subscribe(new BaseSubscriber() { + @Override + protected void hookOnSubscribe(Subscription s) { + subscription.set(s); + } + }); + Subscription sink = Objects.requireNonNull(subscription.get()); + ExecutorService first = Executors.newSingleThreadExecutor(); + try { + Future firstCancel = first.submit(sink::cancel); + assertThat(inCancelCallback.await(5, TimeUnit.SECONDS)).isTrue(); + + sink.cancel(); + + assertThat(disposed).as("the second cancel ran the dispose callback").isFalse(); + release.countDown(); + firstCancel.get(5, TimeUnit.SECONDS); + assertThat(cancelCallbacks).hasValue(1); + assertThat(disposed).isTrue(); + } + finally { + release.countDown(); + first.shutdownNow(); + } + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("not released"); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Param(name = "id", gen = IntGen.class, conf = "1:2") + public static class Requests { + + private final AtomicReference<@Nullable Throwable> transportFailure = new AtomicReference<>(); + + /** How many $/cancel_request notifications each request id caused. */ + private final AtomicIntegerArray cancelsSent = new AtomicIntegerArray(REQUESTS); + + private final PendingResponses pending = new PendingResponses(transportFailure::get, + cause -> new IllegalStateException("not started", cause), "agent"); + + /** Request ids are unique: each may be sent once. Only the thread of that id touches its slot. */ + private final boolean[] used = new boolean[REQUESTS]; + + /** The sink of each request that was registered. */ + private final AtomicReferenceArray sinks = new AtomicReferenceArray<>(REQUESTS); + + /** Each thread sends one request, whose id is the thread's. */ + @Operation + public String send(@Param(gen = ThreadIdGen.class) int id) { + if (used[id]) { + return "DUPLICATE"; + } + used[id] = true; + RecordingSink sink = new RecordingSink(); + if (!pending.register(id, sink, () -> cancelsSent.incrementAndGet(id))) { + return "FAILED_AT_ONCE"; + } + sinks.set(id, sink); + return "SENT"; + } + + @Operation + public void respond(@Param(name = "id") int id) { + pending.complete(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, id, "result", null)); + } + + /** + * The caller gives up: it disposes the request, or its timeout fires. Reactor cancels + * and disposes the sink. + */ + @Operation + public void cancel(@Param(name = "id") int id) { + RecordingSink sink = sinks.get(id); + if (sink != null) { + sink.cancel(); + } + } + + /** The transport terminates (or failed to start): what the sessions do. */ + @Operation + public void transportFails() { + IllegalStateException failure = new IllegalStateException("transport terminated"); + transportFailure.compareAndSet(null, failure); + pending.dismissAll(failure); + } + + @Operation + public void close() { + pending.dismissAll(null); + } + + @Validate + public void noLostOrDoubleCompletion() { + int waiting = 0; + for (int id = 1; id < REQUESTS; id++) { + RecordingSink sink = sinks.get(id); + if (sink == null) { + continue; + } + if (sink.signals.get() > 1) { + throw new IllegalStateException("request " + id + " was completed " + sink.signals.get() + " times"); + } + int cancels = cancelsSent.get(id); + boolean gaveUpUnanswered = sink.cancelled && sink.signals.get() == 0; + if (cancels != (gaveUpUnanswered ? 1 : 0)) { + throw new IllegalStateException("request " + id + " caused " + cancels + + " $/cancel_request, cancelled before any signal: " + gaveUpUnanswered); + } + if (sink.signals.get() == 0 && !sink.cancelled) { + waiting++; + } + } + if (transportFailure.get() != null && waiting > 0) { + throw new IllegalStateException(waiting + " requests still wait on a transport that failed"); + } + if (pending.size() != waiting) { + throw new IllegalStateException( + "requests waiting: " + waiting + ", but " + pending.size() + " can still be completed"); + } + } + + } + + /** Sequential specification of what {@code send} returns. */ + public static class RequestsSpec { + + private final Set used = new HashSet<>(); + + private boolean failed; + + public String send(int id) { + if (!used.add(id)) { + return "DUPLICATE"; + } + return failed ? "FAILED_AT_ONCE" : "SENT"; + } + + public void respond(int id) { + } + + public void cancel(int id) { + } + + public void transportFails() { + failed = true; + } + + public void close() { + } + + } + + /** + * A {@link MonoSink} that counts the signals it is given and, like Reactor's, runs its + * dispose callback when it is cancelled or signalled. + */ + static final class RecordingSink implements MonoSink { + + final AtomicInteger signals = new AtomicInteger(); + + volatile boolean cancelled; + + /** Whether a cancel already ran: Reactor's sink acts on the first cancel only. */ + private final AtomicBoolean cancelRan = new AtomicBoolean(); + + private final AtomicReference<@Nullable Disposable> onDispose = new AtomicReference<>(); + + private final AtomicReference<@Nullable Disposable> onCancel = new AtomicReference<>(); + + /** + * Like Reactor's: the cancel callback, then the dispose callback, unless already + * signalled. Only the first cancel does anything, as in Reactor's sink, whose + * {@code cancel()} swaps its state once and runs both callbacks on the thread that + * won: a second cancel (the caller disposes as its timeout fires, say) cannot run the + * dispose callback while the first is still in the cancel callback. A model that let + * it would report a lost {@code $/cancel_request} that Reactor never produces + * ({@link PendingResponsesLincheckTest#reactorRunsTheCallbacksOfOnlyTheFirstCancel()} pins + * that contract). + */ + void cancel() { + cancelled = true; + if (!cancelRan.compareAndSet(false, true)) { + return; + } + Disposable callback = onCancel.getAndSet(null); + if (callback != null && signals.get() == 0) { + callback.dispose(); + } + dispose(); + } + + private void dispose() { + onCancel.set(null); + Disposable callback = onDispose.getAndSet(null); + if (callback != null) { + callback.dispose(); + } + } + + @Override + public void success() { + signals.incrementAndGet(); + dispose(); + } + + @Override + public void success(AcpSchema.@Nullable JSONRPCResponse value) { + signals.incrementAndGet(); + dispose(); + } + + @Override + public void error(Throwable e) { + signals.incrementAndGet(); + dispose(); + } + + @Override + @SuppressWarnings("deprecation") + public Context currentContext() { + return Context.empty(); + } + + @Override + public MonoSink onRequest(LongConsumer consumer) { + return this; + } + + @Override + public MonoSink onCancel(Disposable d) { + if (cancelled) { + d.dispose(); + } + else { + onCancel.set(d); + } + return this; + } + + @Override + public MonoSink onDispose(Disposable d) { + if (cancelled) { + d.dispose(); + } + else { + onDispose.set(d); + } + return this; + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLogTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLogTest.java new file mode 100644 index 00000000..167bb36b --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PendingResponsesLogTest.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * How {@link PendingResponses} logs a response it cannot match to a request. + */ +class PendingResponsesLogTest { + + private ListAppender appender; + + private Logger logger; + + @BeforeEach + void captureLogs() { + this.logger = (Logger) LoggerFactory.getLogger(PendingResponses.class); + this.appender = new ListAppender<>(); + this.appender.start(); + this.logger.addAppender(this.appender); + } + + @AfterEach + void releaseLogs() { + this.logger.detachAppender(this.appender); + this.appender.stop(); + } + + /** + * JSON-RPC 2.0 answers a message whose id could not be read with an error whose id is + * null, so a null-id error is the peer reporting something it could not read, not a bug + * in this SDK's request sending. + */ + @Test + void aNullIdErrorIsLoggedAsThePeersErrorReportAtWarn() { + PendingResponses pending = new PendingResponses(() -> null, RuntimeException::new, "agent"); + + pending.complete(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, null, null, + new AcpSchema.JSONRPCError(-32700, "Parse error", null))); + + List events = this.appender.list.stream() + .filter(event -> event.getLevel().isGreaterOrEqual(Level.WARN)) + .toList(); + assertThat(events).hasSize(1); + assertThat(events.get(0).getLevel()).isEqualTo(Level.WARN); + assertThat(events.get(0).getFormattedMessage()).contains("agent") + .contains("-32700") + .contains("Parse error") + .doesNotContain("bug"); + } + + /** A stray response with no id answers nothing this side sent: a WARN, not an ERROR about a bug. */ + @Test + void aResponseWithoutAnIdIsLoggedAtWarn() { + PendingResponses pending = new PendingResponses(() -> null, RuntimeException::new, "agent"); + + pending.complete(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, null, java.util.Map.of(), null)); + + List events = this.appender.list.stream() + .filter(event -> event.getLevel().isGreaterOrEqual(Level.WARN)) + .toList(); + assertThat(events).singleElement().satisfies(event -> { + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("agent").doesNotContain("bug"); + }); + } + + /** + * The peer's error message and data are its own text and can carry anything: at INFO and + * above the error is logged by its code and the SDK's description of the code; the + * message and data stay at DEBUG. + */ + @Test + void aNullIdErrorIsLoggedWithoutThePeersMessageOrDataAboveDebug() { + PendingResponses pending = new PendingResponses(() -> null, RuntimeException::new, "agent"); + + pending.complete(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, null, null, + new AcpSchema.JSONRPCError(-32600, "MESSAGE-SECRET", java.util.Map.of("details", "DATA-SECRET")))); + + assertThat(this.appender.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .singleElement() + .satisfies(event -> assertThat(event.getFormattedMessage()).contains("-32600") + .contains("Invalid request") + .doesNotContain("MESSAGE-SECRET") + .doesNotContain("DATA-SECRET")); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerLincheckTest.java new file mode 100644 index 00000000..a0719cb5 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerLincheckTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.QuietLoggers; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Validate; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** + * Model checks who answers a prompt ({@link PromptAnswer}) with Lincheck: the handler's + * answer, a {@code session/cancel}, the cancel grace timer and the maximum-duration timer + * race in every interleaving. The winner publishes through the session's release-before- + * publish path ({@link ActivePrompts#endBeforePublishing}). + * + *

    + * Checked after every interleaving: at most one answer reaches the wire, and once one has, + * the turn is released. The sequential specification fixes who wins: the first claim, the + * grace timer only while the prompt is being cancelled, and a maximum-duration expiry + * answers {@code CANCELLED} for a cancelled prompt, {@code EXPIRED} otherwise. + *

    + */ +class PromptAnswerLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static @Nullable QuietLoggers quiet; + + @BeforeAll + static void quietLogs() { + quiet = QuietLoggers.of(ActivePrompts.class); + } + + @AfterAll + static void restoreLogs() { + if (quiet != null) { + quiet.close(); + } + } + + @Test + void exactlyOneAnswerPerPrompt() { + new ModelCheckingOptions().iterations(30 * SCALE) + .invocationsPerIteration(200) + .threads(3) + .actorsPerThread(2) + .actorsBefore(0) + .actorsAfter(1) + .sequentialSpecification(AnswerSpec.class) + .check(Answer.class); + } + + /** + * A cancel can arrive while the prompt's deadlines are being set up: the grace period + * must still start, exactly once. + */ + @Test + void aCancelRacingTheDeadlinesSetUpStartsTheGracePeriodOnce() { + new ModelCheckingOptions().iterations(10 * SCALE) + .invocationsPerIteration(200) + .threads(2) + .actorsPerThread(1) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(GraceStartSpec.class) + .check(GraceStart.class); + } + + /** The session's deadlines register for the cancel while a cancel arrives. */ + public static class GraceStart { + + private final ActivePrompts prompts = new ActivePrompts(); + + private final AtomicInteger starts = new AtomicInteger(); + + private final AtomicBoolean started = new AtomicBoolean(); + + private volatile boolean registered; + + public GraceStart() { + prompts.tryStart("s", 0); + } + + @Operation(runOnce = true) + public void deadlinesSetUp() { + ActivePrompts.Turn turn = prompts.current("s"); + if (turn != null) { + // What PromptDeadlines registers: an idempotent start of the grace timer. + turn.onCancelRequested(() -> { + if (started.compareAndSet(false, true)) { + starts.incrementAndGet(); + } + }); + registered = true; + } + } + + @Operation(runOnce = true) + public void cancel() { + prompts.cancel("s"); + } + + @Validate + public void graceStartedOnceIfCancelled() { + ActivePrompts.Turn turn = prompts.current("s"); + boolean cancelling = turn != null && turn.answer().isCancelling(); + if (registered && cancelling && starts.get() != 1) { + throw new IllegalStateException("cancelled, but the grace period started " + starts.get() + " times"); + } + } + + } + + public static class GraceStartSpec { + + public void deadlinesSetUp() { + } + + public void cancel() { + } + + } + + /** + * One prompt, active from the start. The grace timer is modelled as firing at any time: + * it wins only while the prompt is being cancelled, which is the state a cancel moves it + * to before starting the timer. + */ + public static class Answer { + + private final ActivePrompts prompts = new ActivePrompts(); + + private final ActivePrompts.Turn turn; + + /** The answers the client received. */ + private final Queue wire = new ConcurrentLinkedQueue<>(); + + public Answer() { + ActivePrompts.Turn started = prompts.tryStart("s", 0); + if (started == null) { + throw new IllegalStateException("no turn"); + } + this.turn = started; + } + + private void publish(String answer) { + prompts.endBeforePublishing(turn, Mono.just(answer)).subscribe(wire::add); + } + + @Operation + public boolean handlerAnswers() { + if (turn.answer().handlerAnswered()) { + publish("HANDLER"); + return true; + } + return false; + } + + @Operation + public void cancel() { + prompts.cancel("s"); + } + + @Operation + public String graceTimerFires() { + if (turn.answer().graceExpired()) { + publish("CANCELLED"); + return "CANCELLED"; + } + return "LOST"; + } + + @Operation + public String maxDurationTimerFires() { + PromptAnswer.Forced forced = turn.answer().maxDurationExpired(); + if (forced == null) { + return "LOST"; + } + publish(forced.name()); + return forced.name(); + } + + @Validate + public void oneAnswerAndTheTurnReleased() { + if (wire.size() > 1) { + throw new IllegalStateException("the prompt was answered " + wire.size() + " times: " + wire); + } + if (wire.size() == 1 && prompts.isActive("s")) { + throw new IllegalStateException("answered " + wire + " but the turn is still held"); + } + } + + } + + /** Sequential specification of {@link Answer}. */ + public static class AnswerSpec { + + private boolean cancelling; + + private boolean answered; + + public boolean handlerAnswers() { + if (answered) { + return false; + } + answered = true; + return true; + } + + public void cancel() { + if (!answered) { + cancelling = true; + } + } + + public String graceTimerFires() { + if (!cancelling || answered) { + return "LOST"; + } + answered = true; + return "CANCELLED"; + } + + public String maxDurationTimerFires() { + if (answered) { + return "LOST"; + } + answered = true; + return cancelling ? "CANCELLED" : "EXPIRED"; + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerTest.java new file mode 100644 index 00000000..4bd23571 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/PromptAnswerTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Who answers a prompt, one step at a time. PromptAnswerLincheckTest checks the same claims + * under every interleaving. + */ +class PromptAnswerTest { + + private final PromptAnswer answer = new PromptAnswer(); + + @Test + void theHandlerAnswersOnce() { + assertThat(answer.handlerAnswered()).isTrue(); + assertThat(answer.handlerAnswered()).isFalse(); + assertThat(answer.requestCancel()).as("too late to cancel").isFalse(); + assertThat(answer.graceExpired()).isFalse(); + assertThat(answer.maxDurationExpired()).isNull(); + } + + @Test + void theGracePeriodAnswersOnlyACancelledPrompt() { + assertThat(answer.graceExpired()).as("not cancelled").isFalse(); + assertThat(answer.requestCancel()).isTrue(); + assertThat(answer.requestCancel()).as("the grace period starts once").isFalse(); + assertThat(answer.isCancelling()).isTrue(); + + assertThat(answer.graceExpired()).isTrue(); + assertThat(answer.isCancelling()).isFalse(); + assertThat(answer.handlerAnswered()).as("the handler's late answer is dropped").isFalse(); + } + + @Test + void theMaximumDurationAnswersExpiredOrCancelled() { + assertThat(answer.maxDurationExpired()).isEqualTo(PromptAnswer.Forced.EXPIRED); + + PromptAnswer cancelled = new PromptAnswer(); + cancelled.requestCancel(); + assertThat(cancelled.maxDurationExpired()).isEqualTo(PromptAnswer.Forced.CANCELLED); + assertThat(cancelled.graceExpired()).isFalse(); + } + + @Test + void aCancelBeforeTheDeadlinesRegisterStillReachesThem() { + ActivePrompts prompts = new ActivePrompts(); + ActivePrompts.Turn turn = prompts.tryStart("s", 1); + assertThat(turn).isNotNull(); + prompts.cancel("s"); + + AtomicInteger runs = new AtomicInteger(); + turn.onCancelRequested(runs::incrementAndGet); + assertThat(runs).as("registered after the cancel: runs at once").hasValue(1); + + prompts.cancel("s"); + assertThat(runs).as("a second cancel starts nothing").hasValue(1); + } + + @Test + void aCancelAfterTheDeadlinesRegisterRunsThem() { + ActivePrompts prompts = new ActivePrompts(); + ActivePrompts.Turn turn = prompts.tryStart("s", 1); + assertThat(turn).isNotNull(); + AtomicInteger runs = new AtomicInteger(); + turn.onCancelRequested(runs::incrementAndGet); + assertThat(runs).hasValue(0); + + prompts.cancel("s"); + assertThat(runs).hasValue(1); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/RequiredFieldsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/RequiredFieldsTest.java new file mode 100644 index 00000000..5a1b89a1 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/RequiredFieldsTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The required-field check covers the schema's records only: an application's own record, + * such as an extension method's params, has no schema behind it, and one this package cannot + * read must not be reported as missing its fields. + */ +class RequiredFieldsTest { + + private record Ping(String text) { + } + + private record Nested(Ping ping, List pings) { + } + + @Test + void schemaRecordMissingARequiredFieldIsReported() { + AcpSchema.PromptRequest request = new AcpSchema.PromptRequest(null, List.of()); + + assertThat(RequiredFields.firstMissing(request)).isEqualTo("sessionId"); + } + + @Test + void completeSchemaRecordPasses() { + assertThat(RequiredFields.firstMissing(new AcpSchema.PromptRequest("s", List.of()))).isNull(); + } + + @Test + void applicationRecordsAreNotChecked() { + assertThat(RequiredFields.firstMissing(new Ping("hi"))).isNull(); + assertThat(RequiredFields.firstMissing(new Ping(null))).isNull(); + assertThat(RequiredFields.firstMissing(new Nested(new Ping("a"), List.of(new Ping("b"))))).isNull(); + assertThat(RequiredFields.firstMissing(List.of(new Ping("c")))).isNull(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SchemaShortcutsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SchemaShortcutsTest.java new file mode 100644 index 00000000..dc49d96a --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SchemaShortcutsTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The short forms real code writes for the most common messages are the same values, and the + * same JSON, as the long forms. + */ +class SchemaShortcutsTest { + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + @Test + void newSessionRequestWithOnlyACwdHasNoMcpServers() throws Exception { + var shortForm = new AcpSchema.NewSessionRequest("/workspace"); + var longForm = new AcpSchema.NewSessionRequest("/workspace", List.of()); + + assertThat(shortForm).isEqualTo(longForm); + assertThat(mapper.writeValueAsString(shortForm)).isEqualTo(mapper.writeValueAsString(longForm)) + .isEqualTo("{\"cwd\":\"/workspace\",\"mcpServers\":[]}"); + assertThat(mapper.readValue(mapper.writeValueAsString(shortForm), AcpSchema.NewSessionRequest.class)) + .isEqualTo(shortForm); + } + + @Test + void newSessionResponseWithOnlyAnIdHasNoModesOrOptions() throws Exception { + var shortForm = new AcpSchema.NewSessionResponse("s-1"); + var longForm = new AcpSchema.NewSessionResponse("s-1", null, null); + + assertThat(shortForm).isEqualTo(longForm); + assertThat(mapper.writeValueAsString(shortForm)).isEqualTo(mapper.writeValueAsString(longForm)) + .isEqualTo("{\"sessionId\":\"s-1\"}"); + assertThat(mapper.readValue("{\"sessionId\":\"s-1\"}", AcpSchema.NewSessionResponse.class)) + .isEqualTo(shortForm); + } + + @Test + void aTextPromptIsOneTextBlock() throws Exception { + var shortForm = AcpSchema.PromptRequest.text("s-1", "Fix the failing test"); + var longForm = new AcpSchema.PromptRequest("s-1", List.of(new AcpSchema.TextContent("Fix the failing test"))); + + assertThat(shortForm).isEqualTo(longForm); + assertThat(shortForm.text()).isEqualTo("Fix the failing test"); + assertThat(mapper.writeValueAsString(shortForm)).isEqualTo(mapper.writeValueAsString(longForm)); + assertThat(mapper.readValue(mapper.writeValueAsString(shortForm), AcpSchema.PromptRequest.class)) + .isEqualTo(shortForm); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionCapabilitiesWireTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionCapabilitiesWireTest.java new file mode 100644 index 00000000..6d92310a --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionCapabilitiesWireTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.IOException; +import java.util.Map; + +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A session capability is an object on the wire ({@code {}}) or absent. The components are typed + * {@code Object}, so the record takes care of what a caller passes, and of what a peer sends. + */ +class SessionCapabilitiesWireTest { + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + @Test + void trueIsWrittenAsAnEmptyObject() throws IOException { + String json = mapper.writeValueAsString(new AcpSchema.SessionCapabilities(Boolean.TRUE, null, Boolean.TRUE)); + + assertThat(json).contains("\"list\":{}").contains("\"resume\":{}").doesNotContain("true"); + } + + @Test + void falseIsLeftOut() throws IOException { + AcpSchema.SessionCapabilities capabilities = new AcpSchema.SessionCapabilities(Boolean.FALSE, Map.of(), + Boolean.FALSE); + + assertThat(capabilities.list()).isNull(); + assertThat(mapper.writeValueAsString(capabilities)).isEqualTo("{\"close\":{}}"); + } + + @Test + void onlyAnObjectFromThePeerCountsAsAdvertised() throws IOException { + AcpSchema.AgentCapabilities agent = mapper.readValue( + "{\"sessionCapabilities\":{\"list\":true,\"close\":false,\"resume\":{},\"delete\":null," + + "\"fork\":\"yes\",\"additionalDirectories\":{\"x\":1}}}", + AcpSchema.AgentCapabilities.class); + + NegotiatedCapabilities negotiated = NegotiatedCapabilities.fromAgent(agent); + + assertThat(negotiated.supportsListSessions()).isFalse(); + assertThat(negotiated.supportsCloseSession()).isFalse(); + assertThat(negotiated.supportsResumeSession()).isTrue(); + assertThat(negotiated.supportsDeleteSession()).isFalse(); + assertThat(negotiated.supportsForkSession()).isFalse(); + assertThat(negotiated.supportsAdditionalDirectories()).isTrue(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionManagementTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionManagementTest.java index 774dbe5d..659c0945 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionManagementTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionManagementTest.java @@ -121,6 +121,41 @@ void loadSessionResponseRoundTrip() throws IOException { assertThat(result.modes().availableModes()).hasSize(2); } + /** configOptions as the v1.9.1 schema puts it on new, load and resume responses. */ + private static final String CONFIG_OPTIONS = """ + "configOptions":[{"type":"select","id":"model","name":"Model","category":"model",\ + "currentValue":"fast","options":[{"value":"fast","name":"Fast"}]},\ + {"type":"boolean","id":"web","name":"Web search","currentValue":false}]"""; + + @Test + void sessionResponsesReadAndWriteConfigOptions() throws IOException { + var map = new TypeRef>() { + }; + String created = "{\"sessionId\":\"s1\"," + CONFIG_OPTIONS + "}"; + String loaded = "{" + CONFIG_OPTIONS + "}"; + + var newResponse = jsonMapper.readValue(created, AcpSchema.NewSessionResponse.class); + var loadResponse = jsonMapper.readValue(loaded, AcpSchema.LoadSessionResponse.class); + var resumeResponse = jsonMapper.readValue(loaded, AcpSchema.ResumeSessionResponse.class); + + var expected = List.of( + new AcpSchema.SessionConfigSelect("select", "model", "Model", null, "model", "fast", + AcpSchema.SessionConfigSelectOptions.ungrouped(List.of(new AcpSchema.SessionConfigSelectOption("fast", "Fast"))), null), + new AcpSchema.SessionConfigBoolean("web", "Web search", false)); + assertThat(newResponse.configOptions()).isEqualTo(expected); + assertThat(loadResponse.configOptions()).isEqualTo(expected); + assertThat(resumeResponse.configOptions()).isEqualTo(expected); + assertThat(jsonMapper.readValue(jsonMapper.writeValueAsString(newResponse), map)) + .isEqualTo(jsonMapper.readValue(created, map)); + assertThat(jsonMapper.readValue(jsonMapper.writeValueAsString(loadResponse), map)) + .isEqualTo(jsonMapper.readValue(loaded, map)); + assertThat(jsonMapper.readValue(jsonMapper.writeValueAsString(resumeResponse), map)) + .isEqualTo(jsonMapper.readValue(loaded, map)); + assertThat(new AcpSchema.LoadSessionResponse(null).configOptions()).isNull(); + assertThat(new AcpSchema.ResumeSessionResponse(null).configOptions()).isNull(); + assertThat(new AcpSchema.NewSessionResponse("s", null).configOptions()).isNull(); + } + // --------------------------- // SetSessionModeRequest Tests // --------------------------- @@ -240,7 +275,7 @@ void currentModeUpdateDeserialization() throws IOException { @Test void currentModeUpdateRoundTrip() throws IOException { - AcpSchema.CurrentModeUpdate original = new AcpSchema.CurrentModeUpdate("current_mode_update", "code"); + AcpSchema.CurrentModeUpdate original = new AcpSchema.CurrentModeUpdate("code"); String json = jsonMapper.writeValueAsString(original); AcpSchema.SessionUpdate result = jsonMapper.readValue(json, new TypeRef() { @@ -265,7 +300,7 @@ void loadSessionResponseWithNullModes() throws IOException { // Null fields should not be serialized assertThat(node.has("modes")).isFalse(); - assertThat(node.has("models")).isFalse(); + assertThat(node.has("_meta")).isFalse(); } @Test diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateConstructorsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateConstructorsTest.java new file mode 100644 index 00000000..c5cf2e63 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateConstructorsTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The short constructors of the session updates leave the discriminator out: it can only + * be the variant's own name, so only the canonical constructor takes it. + */ +class SessionUpdateConstructorsTest { + + private static final AcpSchema.TextContent TEXT = new AcpSchema.TextContent("hi"); + + @Test + void configOptionUpdateTakesTheFullList() { + List options = List + .of(new AcpSchema.SessionConfigBoolean("web", "Web search", true)); + + assertThat(new AcpSchema.ConfigOptionUpdate(options)) + .isEqualTo(new AcpSchema.ConfigOptionUpdate("config_option_update", options, null)); + } + + @Test + void chunksTakeContentAndOptionalMessageId() { + assertThat(new AcpSchema.UserMessageChunk(TEXT)) + .isEqualTo(new AcpSchema.UserMessageChunk("user_message_chunk", TEXT, null, null)); + assertThat(new AcpSchema.UserMessageChunk(TEXT, "m1")) + .isEqualTo(new AcpSchema.UserMessageChunk("user_message_chunk", TEXT, "m1", null)); + assertThat(new AcpSchema.AgentMessageChunk(TEXT)) + .isEqualTo(new AcpSchema.AgentMessageChunk("agent_message_chunk", TEXT, null, null)); + assertThat(new AcpSchema.AgentMessageChunk(TEXT, "m1")) + .isEqualTo(new AcpSchema.AgentMessageChunk("agent_message_chunk", TEXT, "m1", null)); + assertThat(new AcpSchema.AgentThoughtChunk(TEXT)) + .isEqualTo(new AcpSchema.AgentThoughtChunk("agent_thought_chunk", TEXT, null, null)); + assertThat(new AcpSchema.AgentThoughtChunk(TEXT, "m1")) + .isEqualTo(new AcpSchema.AgentThoughtChunk("agent_thought_chunk", TEXT, "m1", null)); + } + + @Test + void otherUpdatesTakeTheirFieldsOnly() { + var entry = new AcpSchema.PlanEntry("step", AcpSchema.PlanEntryPriority.HIGH, + AcpSchema.PlanEntryStatus.PENDING); + assertThat(new AcpSchema.Plan(List.of(entry))).isEqualTo(new AcpSchema.Plan("plan", List.of(entry), null)); + + var command = new AcpSchema.AvailableCommand("test", "Run the tests", null); + assertThat(new AcpSchema.AvailableCommandsUpdate(List.of(command))) + .isEqualTo(new AcpSchema.AvailableCommandsUpdate("available_commands_update", List.of(command), null)); + + assertThat(new AcpSchema.CurrentModeUpdate("code")) + .isEqualTo(new AcpSchema.CurrentModeUpdate("current_mode_update", "code", null)); + + assertThat(new AcpSchema.UsageUpdate(10L, 100L)) + .isEqualTo(new AcpSchema.UsageUpdate("usage_update", 10L, 100L, null, null)); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateDeserializationTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateDeserializationTest.java index 7831ae4f..7629dc91 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateDeserializationTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/SessionUpdateDeserializationTest.java @@ -306,10 +306,10 @@ void usageUpdateWithCostDeserialization() throws IOException { @Test void toolCallStatusAllValues() throws IOException { // Test all ToolCallStatus enum values can be deserialized - for (AcpSchema.ToolCallStatus status : AcpSchema.ToolCallStatus.values()) { + for (AcpSchema.ToolCallStatus status : AcpSchema.ToolCallStatus.known()) { String json = String.format( "{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"call_test\",\"title\":\"Test\",\"kind\":\"read\",\"status\":\"%s\"}", - status.name().toLowerCase()); + status.value()); AcpSchema.SessionUpdate update = deserializeSessionUpdate(json); assertThat(update).isInstanceOf(AcpSchema.ToolCall.class); @@ -324,7 +324,7 @@ void toolKindAllValues() throws IOException { for (AcpSchema.ToolKind kind : AcpSchema.ToolKind.values()) { String json = String.format( "{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"call_test\",\"title\":\"Test\",\"kind\":\"%s\",\"status\":\"pending\"}", - kind.name().toLowerCase()); + kind.value()); AcpSchema.SessionUpdate update = deserializeSessionUpdate(json); assertThat(update).isInstanceOf(AcpSchema.ToolCall.class); @@ -336,10 +336,10 @@ void toolKindAllValues() throws IOException { @Test void planEntryPriorityAllValues() throws IOException { // Test all PlanEntryPriority enum values can be deserialized - for (AcpSchema.PlanEntryPriority priority : AcpSchema.PlanEntryPriority.values()) { + for (AcpSchema.PlanEntryPriority priority : AcpSchema.PlanEntryPriority.known()) { String json = String.format( "{\"sessionUpdate\":\"plan\",\"entries\":[{\"content\":\"Test\",\"priority\":\"%s\",\"status\":\"pending\"}]}", - priority.name().toLowerCase()); + priority.value()); AcpSchema.SessionUpdate update = deserializeSessionUpdate(json); assertThat(update).isInstanceOf(AcpSchema.Plan.class); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/TerminalAuthTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/TerminalAuthTest.java new file mode 100644 index 00000000..8a799199 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/TerminalAuthTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Terminal authentication (stable in ACP v1, schema v1.9.1): the {@code terminal} auth + * method and the client's {@code auth.terminal} capability. + */ +class TerminalAuthTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + @Test + void initializeResponseReadsBothMethodKinds() throws Exception { + String json = """ + {"protocolVersion":1,"authMethods":[ + {"id":"api-key","name":"API key","description":"Paste a key"}, + {"type":"terminal","id":"login","name":"Log in","args":["--login"],"env":{"MODE":"tui"},"_meta":{"k":1}}]}"""; + + var response = mapper.readValue(json, AcpSchema.InitializeResponse.class); + + assertThat(response.authMethods()).containsExactly( + new AcpSchema.AuthMethodAgent("api-key", "API key", "Paste a key"), + new AcpSchema.AuthMethodTerminal("login", "Log in", null, List.of("--login"), Map.of("MODE", "tui"), + Map.of("k", 1))); + assertWritesBack(response, json); + } + + @Test + void terminalMethodWritesItsTypeOnceAndAgentMethodNone() throws Exception { + assertThat(mapper.writeValueAsString(new AcpSchema.AuthMethodTerminal("login", "Log in", null, null))) + .isEqualTo("{\"id\":\"login\",\"name\":\"Log in\",\"type\":\"terminal\"}"); + assertThat(mapper.writeValueAsString(new AcpSchema.AuthMethodAgent("k", "Key", null))) + .isEqualTo("{\"id\":\"k\",\"name\":\"Key\"}"); + } + + @Test + void agentTypeAndUnknownTypesReadAsAgentMethods() throws Exception { + var methods = mapper.readValue(""" + [{"type":"agent","id":"a","name":"A"},{"type":"env_var","id":"e","name":"E","vars":[]}]""", + new TypeRef>() { + }); + + assertThat(methods).containsExactly(new AcpSchema.AuthMethodAgent("a", "A", null), + new AcpSchema.AuthMethodAgent("e", "E", null)); + assertThat(methods.get(1).id()).isEqualTo("e"); + assertThat(methods.get(1).name()).isEqualTo("E"); + assertThat(methods.get(1).description()).isNull(); + } + + @Test + void clientAdvertisesTerminalAuth() throws Exception { + String json = """ + {"fs":{"readTextFile":true,"writeTextFile":false},"terminal":true,"auth":{"terminal":true}}"""; + + var caps = mapper.readValue(json, AcpSchema.ClientCapabilities.class); + + assertThat(caps.auth()).isEqualTo(new AcpSchema.AuthCapabilities(true)); + assertWritesBack(caps, json); + assertThat(NegotiatedCapabilities.fromClient(caps).supportsTerminalAuth()).isTrue(); + NegotiatedCapabilities.fromClient(caps).requireTerminalAuth(); + } + + @Test + void terminalAuthIsOffUnlessAdvertised() { + var none = NegotiatedCapabilities.fromClient(new AcpSchema.ClientCapabilities()); + var off = NegotiatedCapabilities.fromClient(new AcpSchema.ClientCapabilities(null, null, null, + new AcpSchema.AuthCapabilities(false), null, null)); + + assertThat(none.supportsTerminalAuth()).isFalse(); + assertThat(off.supportsTerminalAuth()).isFalse(); + assertThatThrownBy(none::requireTerminalAuth).isInstanceOf(AcpCapabilityException.class) + .hasMessageContaining("auth.terminal"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ToolCallNameTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ToolCallNameTest.java new file mode 100644 index 00000000..b55ba6ec --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/ToolCallNameTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.util.Map; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The tool call {@code name} (stable in ACP v1, schema v1.9.1): the programmatic name of + * the tool, on tool calls, tool call updates and the tool call of a permission request. + */ +class ToolCallNameTest { + + private static final TypeRef> MAP = new TypeRef<>() { + }; + + private final AcpJsonMapper mapper = AcpJsonMapper.createDefault(); + + private void assertWritesBack(Object value, String json) throws Exception { + assertThat(mapper.readValue(mapper.writeValueAsString(value), MAP)).isEqualTo(mapper.readValue(json, MAP)); + } + + @Test + void toolCallCarriesName() throws Exception { + String json = """ + {"sessionUpdate":"tool_call","toolCallId":"t1","title":"Reading pom.xml","name":"read_file"}"""; + + var call = (AcpSchema.ToolCall) mapper.readValue(json, new TypeRef() { + }); + + assertThat(call.name()).isEqualTo("read_file"); + assertWritesBack(call, json); + } + + @Test + void toolCallUpdateNotificationCarriesName() throws Exception { + String json = """ + {"sessionUpdate":"tool_call_update","toolCallId":"t1","name":"read_file","status":"completed"}"""; + + var update = (AcpSchema.ToolCallUpdateNotification) mapper.readValue(json, + new TypeRef() { + }); + + assertThat(update.name()).isEqualTo("read_file"); + assertWritesBack(update, json); + } + + @Test + void permissionRequestToolCallCarriesName() throws Exception { + String json = """ + {"sessionId":"s","toolCall":{"toolCallId":"t1","name":"write_file"},\ + "options":[{"optionId":"allow","name":"Allow","kind":"allow_once"}]}"""; + + var request = mapper.readValue(json, AcpSchema.RequestPermissionRequest.class); + + assertThat(request.toolCall().name()).isEqualTo("write_file"); + assertWritesBack(request, json); + } + + @Test + void absentAndNullNameBothMeanNoName() throws Exception { + var absent = (AcpSchema.ToolCall) mapper.readValue("{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"t\",\"title\":\"x\"}", + new TypeRef() { + }); + var explicitNull = (AcpSchema.ToolCall) mapper.readValue( + "{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"t\",\"title\":\"x\",\"name\":null}", + new TypeRef() { + }); + + assertThat(absent.name()).isNull(); + assertThat(explicitNull.name()).isNull(); + assertThat(mapper.writeValueAsString(absent)).doesNotContain("name"); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/UnreadableMessageResponseTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/UnreadableMessageResponseTest.java new file mode 100644 index 00000000..09cbb761 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/UnreadableMessageResponseTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spec; + +import java.io.IOException; + +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The answer to a message that cannot be read: JSON-RPC 2.0 prescribes -32700 for text that + * is not JSON and -32600 for JSON that is no JSON-RPC message, both with {@code "id": null}. + */ +class UnreadableMessageResponseTest { + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + @ParameterizedTest + @ValueSource(strings = { "{not json", "{\"jsonrpc\":\"2.0\",", "" }) + void textThatIsNotJsonIsAParseError(String text) throws IOException { + AcpSchema.JSONRPCResponse response = AcpSchema.unreadableMessageResponse(jsonMapper, text); + + assertThat(response.id()).isNull(); + assertThat(response.result()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.PARSE_ERROR); + assertThat(response.error().message()).isEqualTo("Parse error"); + } + + @ParameterizedTest + @ValueSource(strings = { "42", "\"text\"", "null", "[1,2]", "{\"jsonrpc\":\"2.0\"}" }) + void jsonThatIsNoJsonRpcMessageIsAnInvalidRequest(String text) throws IOException { + AcpSchema.JSONRPCResponse response = AcpSchema.unreadableMessageResponse(jsonMapper, text); + + assertThat(response.id()).isNull(); + assertThat(response.error()).isNotNull(); + assertThat(response.error().code()).isEqualTo(AcpErrorCodes.INVALID_REQUEST); + assertThat(response.error().message()).isEqualTo("Invalid Request"); + } + + @ParameterizedTest + @ValueSource(strings = { "{not json", "42" }) + void theAnswerCarriesANullIdOnTheWire(String text) throws IOException { + String json = jsonMapper.writeValueAsString(AcpSchema.unreadableMessageResponse(jsonMapper, text)); + + assertThat(json).contains("\"id\":null").doesNotContain("\"result\""); + assertThat(AcpSchema.deserializeJsonRpcMessage(jsonMapper, json)) + .isInstanceOfSatisfying(AcpSchema.JSONRPCResponse.class, response -> assertThat(response.id()).isNull()); + } + + @ParameterizedTest + @ValueSource(strings = { "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":5}", + "{\"jsonrpc\":\"2.0\",\"method\":true}", + "{\"jsonrpc\":\"2.0\",\"id\":{\"a\":1},\"method\":\"session/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":[1],\"method\":\"session/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":true,\"method\":\"session/list\"}", + "{\"jsonrpc\":\"1.0\",\"id\":1,\"method\":\"session/list\"}", + "{\"id\":1,\"method\":\"session/list\"}", "{\"jsonrpc\":2.0,\"method\":\"session/cancel\"}" }) + void anInvalidRequestIsRefused(String text) { + assertThatThrownBy(() -> AcpSchema.deserializeJsonRpcMessage(jsonMapper, text)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(AcpSchema.unreadableMessageResponse(jsonMapper, text).error().code()) + .isEqualTo(AcpErrorCodes.INVALID_REQUEST); + } + + /** JSON-RPC 2.0 section 5: the id is null only when it could not be read. */ + @Test + void anInvalidRequestWithAReadableIdIsAnsweredWithIt() { + assertThat(AcpSchema.unreadableMessageResponse(jsonMapper, "{\"jsonrpc\":\"2.0\",\"id\":\"r1\",\"method\":5}").id()) + .isEqualTo("r1"); + assertThat(AcpSchema.unreadableMessageResponse(jsonMapper, "{\"jsonrpc\":\"1.0\",\"id\":7,\"method\":\"x\"}").id()) + .isEqualTo(7); + assertThat(AcpSchema.unreadableMessageResponse(jsonMapper, "{\"jsonrpc\":\"2.0\",\"id\":{},\"method\":\"x\"}").id()) + .isNull(); + } + + @ParameterizedTest + @ValueSource(strings = { "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"session/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":\"a\",\"method\":\"session/list\",\"params\":{}}", + "{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"session/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":1.5,\"method\":\"session/list\"}", + "{\"jsonrpc\":\"2.0\",\"method\":\"session/cancel\",\"params\":{}}", + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}" }) + void aValidMessageIsRead(String text) throws IOException { + assertThat(AcpSchema.deserializeJsonRpcMessage(jsonMapper, text)).isNotNull(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/WireFormatTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/WireFormatTest.java index aa65947a..859bc0a2 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/WireFormatTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/spec/WireFormatTest.java @@ -38,7 +38,7 @@ void contentBlockWritesItsDiscriminatorOnceAndFirst() throws IOException { @Test void sessionUpdateNotification() throws IOException { var notification = new AcpSchema.SessionNotification("s1", - new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent("hi"))); + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("hi"))); assertThat(mapper.writeValueAsString(notification)).isEqualTo( "{\"sessionId\":\"s1\",\"update\":{\"sessionUpdate\":\"agent_message_chunk\",\"content\":{\"type\":\"text\",\"text\":\"hi\"}}}"); @@ -63,6 +63,15 @@ void responseEnvelopeWithEnumResult() throws IOException { .isEqualTo("{\"jsonrpc\":\"2.0\",\"id\":\"abc\",\"result\":{\"stopReason\":\"end_turn\"}}"); } + @Test + void responseEnvelopeWithAnEmptyResultWritesAnEmptyObject() throws IOException { + // Never "result": null, and never a dropped result: both break strict peers. + var response = new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, 7, + new AcpSchema.WriteTextFileResponse(), null); + + assertThat(mapper.writeValueAsString(response)).isEqualTo("{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}"); + } + @Test void metaIsWrittenAsGivenIncludingNullValues() throws IOException { Map meta = new LinkedHashMap<>(); @@ -76,11 +85,11 @@ void metaIsWrittenAsGivenIncludingNullValues() throws IOException { @Test void toolCallWithEnums() throws IOException { - var toolCall = new AcpSchema.ToolCall("tool_call", "t1", "Read file", AcpSchema.ToolKind.READ, + var toolCall = new AcpSchema.ToolCall("tool_call", "t1", "Read file", "read_file", AcpSchema.ToolKind.READ, AcpSchema.ToolCallStatus.IN_PROGRESS, List.of(), List.of(), null, null, null); assertThat(mapper.writeValueAsString(toolCall)).isEqualTo("{\"sessionUpdate\":\"tool_call\",\"toolCallId\":\"t1\"," - + "\"title\":\"Read file\",\"kind\":\"read\",\"status\":\"in_progress\",\"content\":[],\"locations\":[]}"); + + "\"title\":\"Read file\",\"name\":\"read_file\",\"kind\":\"read\",\"status\":\"in_progress\",\"content\":[],\"locations\":[]}"); } @Test diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/AbstractProtocolTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/AbstractProtocolTest.java index b1fe677a..53c997ab 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/AbstractProtocolTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/AbstractProtocolTest.java @@ -112,7 +112,7 @@ void messageFromAgentArrivesAtClient() { AcpSchema.JSONRPCNotification notification = AcpTestFixtures.createJsonRpcNotification( AcpSchema.METHOD_SESSION_UPDATE, new AcpSchema.SessionNotification("session-123", - new AcpSchema.AgentMessageChunk("agentMessage", AcpTestFixtures.createTextContent("Hello")))); + new AcpSchema.AgentMessageChunk(AcpTestFixtures.createTextContent("Hello")))); agentTransport.sendMessage(notification).block(TIMEOUT); try { diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java index 4c23befd..78ecbf10 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java @@ -4,8 +4,10 @@ package com.agentclientprotocol.sdk.test; -import java.time.Duration; import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Function; @@ -14,6 +16,7 @@ import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; @@ -55,9 +58,8 @@ public class InMemoryTransportPair { private final InMemoryAgentTransport agentTransport; private InMemoryTransportPair() { - // Bidirectional sinks: client→agent and agent→client - Sinks.Many clientToAgent = Sinks.many().unicast().onBackpressureBuffer(); - Sinks.Many agentToClient = Sinks.many().unicast().onBackpressureBuffer(); + Channel clientToAgent = new Channel(); + Channel agentToClient = new Channel(); this.clientTransport = new InMemoryClientTransport(clientToAgent, agentToClient); this.agentTransport = new InMemoryAgentTransport(agentToClient, clientToAgent); @@ -97,20 +99,56 @@ public Mono closeGracefully() { /** - * Emits on a sink that several threads share. {@code Sinks.many()} sinks reject a - * concurrent emission from another thread with {@code FAIL_NON_SERIALIZED}; the busy - * loop serialises emitters the same way the shipped transports do. + * One direction of the pair, which several threads send on. A {@code Sinks.many()} sink + * refuses a message while another thread is emitting on it ({@code FAIL_NON_SERIALIZED}), + * and that thread can hold it for as long as the receiver takes, since delivery runs on + * the emitting thread. So a send is queued, and whichever thread finds the sink free emits + * the queue in order: no message is dropped and no sender waits, including a receiver that + * sends from inside a delivery. */ - private static Mono emit(Sinks.Many sink, AcpSchema.JSONRPCMessage message, - String what) { - return Mono.fromRunnable(() -> { - try { - sink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - } - catch (Sinks.EmissionException e) { - throw new RuntimeException("Failed to send " + what + ": " + e.getReason(), e); - } - }); + private static final class Channel { + + private final Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + + private final Queue pending = new ConcurrentLinkedQueue<>(); + + private final AtomicInteger emitters = new AtomicInteger(); + + Flux asFlux() { + return sink.asFlux(); + } + + void complete() { + sink.tryEmitComplete(); + } + + Mono send(AcpSchema.JSONRPCMessage message, String what) { + return Mono.fromRunnable(() -> { + pending.offer(message); + if (emitters.getAndIncrement() != 0) { + return; // the thread emitting now emits this message too + } + // A failure means the channel is closed, so it is reported to this sender. + Sinks.EmitResult sent = Sinks.EmitResult.OK; + int missed = 1; + do { + AcpSchema.JSONRPCMessage next; + while ((next = pending.poll()) != null) { + Sinks.EmitResult result = sink.tryEmitNext(next); + if (result.isFailure()) { + sent = result; + } + } + missed = emitters.addAndGet(-missed); + } + while (missed != 0); + if (sent.isFailure()) { + throw new RuntimeException("Failed to send " + what + ": " + sent, + new Sinks.EmissionException(sent)); + } + }); + } + } /** @@ -118,17 +156,16 @@ private static Mono emit(Sinks.Many sink, AcpSch */ private static class InMemoryClientTransport implements AcpClientTransport { - private final Sinks.Many outbound; + private final Channel outbound; - private final Sinks.Many inbound; + private final Channel inbound; private volatile boolean connected = false; private Consumer exceptionHandler = t -> { }; - InMemoryClientTransport(Sinks.Many outbound, - Sinks.Many inbound) { + InMemoryClientTransport(Channel outbound, Channel inbound) { this.outbound = outbound; this.inbound = inbound; } @@ -153,14 +190,14 @@ public Mono connect(Function, Mono sendMessage(AcpSchema.JSONRPCMessage message) { - return emit(outbound, message, "message"); + return outbound.send(message, "message"); } @Override public Mono closeGracefully() { return Mono.defer(() -> { connected = false; - outbound.tryEmitComplete(); + outbound.complete(); return Mono.empty(); }); } @@ -182,9 +219,9 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { */ private static class InMemoryAgentTransport implements AcpAgentTransport { - private final Sinks.Many outbound; + private final Channel outbound; - private final Sinks.Many inbound; + private final Channel inbound; private final Sinks.One terminationSink = Sinks.one(); @@ -193,8 +230,7 @@ private static class InMemoryAgentTransport implements AcpAgentTransport { private Consumer exceptionHandler = t -> { }; - InMemoryAgentTransport(Sinks.Many outbound, - Sinks.Many inbound) { + InMemoryAgentTransport(Channel outbound, Channel inbound) { this.outbound = outbound; this.inbound = inbound; } @@ -213,7 +249,7 @@ public Mono start(Function, Mono Mono.just(message) .transform(handler) - .flatMap(response -> emit(outbound, response, "response")) + .flatMap(response -> outbound.send(response, "response")) // A failed emission is this message's problem, not the transport's: it // must not terminate the inbound subscription and kill the agent. .onErrorResume(error -> { @@ -227,14 +263,14 @@ public Mono start(Function, Mono sendMessage(AcpSchema.JSONRPCMessage message) { - return emit(outbound, message, "message"); + return outbound.send(message, "message"); } @Override public Mono closeGracefully() { return Mono.defer(() -> { started = false; - outbound.tryEmitComplete(); + outbound.complete(); terminationSink.tryEmitValue(null); return Mono.empty(); }); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java index e2340746..8bbeea3a 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java @@ -5,8 +5,14 @@ package com.agentclientprotocol.sdk.test; import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import com.agentclientprotocol.sdk.AcpTestFixtures; @@ -103,4 +109,87 @@ void closeGracefullyCompletesBothTransports() { } } + @Test + void sendIsDeliveredWhileAnotherThreadIsStillDeliveringItsMessage() throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List received = new CopyOnWriteArrayList<>(); + CountDownLatch firstInHandler = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch bothReceived = new CountDownLatch(2); + + // The first message is delivered on its sender's thread and held there, so that + // thread is still emitting on the sink when the second send arrives. + pair.agentTransport().start(msg -> msg.doOnNext(m -> { + Object id = ((AcpSchema.JSONRPCRequest) m).id(); + if ("1".equals(id)) { + firstInHandler.countDown(); + awaitQuietly(releaseFirst); + } + received.add(id); + bothReceived.countDown(); + }).then(Mono.empty())).subscribe(); + + ExecutorService senders = Executors.newFixedThreadPool(2); + try { + senders.submit(() -> pair.clientTransport().sendMessage(request("1")).block(TIMEOUT)); + assertThat(firstInHandler.await(5, TimeUnit.SECONDS)).isTrue(); + + // Held well past the 100 ms a busy loop would wait before giving up. + Future second = senders.submit(() -> pair.clientTransport().sendMessage(request("2")).block(TIMEOUT)); + try { + second.get(1, TimeUnit.SECONDS); + } + catch (TimeoutException stillWaiting) { + // Waiting for the first delivery to finish is allowed; dropping is not. + } + releaseFirst.countDown(); + second.get(5, TimeUnit.SECONDS); + + assertThat(bothReceived.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(received).containsExactly("1", "2"); + } + finally { + releaseFirst.countDown(); + senders.shutdownNow(); + pair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void sendFromInsideDeliveryOnTheSameSinkIsDeliveredInOrder() throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List received = new CopyOnWriteArrayList<>(); + CountDownLatch bothReceived = new CountDownLatch(2); + + pair.agentTransport().start(msg -> msg.doOnNext(m -> { + Object id = ((AcpSchema.JSONRPCRequest) m).id(); + received.add(id); + if ("1".equals(id)) { + // Re-entrant: the sender's thread is still delivering message 1. + pair.clientTransport().sendMessage(request("2")).block(TIMEOUT); + } + bothReceived.countDown(); + }).then(Mono.empty())).subscribe(); + + pair.clientTransport().sendMessage(request("1")).block(TIMEOUT); + + assertThat(bothReceived.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(received).containsExactly("1", "2"); + pair.closeGracefully().block(TIMEOUT); + } + + private static AcpSchema.JSONRPCRequest request(String id) { + return new AcpSchema.JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, id, + new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())); + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(5, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java index 55c79de6..ed611f36 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java @@ -33,7 +33,7 @@ void defaultMockAgentAcceptsInitialize() throws Exception { AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); AcpSchema.InitializeResponse response = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) + .initialize() .block(TIMEOUT); assertThat(response).isNotNull(); @@ -60,7 +60,7 @@ void mockAgentRecordsPrompts() throws Exception { Thread.sleep(100); AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); mockAgent.expectPrompts(1); @@ -93,7 +93,7 @@ void mockAgentRecordsCancellations() throws Exception { Thread.sleep(100); AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.cancel(new AcpSchema.CancelNotification("cancel-session")).block(TIMEOUT); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClient.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClient.java index 8158ca48..05932513 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClient.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClient.java @@ -97,14 +97,11 @@ public static MockAcpClient createDefault(AcpClientTransport transport) { /** * Initializes the connection with the agent. - * Advertises all capabilities (file read, file write, terminal) by default. + * Advertises file read and write, which it handles, and no terminal. * @return The initialize response */ public AcpSchema.InitializeResponse initialize() { - // Mock client advertises all capabilities by default - AcpSchema.FileSystemCapability fs = new AcpSchema.FileSystemCapability(true, true); - AcpSchema.ClientCapabilities caps = new AcpSchema.ClientCapabilities(fs, true); - return delegate.initialize(new AcpSchema.InitializeRequest(1, caps)).block(timeout); + return delegate.initialize().block(timeout); } /** @@ -329,8 +326,11 @@ public MockAcpClient build() { MockAcpClient mockClient = new MockAcpClient(null, transport, requestTimeout); AcpAsyncClient delegate = AcpClient.async(transport) + // The mock client advertises what it has handlers for: file read and write. It has no + // terminal handlers, so it does not advertise terminal. + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, true), false)) .requestTimeout(requestTimeout) - .sessionUpdateConsumer(notification -> { + .sessionUpdateHandler(notification -> { mockClient.receivedUpdates.add(notification); mockClient.updateLatch.countDown(); return Mono.empty(); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java index bed33453..5d4f91d6 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java @@ -72,9 +72,7 @@ void mockClientReceivesSessionUpdates() throws Exception { .promptHandler((request, updater) -> { // Send update during prompt return updater - .sendUpdate("update-session", - new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Processing..."))) + .sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("Processing..."))) .then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))); }) .build(); @@ -113,8 +111,7 @@ void mockClientHandlesPermissionRequests() throws Exception { .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("perm-session", null, null))) .promptHandler((request, updater) -> { - AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Edit File", - AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING, null, null, null, null); + AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Edit File", AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING); List options = List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE)); diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/AcpSchedulersTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/AcpSchedulersTest.java index c7d823d5..c8622897 100644 --- a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/AcpSchedulersTest.java +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/AcpSchedulersTest.java @@ -5,6 +5,7 @@ package com.agentclientprotocol.sdk.util; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -53,4 +54,32 @@ void aBlockingTimeoutHandlerDoesNotDelayAnotherSessionsTimeout() throws Exceptio } } + @Test + void timeoutsAreDeliveredOnVirtualThreadsWhereTheJdkHasThem() throws Exception { + CompletableFuture delivered = new CompletableFuture<>(); + AcpSchedulers.withTimeout(Mono.never(), Duration.ofMillis(10)) + .subscribe(v -> { + }, error -> delivered.complete(Thread.currentThread())); + Thread thread = delivered.get(5, TimeUnit.SECONDS); + assertThat(VirtualThreads.isVirtual(thread)).isEqualTo(Runtime.version().feature() >= 21); + assertThat(thread.isDaemon()).isTrue(); + } + + /** + * Both kinds of delivery threads, whichever JDK runs the test: a virtual thread per delivery + * (a daemon platform thread where the JDK has none), and the cached pool of daemon platform + * threads JDK 17 gets. + */ + @Test + void eitherKindOfDeliveryThreadIsANamedDaemon() throws Exception { + for (boolean virtualThreads : new boolean[] { true, false }) { + CompletableFuture delivered = new CompletableFuture<>(); + AcpSchedulers.deliveryScheduler(virtualThreads).schedule(() -> delivered.complete(Thread.currentThread())); + Thread thread = delivered.get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("acp-timeout-delivery"); + assertThat(thread.isDaemon()).isTrue(); + assertThat(VirtualThreads.isVirtual(thread)).isEqualTo(virtualThreads && VirtualThreads.isSupported()); + } + } + } diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/HandlerFailuresTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/HandlerFailuresTest.java new file mode 100644 index 00000000..2130bf9b --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/HandlerFailuresTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * How a handler's {@link Error} is turned into something Reactor signals, and how a + * {@link VirtualMachineError} is still reported. + */ +class HandlerFailuresTest { + + @Test + void anExceptionIsSignalledAsItIs() { + IllegalStateException exception = new IllegalStateException("boom"); + assertThat(HandlerFailures.contain(exception)).isSameAs(exception); + } + + @Test + void anErrorIsCarriedByAHandlerErrorNamingOnlyItsType() { + NoSuchMethodError error = new NoSuchMethodError("secret"); + Throwable contained = HandlerFailures.contain(error); + assertThat(contained).isInstanceOf(HandlerFailures.HandlerError.class) + .hasCause(error) + .hasMessage(NoSuchMethodError.class.getName()); + } + + @Test + void aSynchronousErrorFromAMonoHandlerBecomesItsErrorSignal() { + StepVerifier.create(HandlerFailures.invoke(() -> { + throw new NoSuchMethodError("secret"); + })).expectErrorSatisfies(error -> assertThat(error).isInstanceOf(HandlerFailures.HandlerError.class) + .hasCauseInstanceOf(NoSuchMethodError.class)).verify(); + } + + @Test + void aBlockingCallPassesExceptionsAndWrapsErrors() throws Exception { + assertThat(HandlerFailures.guard(() -> "ok").call()).isEqualTo("ok"); + assertThatThrownBy(() -> HandlerFailures.guard(() -> { + throw new IOException("checked"); + }).call()).isInstanceOf(IOException.class); + assertThatThrownBy(() -> HandlerFailures.guard(() -> { + throw new AssertionError("secret"); + }).call()).isInstanceOf(HandlerFailures.HandlerError.class).hasCauseInstanceOf(AssertionError.class); + assertThatThrownBy(() -> HandlerFailures.guard((Runnable) () -> { + throw new LinkageError("secret"); + }).run()).isInstanceOf(HandlerFailures.HandlerError.class).hasCauseInstanceOf(LinkageError.class); + Mono wrapped = Mono.fromCallable(HandlerFailures.guard(() -> { + throw new NoClassDefFoundError("secret"); + })); + StepVerifier.create(wrapped).expectError(HandlerFailures.HandlerError.class).verify(); + } + + /** Answered like any error, and also handed to the thread's uncaught-exception handler. */ + @Test + void aVirtualMachineErrorIsAlsoReportedToTheThreadsUncaughtExceptionHandler() throws Exception { + List reported = new CopyOnWriteArrayList<>(); + AtomicReference contained = new AtomicReference<>(); + OutOfMemoryError outOfMemory = new OutOfMemoryError("test"); + Thread thread = new Thread(() -> contained.set(HandlerFailures.contain(outOfMemory))); + thread.setUncaughtExceptionHandler((t, e) -> reported.add(e)); + thread.start(); + thread.join(); + + assertThat(reported).containsExactly(outOfMemory); + assertThat(contained.get()).isInstanceOf(HandlerFailures.HandlerError.class).hasCause(outOfMemory); + } + + @Test + void anErrorThatIsNotFatalIsNotReported() throws Exception { + List reported = new CopyOnWriteArrayList<>(); + Thread thread = new Thread(() -> HandlerFailures.contain(new AssertionError())); + thread.setUncaughtExceptionHandler((t, e) -> reported.add(e)); + thread.start(); + thread.join(); + + assertThat(reported).isEmpty(); + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/OutboundSinksLincheckTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/OutboundSinksLincheckTest.java new file mode 100644 index 00000000..653b7804 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/OutboundSinksLincheckTest.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.ThreadIdGen; +import org.jetbrains.lincheck.datastructures.Validate; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Sinks; + +/** + * Model checks {@link OutboundSinks#emit}: several threads emitting on one transport's + * outbound sink (the shape every transport uses: a unicast sink whose writer subscribed) + * lose no message and keep each thread's messages in order (#14: a plain emission that + * collided with another failed with {@code FAIL_NON_SERIALIZED} and the message was lost). + */ +class OutboundSinksLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static final int THREADS = 3; + + /** Lincheck numbers its threads from 1. */ + private static final int IDS = THREADS + 1; + + @Test + void concurrentEmissionsAreAllWrittenInOrder() { + new ModelCheckingOptions().iterations(10 * SCALE) + .invocationsPerIteration(200) + .threads(THREADS) + .actorsPerThread(2) + .actorsBefore(0) + .actorsAfter(0) + .sequentialSpecification(EmitterSpec.class) + .check(Emitter.class); + } + + public static class Emitter { + + private final Sinks.Many outbound = Sinks.many().unicast().onBackpressureBuffer(); + + /** What the transport's writer wrote, in order. */ + private final Queue written = new ConcurrentLinkedQueue<>(); + + /** Messages each thread emitted; each slot is touched only by its own thread. */ + private final int[] emitted = new int[IDS]; + + public Emitter() { + outbound.asFlux().subscribe(written::add); + } + + @Operation + public void emit(@Param(gen = ThreadIdGen.class) int thread) { + OutboundSinks.emit(outbound, thread + ":" + emitted[thread]++); + } + + @Validate + public void everyMessageWrittenOnceInOrder() { + int[] next = new int[IDS]; + for (String message : written) { + int thread = Integer.parseInt(message.substring(0, message.indexOf(':'))); + String inOrder = thread + ":" + next[thread]++; + if (!message.equals(inOrder)) { + throw new IllegalStateException("written " + written + ": expected " + inOrder); + } + } + for (int thread = 1; thread < IDS; thread++) { + if (next[thread] != emitted[thread]) { + throw new IllegalStateException("thread " + thread + " emitted " + emitted[thread] + + " messages, written " + written); + } + } + } + + } + + public static class EmitterSpec { + + public void emit(int thread) { + } + + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/SerialExecutorTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/SerialExecutorTest.java new file mode 100644 index 00000000..3945fa4c --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/SerialExecutorTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A serial executor runs the tasks given to it one at a time, in order, on the threads of an + * executor that may run tasks concurrently. + */ +class SerialExecutorTest { + + @Test + void runsTasksOneAtATimeInTheOrderGiven() throws Exception { + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + SerialExecutor serial = new SerialExecutor(pool); + AtomicInteger running = new AtomicInteger(); + AtomicInteger maxRunning = new AtomicInteger(); + List order = new CopyOnWriteArrayList<>(); + CountDownLatch done = new CountDownLatch(1000); + IntStream.range(0, 1000).forEach(i -> serial.execute(() -> { + maxRunning.accumulateAndGet(running.incrementAndGet(), Math::max); + order.add(i); + running.decrementAndGet(); + done.countDown(); + })); + assertThat(done.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(maxRunning.get()).isEqualTo(1); + assertThat(order).containsExactlyElementsOf(IntStream.range(0, 1000).boxed().toList()); + } + finally { + pool.shutdownNow(); + } + } + + @Test + void aFailingTaskDoesNotStopTheTasksAfterIt() throws Exception { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try { + SerialExecutor serial = new SerialExecutor(pool); + CountDownLatch after = new CountDownLatch(1); + serial.execute(() -> { + throw new IllegalStateException("boom"); + }); + serial.execute(after::countDown); + assertThat(after.await(5, TimeUnit.SECONDS)).isTrue(); + } + finally { + pool.shutdownNow(); + } + } + +} diff --git a/acp-core/src/test/java/com/agentclientprotocol/sdk/util/VirtualThreadsTest.java b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/VirtualThreadsTest.java new file mode 100644 index 00000000..4a4c4b42 --- /dev/null +++ b/acp-core/src/test/java/com/agentclientprotocol/sdk/util/VirtualThreadsTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.util; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Virtual threads where the JDK has them (21 and later), daemon platform threads otherwise. The + * tests that give the handles make both choices on whichever JDK runs them: the coverage gate + * requires every branch of {@link VirtualThreads} covered on JDK 17 and on JDK 21. + */ +class VirtualThreadsTest { + + private static final boolean JDK_21 = Runtime.version().feature() >= 21; + + @Test + void supportedExactlyOnJdk21AndLater() { + assertThat(VirtualThreads.isSupported()).isEqualTo(JDK_21); + } + + @Test + void theFactoryMakesNamedVirtualThreadsOnJdk21ElseNamedDaemonThreads() throws Exception { + ThreadFactory factory = VirtualThreads.factoryOrDaemon("acp-test-vt"); + CompletableFuture ran = new CompletableFuture<>(); + Thread thread = factory.newThread(() -> ran.complete(Thread.currentThread())); + thread.start(); + Thread current = ran.get(5, TimeUnit.SECONDS); + assertThat(current.getName()).isEqualTo("acp-test-vt"); + assertThat(current.isDaemon()).isTrue(); + assertThat(VirtualThreads.isVirtual(current)).isEqualTo(JDK_21); + } + + @Test + void thePerTaskExecutorStartsANamedVirtualThreadPerTaskOnJdk21AndIsAbsentBefore() throws Exception { + java.util.concurrent.ExecutorService executor = VirtualThreads.newPerTaskExecutor("acp-test-task"); + if (!JDK_21) { + assertThat(executor).isNull(); + return; + } + try { + Thread thread = executor.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + assertThat(VirtualThreads.isVirtual(thread)).isTrue(); + assertThat(thread.getName()).isEqualTo("acp-test-task"); + } + finally { + executor.shutdownNow(); + } + } + + // The handles a JDK without virtual threads has: none. + + @Test + void withoutTheHandlesNothingIsVirtualAndTheFactoryMakesDaemonThreads() throws Exception { + assertThat(VirtualThreads.isSupported(null)).isFalse(); + assertThat(VirtualThreads.isVirtual(null, Thread.currentThread())).isFalse(); + assertThat(VirtualThreads.newPerTaskExecutor(null, null, "acp-test")).isNull(); + assertThat(VirtualThreads.newPerTaskExecutor(perTaskHandle(), null, "acp-test")).isNull(); + assertDaemonPlatformThread(VirtualThreads.factoryOrDaemon(null, "acp-test-daemon"), "acp-test-daemon"); + } + + // Handles that stand in for the JDK 21 ones, so the JDK 21 choice is made on JDK 17 too. + + @Test + void withTheHandlesTheirAnswersAreUsed() throws Exception { + MethodHandle factory = handle("namedFactory", ThreadFactory.class, String.class); + assertThat(VirtualThreads.isSupported(factory)).isTrue(); + assertThat(VirtualThreads.isVirtual(handle("isDaemon", boolean.class, Thread.class), daemon())).isTrue(); + + ThreadFactory named = VirtualThreads.factoryOrDaemon(factory, "acp-test-named"); + assertThat(named.newThread(() -> { + }).getName()).isEqualTo("stand-in acp-test-named"); + + ExecutorService executor = VirtualThreads.newPerTaskExecutor(perTaskHandle(), factory, "acp-test-task"); + try { + Thread thread = executor.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + assertThat(thread.getName()).isEqualTo("stand-in acp-test-task"); + } + finally { + executor.shutdownNow(); + } + } + + @Test + void handlesThatFailFallBack() throws Exception { + MethodHandle failingFactory = handle("failingFactory", ThreadFactory.class, String.class); + assertThat(VirtualThreads.isVirtual(handle("failingIsVirtual", boolean.class, Thread.class), + Thread.currentThread())) + .isFalse(); + assertDaemonPlatformThread(VirtualThreads.factoryOrDaemon(failingFactory, "acp-test-fallback"), + "acp-test-fallback"); + assertThat(VirtualThreads.newPerTaskExecutor(handle("failingPerTask", ExecutorService.class, ThreadFactory.class), + handle("namedFactory", ThreadFactory.class, String.class), "acp-test")) + .isNull(); + } + + private static void assertDaemonPlatformThread(ThreadFactory factory, String name) throws Exception { + CompletableFuture ran = new CompletableFuture<>(); + Thread thread = factory.newThread(() -> ran.complete(Thread.currentThread())); + thread.start(); + Thread current = ran.get(5, TimeUnit.SECONDS); + assertThat(current.getName()).isEqualTo(name); + assertThat(current.isDaemon()).isTrue(); + assertThat(VirtualThreads.isVirtual(current)).isFalse(); + } + + private static Thread daemon() { + Thread thread = new Thread(() -> { + }); + thread.setDaemon(true); + return thread; + } + + private static MethodHandle perTaskHandle() throws ReflectiveOperationException { + return MethodHandles.publicLookup() + .findStatic(Executors.class, "newSingleThreadExecutor", + MethodType.methodType(ExecutorService.class, ThreadFactory.class)); + } + + private static MethodHandle handle(String name, Class returnType, Class parameterType) + throws ReflectiveOperationException { + if (name.equals("isDaemon")) { + return MethodHandles.publicLookup().findVirtual(Thread.class, name, MethodType.methodType(returnType)); + } + return MethodHandles.lookup() + .findStatic(VirtualThreadsTest.class, name, MethodType.methodType(returnType, parameterType)); + } + + static ThreadFactory namedFactory(String name) { + return runnable -> new Thread(runnable, "stand-in " + name); + } + + static ThreadFactory failingFactory(String name) { + throw new IllegalStateException("no factory for " + name); + } + + static boolean failingIsVirtual(Thread thread) { + throw new IllegalStateException("cannot tell " + thread.getName()); + } + + static ExecutorService failingPerTask(ThreadFactory factory) { + throw new IllegalStateException("no executor for " + factory); + } + +} diff --git a/acp-core/src/test/resources/archunit.properties b/acp-core/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-core/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-websocket-jetty/src/test/resources/logback-test.xml b/acp-core/src/test/resources/logback-stdio-agent.xml similarity index 50% rename from acp-websocket-jetty/src/test/resources/logback-test.xml rename to acp-core/src/test/resources/logback-stdio-agent.xml index 4550b08d..e7c0efe4 100644 --- a/acp-websocket-jetty/src/test/resources/logback-test.xml +++ b/acp-core/src/test/resources/logback-stdio-agent.xml @@ -1,16 +1,14 @@ + - + + System.err %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - - + diff --git a/acp-core/src/test/resources/schema/v1/schema.json b/acp-core/src/test/resources/schema/v1/schema.json new file mode 100644 index 00000000..fa1242c6 --- /dev/null +++ b/acp-core/src/test/resources/schema/v1/schema.json @@ -0,0 +1,5855 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Agent Client Protocol", + "anyOf": [ + { + "title": "Agent", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + } + }, + "required": ["jsonrpc"], + "anyOf": [ + { + "title": "Request", + "allOf": [ + { + "$ref": "#/$defs/AgentRequest" + } + ] + }, + { + "title": "Response", + "allOf": [ + { + "$ref": "#/$defs/AgentResponse" + } + ] + }, + { + "title": "Notification", + "allOf": [ + { + "$ref": "#/$defs/AgentNotification" + } + ] + } + ] + }, + { + "title": "Client", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + } + }, + "required": ["jsonrpc"], + "anyOf": [ + { + "title": "Request", + "allOf": [ + { + "$ref": "#/$defs/ClientRequest" + } + ] + }, + { + "title": "Response", + "allOf": [ + { + "$ref": "#/$defs/ClientResponse" + } + ] + }, + { + "title": "Notification", + "allOf": [ + { + "$ref": "#/$defs/ClientNotification" + } + ] + } + ] + }, + { + "title": "ProtocolLevel", + "description": "A message (request, response, or notification) with `\"jsonrpc\": \"2.0\"` specified as\n[required by JSON-RPC 2.0 Specification][1].\n\n[1]: https://www.jsonrpc.org/specification#compatibility", + "type": "object", + "properties": { + "jsonrpc": { + "type": "string", + "enum": ["2.0"] + }, + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "General protocol-level notifications that all sides are expected to\nimplement.\n\nNotifications whose methods start with '$/' are messages which\nare protocol implementation dependent and might not be implementable in all\nclients or agents. For example if the implementation uses a single threaded\nsynchronous programming language then there is little it can do to react to\na `$/cancel_request` notification. If an agent or client receives\nnotifications starting with '$/' it is free to ignore the notification.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "CancelRequestNotification", + "description": "Cancels an ongoing request.\n\nThis is a notification sent by the side that sent a request to cancel that request.\n\nUpon receiving this notification, the receiver:\n\n1. MAY cancel the corresponding request activity and all nested activities\n2. MAY send any pending notifications.\n3. MUST send one of these responses for the original request:\n - Valid response with appropriate data (partial results or cancellation marker)\n - Error response with code `-32800` (Cancelled)\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "allOf": [ + { + "$ref": "#/$defs/CancelRequestNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["jsonrpc", "method"], + "x-docs-ignore": true + } + ], + "$defs": { + "AgentRequest": { + "description": "A JSON-RPC request object.", + "type": "object", + "properties": { + "id": { + "description": "The request id used to correlate the matching response.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "method": { + "description": "The method name to invoke.", + "type": "string" + }, + "params": { + "description": "Method-specific request parameters.", + "anyOf": [ + { + "description": "All possible requests that an agent can send to a client.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from agent to client.", + "anyOf": [ + { + "title": "WriteTextFileRequest", + "description": "Writes content to a text file in the client's file system.\n\nOnly available if the client advertises the `fs.writeTextFile` capability.\nAllows the agent to create or modify files within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileRequest" + } + ] + }, + { + "title": "ReadTextFileRequest", + "description": "Reads content from a text file in the client's file system.\n\nOnly available if the client advertises the `fs.readTextFile` capability.\nAllows the agent to access file contents within the client's environment.\n\nSee protocol docs: [Client](https://agentclientprotocol.com/protocol/overview#client)", + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileRequest" + } + ] + }, + { + "title": "RequestPermissionRequest", + "description": "Requests permission from the user for a tool call operation.\n\nCalled by the agent when it needs user authorization before executing\na potentially sensitive operation. The client should present the options\nto the user and return their decision.\n\nIf the client cancels the prompt turn via `session/cancel`, it MUST\nrespond to this request with `RequestPermissionOutcome::Cancelled`.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionRequest" + } + ] + }, + { + "title": "CreateTerminalRequest", + "description": "Executes a command in a new terminal\n\nOnly available if the `terminal` Client capability is set to `true`.\n\nReturns a `TerminalId` that can be used with other terminal methods\nto get the current output, wait for exit, and kill the command.\n\nThe `TerminalId` can also be used to embed the terminal in a tool call\nby using the `ToolCallContent::Terminal` variant.\n\nThe Agent is responsible for releasing the terminal by using the `terminal/release`\nmethod.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalRequest" + } + ] + }, + { + "title": "TerminalOutputRequest", + "description": "Gets the terminal output and exit status\n\nReturns the current content in the terminal without waiting for the command to exit.\nIf the command has already exited, the exit status is included.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputRequest" + } + ] + }, + { + "title": "ReleaseTerminalRequest", + "description": "Releases a terminal\n\nThe command is killed if it hasn't exited yet. Use `terminal/wait_for_exit`\nto wait for the command to exit before releasing the terminal.\n\nAfter release, the `TerminalId` can no longer be used with other `terminal/*` methods,\nbut tool calls that already contain it, continue to display its output.\n\nThe `terminal/kill` method can be used to terminate the command without releasing\nthe terminal, allowing the Agent to call `terminal/output` and other methods.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalRequest" + } + ] + }, + { + "title": "WaitForTerminalExitRequest", + "description": "Waits for the terminal command to exit and return its exit status\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitRequest" + } + ] + }, + { + "title": "KillTerminalRequest", + "description": "Kills the terminal command without releasing the terminal\n\nWhile `terminal/release` will also kill the command, this method will keep\nthe `TerminalId` valid so it can be used with other methods.\n\nThis method can be helpful when implementing command timeouts which terminate\nthe command as soon as elapsed, and then get the final output so it can be sent\nto the model.\n\nNote: Call `terminal/release` when `TerminalId` is no longer needed.\n\nSee protocol docs: [Terminals](https://agentclientprotocol.com/protocol/terminals)", + "allOf": [ + { + "$ref": "#/$defs/KillTerminalRequest" + } + ] + }, + { + "title": "CreateElicitationRequest", + "description": "Requests structured user input via a form or URL.\n\nSee protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation)", + "allOf": [ + { + "$ref": "#/$defs/CreateElicitationRequest" + } + ] + }, + { + "title": "ExtMethodRequest", + "description": "Handles extension method requests from the agent.\n\nAllows the Agent to send an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "x-docs-ignore": true + }, + "RequestId": { + "description": "JSON RPC Request Id\n\nAn identifier established by the Client that MUST contain a String, Number, or NULL value if included. If it is not included it is assumed to be a notification. The value SHOULD normally not be Null \\[1\\] and Numbers SHOULD NOT contain fractional parts \\[2\\]\n\nThe Server MUST reply with the same value in the Response object if included. This member is used to correlate the context between the two objects.\n\n\\[1\\] The use of Null as a value for the id member in a Request object is discouraged, because this specification uses a value of Null for Responses with an unknown id. Also, because JSON-RPC 1.0 uses an id value of Null for Notifications this could cause confusion in handling.\n\n\\[2\\] Fractional parts may be problematic, since many decimal fractions cannot be represented exactly as binary fractions.", + "anyOf": [ + { + "title": "Null", + "description": "The JSON-RPC `null` request id.", + "type": "null" + }, + { + "title": "Number", + "description": "A numeric JSON-RPC request id.", + "type": "integer", + "format": "int64" + }, + { + "title": "Str", + "description": "A string JSON-RPC request id.", + "type": "string" + } + ] + }, + "WriteTextFileRequest": { + "description": "Request to write content to a text file.\n\nOnly available if the client supports the `fs.writeTextFile` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "path": { + "description": "Absolute path to the file to write.", + "type": "string" + }, + "content": { + "description": "The text content to write to the file.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "path", "content"], + "x-side": "client", + "x-method": "fs/write_text_file" + }, + "SessionId": { + "description": "A unique identifier for a conversation session between a client and agent.\n\nSessions maintain their own context, conversation history, and state,\nallowing multiple independent interactions with the same agent.\n\nSee protocol docs: [Session ID](https://agentclientprotocol.com/protocol/session-setup#session-id)", + "type": "string" + }, + "ReadTextFileRequest": { + "description": "Request to read content from a text file.\n\nOnly available if the client supports the `fs.readTextFile` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "path": { + "description": "Absolute path to the file to read.", + "type": "string" + }, + "line": { + "description": "Line number to start reading from (1-based).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "limit": { + "description": "Maximum number of lines to read.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "path"], + "x-side": "client", + "x-method": "fs/read_text_file" + }, + "RequestPermissionRequest": { + "description": "Request for user permission to execute a tool call.\n\nSent when the agent needs authorization before performing a sensitive operation.\n\nSee protocol docs: [Requesting Permission](https://agentclientprotocol.com/protocol/tool-calls#requesting-permission)", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "toolCall": { + "description": "Details about the tool call requiring permission.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ] + }, + "options": { + "description": "Available permission options for the user to choose from.", + "type": "array", + "items": { + "$ref": "#/$defs/PermissionOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "toolCall", "options"], + "x-side": "client", + "x-method": "session/request_permission" + }, + "ToolCallUpdate": { + "description": "An update to an existing tool call.\n\nUsed to report progress and results as tools execute. All fields except\nthe tool call ID are optional - only changed fields need to be included.\n\nSee protocol docs: [Updating](https://agentclientprotocol.com/protocol/tool-calls#updating)", + "type": "object", + "properties": { + "toolCallId": { + "description": "The ID of the tool call being updated.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ] + }, + "kind": { + "description": "Update the tool kind.", + "anyOf": [ + { + "$ref": "#/$defs/ToolKind" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "status": { + "description": "Update the execution status.", + "anyOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "title": { + "description": "Update the human-readable title.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "name": { + "description": "Update the programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that\nthe existing name is left unchanged.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "content": { + "description": "Replace the content collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "locations": { + "description": "Replace the locations collection.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "rawInput": { + "description": "Update the raw input.", + "x-deserialize-default-on-error": true + }, + "rawOutput": { + "description": "Update the raw output.", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["toolCallId"] + }, + "ToolCallId": { + "description": "Unique identifier for a tool call within a session.", + "type": "string" + }, + "ToolKind": { + "description": "Categories of tools that can be invoked.\n\nTool kinds help clients choose appropriate icons and optimize how they\ndisplay tool execution progress.\n\nSee protocol docs: [Creating](https://agentclientprotocol.com/protocol/tool-calls#creating)", + "oneOf": [ + { + "description": "Reading files or data.", + "type": "string", + "const": "read" + }, + { + "description": "Modifying files or content.", + "type": "string", + "const": "edit" + }, + { + "description": "Removing files or data.", + "type": "string", + "const": "delete" + }, + { + "description": "Moving or renaming files.", + "type": "string", + "const": "move" + }, + { + "description": "Searching for information.", + "type": "string", + "const": "search" + }, + { + "description": "Running commands or code.", + "type": "string", + "const": "execute" + }, + { + "description": "Internal reasoning or planning.", + "type": "string", + "const": "think" + }, + { + "description": "Retrieving external data.", + "type": "string", + "const": "fetch" + }, + { + "description": "Switching the current session mode.", + "type": "string", + "const": "switch_mode" + }, + { + "description": "Other tool types (default).", + "type": "string", + "const": "other" + } + ] + }, + "ToolCallStatus": { + "description": "Execution status of a tool call.\n\nTool calls progress through different statuses during their lifecycle.\n\nSee protocol docs: [Status](https://agentclientprotocol.com/protocol/tool-calls#status)", + "oneOf": [ + { + "description": "The tool call hasn't started running yet because the input is either\nstreaming or we're awaiting approval.", + "type": "string", + "const": "pending" + }, + { + "description": "The tool call is currently running.", + "type": "string", + "const": "in_progress" + }, + { + "description": "The tool call completed successfully.", + "type": "string", + "const": "completed" + }, + { + "description": "The tool call failed with an error.", + "type": "string", + "const": "failed" + } + ] + }, + "ToolCallContent": { + "description": "Content produced by a tool call.\n\nTool calls can produce different types of content including\nstandard content blocks (text, images) or file diffs.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "oneOf": [ + { + "description": "Standard content block (text, images, resources).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "content" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Content" + } + ] + }, + { + "description": "File modification shown as a diff.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "diff" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Diff" + } + ] + }, + { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "terminal" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/Terminal" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "ContentBlock": { + "description": "Content blocks represent displayable information in the Agent Client Protocol.\n\nThey provide a structured way to handle various types of user-facing content—whether\nit's text from language models, images for analysis, or embedded resources for context.\n\nContent blocks appear in:\n- User prompts sent via `session/prompt`\n- Language model output streamed through `session/update` notifications\n- Progress updates and results from tool calls\n\nThis structure is compatible with the Model Context Protocol (MCP), enabling\nagents to seamlessly forward content from MCP tool outputs without transformation.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/content)", + "oneOf": [ + { + "description": "Text content. May be plain text or formatted with Markdown.\n\nAll agents MUST support text content blocks in prompts.\nClients SHOULD render this text as Markdown.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/TextContent" + } + ] + }, + { + "description": "Images for visual context or analysis.\n\nRequires the `image` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/ImageContent" + } + ] + }, + { + "description": "Audio data for transcription or analysis.\n\nRequires the `audio` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "audio" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/AudioContent" + } + ] + }, + { + "description": "References to resources that the agent can access.\n\nAll agents MUST support resource links in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource_link" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/ResourceLink" + } + ] + }, + { + "description": "Complete resource contents embedded directly in the message.\n\nPreferred for including context as it avoids extra round-trips.\n\nRequires the `embeddedContext` prompt capability when included in prompts.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "resource" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResource" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "Annotations": { + "description": "Optional annotations for the client. The client can use annotations to inform how objects are used or displayed", + "type": "object", + "properties": { + "audience": { + "description": "Intended recipients for this content, such as the user or assistant.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/Role" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "lastModified": { + "description": "Timestamp indicating when the underlying resource was last modified.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "priority": { + "description": "Relative importance of this content when clients choose what to surface.", + "type": ["number", "null"], + "format": "double", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "Role": { + "description": "The sender or recipient of messages and data in a conversation.", + "oneOf": [ + { + "description": "The assistant side of a conversation.", + "type": "string", + "const": "assistant" + }, + { + "description": "The user side of a conversation.", + "type": "string", + "const": "user" + } + ] + }, + "TextContent": { + "description": "Text provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "text": { + "description": "Text payload carried by this content block.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["text"] + }, + "ImageContent": { + "description": "An image provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "data": { + "description": "Base64-encoded media payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": "string" + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["data", "mimeType"] + }, + "AudioContent": { + "description": "Audio provided to or from an LLM.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "data": { + "description": "Base64-encoded media payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["data", "mimeType"] + }, + "ResourceLink": { + "description": "A resource that the server is capable of reading, included in a prompt or tool call result.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Optional human-readable details shown with this protocol object.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "name": { + "description": "Human-readable name shown for this protocol object.", + "type": "string" + }, + "size": { + "description": "Optional size of the linked resource in bytes, if known.", + "type": ["integer", "null"], + "format": "int64", + "x-deserialize-default-on-error": true + }, + "title": { + "description": "Optional display title for end-user UI.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "uri"] + }, + "EmbeddedResourceResource": { + "description": "Resource content that can be embedded in a message.", + "anyOf": [ + { + "title": "TextResourceContents", + "description": "Text resource contents embedded directly in the message.", + "allOf": [ + { + "$ref": "#/$defs/TextResourceContents" + } + ] + }, + { + "title": "BlobResourceContents", + "description": "Binary resource contents embedded directly in the message.", + "allOf": [ + { + "$ref": "#/$defs/BlobResourceContents" + } + ] + } + ] + }, + "TextResourceContents": { + "description": "Text-based resource contents.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "text": { + "description": "Text payload carried by this content block.", + "type": "string" + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["text", "uri"] + }, + "BlobResourceContents": { + "description": "Binary resource contents.", + "type": "object", + "properties": { + "blob": { + "description": "Base64-encoded bytes for a binary resource payload.", + "type": "string" + }, + "mimeType": { + "description": "MIME type describing the encoded media payload.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "uri": { + "description": "URI associated with this resource or media payload.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["blob", "uri"] + }, + "EmbeddedResource": { + "description": "The contents of a resource, embedded into a prompt or tool call result.", + "type": "object", + "properties": { + "annotations": { + "description": "Optional annotations that help clients decide how to display or route this content.", + "anyOf": [ + { + "$ref": "#/$defs/Annotations" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "resource": { + "description": "Embedded resource payload, either text or binary data.", + "allOf": [ + { + "$ref": "#/$defs/EmbeddedResourceResource" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["resource"] + }, + "Content": { + "description": "Standard content block (text, images, resources).", + "type": "object", + "properties": { + "content": { + "description": "The actual content block.", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"] + }, + "Diff": { + "description": "A diff representing file modifications.\n\nShows changes to files in a format suitable for display in the client UI.\n\nSee protocol docs: [Content](https://agentclientprotocol.com/protocol/tool-calls#content)", + "type": "object", + "properties": { + "path": { + "description": "The absolute file path being modified.", + "type": "string" + }, + "oldText": { + "description": "The original content (None for new files).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "newText": { + "description": "The new content after modification.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["path", "newText"] + }, + "TerminalId": { + "description": "Typed identifier used for terminal values on the wire.", + "type": "string" + }, + "Terminal": { + "description": "Embed a terminal created with `terminal/create` by its id.\n\nThe terminal must be added before calling `terminal/release`.\n\nSee protocol docs: [Terminal](https://agentclientprotocol.com/protocol/terminals)", + "type": "object", + "properties": { + "terminalId": { + "description": "Identifier of the terminal instance to embed in the content stream.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["terminalId"] + }, + "ToolCallLocation": { + "description": "A file location being accessed or modified by a tool.\n\nEnables clients to implement \"follow-along\" features that track\nwhich files the agent is working with in real-time.\n\nSee protocol docs: [Following the Agent](https://agentclientprotocol.com/protocol/tool-calls#following-the-agent)", + "type": "object", + "properties": { + "path": { + "description": "The absolute file path being accessed or modified.", + "type": "string" + }, + "line": { + "description": "Optional line number within the file.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["path"] + }, + "PermissionOption": { + "description": "An option presented to the user when requesting permission.", + "type": "object", + "properties": { + "optionId": { + "description": "Unique identifier for this permission option.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ] + }, + "name": { + "description": "Human-readable label to display to the user.", + "type": "string" + }, + "kind": { + "description": "Hint about the nature of this permission option.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionKind" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["optionId", "name", "kind"] + }, + "PermissionOptionId": { + "description": "Unique identifier for a permission option.", + "type": "string" + }, + "PermissionOptionKind": { + "description": "The type of permission option being presented to the user.\n\nHelps clients choose appropriate icons and UI treatment.", + "oneOf": [ + { + "description": "Allow this operation only this time.", + "type": "string", + "const": "allow_once" + }, + { + "description": "Allow this operation and remember the choice.", + "type": "string", + "const": "allow_always" + }, + { + "description": "Reject this operation only this time.", + "type": "string", + "const": "reject_once" + }, + { + "description": "Reject this operation and remember the choice.", + "type": "string", + "const": "reject_always" + } + ] + }, + "CreateTerminalRequest": { + "description": "Request to create a new terminal and execute a command.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "command": { + "description": "The command to execute.", + "type": "string" + }, + "args": { + "description": "Array of command arguments.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "env": { + "description": "Environment variables for the command.", + "type": "array", + "items": { + "$ref": "#/$defs/EnvVariable" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "cwd": { + "description": "Working directory for the command. Must be an absolute path.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "outputByteLimit": { + "description": "Maximum number of output bytes to retain.\n\nWhen the limit is exceeded, the Client truncates from the beginning of the output\nto stay within the limit.\n\nThe Client MUST ensure truncation happens at a character boundary to maintain valid\nstring output, even if this means the retained output is slightly less than the\nspecified limit.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "command"], + "x-side": "client", + "x-method": "terminal/create" + }, + "EnvVariable": { + "description": "An environment variable to set when launching an MCP server.", + "type": "object", + "properties": { + "name": { + "description": "The name of the environment variable.", + "type": "string" + }, + "value": { + "description": "The value to set for the environment variable.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "value"] + }, + "TerminalOutputRequest": { + "description": "Request to get the current output and status of a terminal.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to get output from.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/output" + }, + "ReleaseTerminalRequest": { + "description": "Request to release a terminal and free its resources.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to release.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/release" + }, + "WaitForTerminalExitRequest": { + "description": "Request to wait for a terminal command to exit.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to wait for.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/wait_for_exit" + }, + "KillTerminalRequest": { + "description": "Request to kill a terminal without releasing it.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session ID for this request.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "terminalId": { + "description": "The ID of the terminal to kill.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "terminalId"], + "x-side": "client", + "x-method": "terminal/kill" + }, + "CreateElicitationRequest": { + "description": "Request from the agent to elicit structured user input.\n\nThe agent sends this to the client to request information from the user,\neither via a form or by directing them to a URL.\nElicitations are tied to a session (optionally a tool call) or a request.", + "type": "object", + "properties": { + "message": { + "description": "A human-readable message describing what input is needed.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "anyOf": [ + { + "description": "Form-based elicitation where the client renders a form from the provided schema.", + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationFormMode" + } + ] + }, + { + "description": "URL-based elicitation where the client directs the user to a URL.", + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationUrlMode" + } + ] + }, + { + "title": "other", + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this mode should preserve the raw payload\nwhen storing, replaying, proxying, or forwarding elicitation requests.\nThey MUST NOT render it as a known elicitation mode.", + "type": "object", + "properties": { + "mode": { + "description": "Custom or future elicitation mode.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["mode"], + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "unevaluatedProperties": true, + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "form" + } + }, + "required": ["mode"] + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "url" + } + }, + "required": ["mode"] + } + ] + } + } + ], + "required": ["message"], + "x-side": "client", + "x-method": "elicitation/create" + }, + "ElicitationSessionScope": { + "description": "Session-scoped elicitation, optionally tied to a specific tool call.\n\nWhen `tool_call_id` is set, the elicitation is tied to a specific tool call.\nThis is useful when an agent receives an elicitation from an MCP server\nduring a tool call and needs to redirect it to the user.", + "type": "object", + "properties": { + "sessionId": { + "description": "The session this elicitation is tied to.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "toolCallId": { + "description": "Optional tool call within the session.\n\nOptional. Omitted and `null` are equivalent and mean the elicitation is scoped to the\nsession without a specific tool call.", + "anyOf": [ + { + "$ref": "#/$defs/ToolCallId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + } + }, + "required": ["sessionId"] + }, + "ElicitationRequestScope": { + "description": "Request-scoped elicitation, tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "type": "object", + "properties": { + "requestId": { + "description": "The request this elicitation is tied to.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + } + }, + "required": ["requestId"] + }, + "ElicitationSchema": { + "description": "Type-safe elicitation schema for requesting structured user input.\n\nThis represents a JSON Schema object with primitive-typed properties,\nas required by the elicitation specification.", + "type": "object", + "properties": { + "type": { + "description": "Type discriminator. Always `\"object\"`.", + "x-deserialize-default-on-error": true, + "default": "object", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSchemaType" + } + ] + }, + "title": { + "description": "Optional title for the schema.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "properties": { + "description": "Property definitions (must be primitive types).", + "type": "object", + "default": {}, + "additionalProperties": { + "$ref": "#/$defs/ElicitationPropertySchema" + } + }, + "required": { + "description": "List of required property names.\n\nOptional. Omitted and `null` are equivalent and mean no property names are required.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "description": { + "description": "Optional description of what this schema represents.\n\nOptional. Omitted and `null` are equivalent and mean no schema description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationSchemaType": { + "description": "Type discriminator for elicitation schemas.", + "oneOf": [ + { + "description": "Object schema type.", + "type": "string", + "const": "object" + } + ] + }, + "ElicitationPropertySchema": { + "description": "Property schema for elicitation form fields.\n\nEach variant corresponds to a JSON Schema `\"type\"` value.\nSingle-select enums use the `String` variant with `enum` or `oneOf` set.\nMulti-select enums use the `Array` variant.", + "anyOf": [ + { + "description": "String property (or single-select enum when `enum`/`oneOf` is set).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/StringPropertySchema" + } + ] + }, + { + "description": "Number (floating-point) property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/NumberPropertySchema" + } + ] + }, + { + "description": "Integer property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/IntegerPropertySchema" + } + ] + }, + { + "description": "Boolean property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/BooleanPropertySchema" + } + ] + }, + { + "description": "Multi-select array property.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/MultiSelectPropertySchema" + } + ] + }, + { + "title": "other", + "description": "Custom or future elicitation property schema.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nClients that do not understand this property schema type should preserve\nthe raw schema when storing, replaying, proxying, or forwarding\nelicitation requests. They MUST NOT render it as a known input control.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future elicitation property schema type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "number" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "integer" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + } + ] + }, + "StringFormat": { + "description": "String format types for string properties in elicitation schemas.", + "oneOf": [ + { + "description": "Email address format.", + "type": "string", + "const": "email" + }, + { + "description": "URI format.", + "type": "string", + "const": "uri" + }, + { + "description": "Date format (YYYY-MM-DD).", + "type": "string", + "const": "date" + }, + { + "description": "Date-time format (ISO 8601).", + "type": "string", + "const": "date-time" + } + ] + }, + "EnumOption": { + "description": "A titled enum option with a const value, human-readable title, and optional description.", + "type": "object", + "properties": { + "const": { + "description": "The constant value for this option.", + "type": "string" + }, + "title": { + "description": "Human-readable title for this option.", + "type": "string" + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["const", "title"] + }, + "StringPropertySchema": { + "description": "Schema for string properties in an elicitation form.\n\nWhen `enum` or `oneOf` is set, this represents a single-select enum\nwith `\"type\": \"string\"`.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minLength": { + "description": "Minimum string length.\n\nOptional. Omitted and `null` are equivalent and mean there is no minimum length constraint.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0 + }, + "maxLength": { + "description": "Maximum string length.\n\nOptional. Omitted and `null` are equivalent and mean there is no maximum length constraint.", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0 + }, + "pattern": { + "description": "Pattern the string must match.\n\nOptional. Omitted and `null` are equivalent and mean there is no pattern constraint.", + "type": ["string", "null"] + }, + "format": { + "description": "String format.\n\nOptional. Omitted and `null` are equivalent and mean there is no format constraint.", + "anyOf": [ + { + "$ref": "#/$defs/StringFormat" + }, + { + "type": "null" + } + ] + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "enum": { + "description": "Enum values for untitled single-select enums.\nOptional. Omitted and `null` are equivalent and mean no untitled single-select choices are\ndeclared by `enum`.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "oneOf": { + "description": "Titled enum options for titled single-select enums.\nOptional. Omitted and `null` are equivalent and mean no titled single-select choices are\ndeclared by `oneOf`.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/EnumOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "NumberPropertySchema": { + "description": "Schema for number (floating-point) properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minimum": { + "description": "Minimum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive lower bound.", + "type": ["number", "null"], + "format": "double" + }, + "maximum": { + "description": "Maximum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive upper bound.", + "type": ["number", "null"], + "format": "double" + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["number", "null"], + "format": "double", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "IntegerPropertySchema": { + "description": "Schema for integer properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minimum": { + "description": "Minimum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive lower bound.", + "type": ["integer", "null"], + "format": "int64" + }, + "maximum": { + "description": "Maximum value (inclusive).\n\nOptional. Omitted and `null` are equivalent and mean there is no inclusive upper bound.", + "type": ["integer", "null"], + "format": "int64" + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["integer", "null"], + "format": "int64", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "BooleanPropertySchema": { + "description": "Schema for boolean properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "default": { + "description": "Default value.\n\nOptional. Omitted and `null` are equivalent and mean no default value is provided.", + "type": ["boolean", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "MultiSelectItems": { + "description": "Items for a multi-select (array) property schema.", + "anyOf": [ + { + "description": "Multi-select string items with plain string values.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/StringMultiSelectItems" + } + ] + }, + { + "title": "other", + "description": "Custom or future typed multi-select items.", + "type": "object", + "properties": { + "type": { + "description": "Custom or future multi-select item type.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["type"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "string" + } + }, + "required": ["type"] + } + ] + }, + "additionalProperties": true + }, + { + "title": "titled", + "description": "Titled multi-select items with human-readable labels.", + "allOf": [ + { + "$ref": "#/$defs/TitledMultiSelectItems" + } + ] + } + ] + }, + "StringMultiSelectItems": { + "description": "String item schema for multi-select enum properties.", + "type": "object", + "properties": { + "enum": { + "description": "Allowed enum values.", + "type": "array", + "items": { + "type": "string" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["enum"] + }, + "TitledMultiSelectItems": { + "description": "Items definition for titled multi-select enum properties.", + "type": "object", + "properties": { + "anyOf": { + "description": "Titled enum options.", + "type": "array", + "items": { + "$ref": "#/$defs/EnumOption" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["anyOf"] + }, + "MultiSelectPropertySchema": { + "description": "Schema for multi-select (array) properties in an elicitation form.", + "type": "object", + "properties": { + "title": { + "description": "Optional title for the property.\n\nOptional. Omitted and `null` are equivalent and mean no title is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "description": { + "description": "Human-readable description.\n\nOptional. Omitted and `null` are equivalent and mean no description is provided.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "minItems": { + "description": "Minimum number of items to select.\n\nOptional. Omitted and `null` are equivalent and mean there is no minimum selection count.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0 + }, + "maxItems": { + "description": "Maximum number of items to select.\n\nOptional. Omitted and `null` are equivalent and mean there is no maximum selection count.", + "type": ["integer", "null"], + "format": "uint64", + "minimum": 0 + }, + "items": { + "description": "The items definition describing allowed values.", + "allOf": [ + { + "$ref": "#/$defs/MultiSelectItems" + } + ] + }, + "default": { + "description": "Default selected values.\n\nOptional. Omitted and `null` are equivalent and mean no default selections are provided.", + "type": ["array", "null"], + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["items"] + }, + "ElicitationFormMode": { + "description": "Form-based elicitation mode where the client renders a form from the provided schema.", + "type": "object", + "properties": { + "requestedSchema": { + "description": "A JSON Schema describing the form fields to present to the user.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSchema" + } + ] + } + }, + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "required": ["requestedSchema"] + }, + "ElicitationId": { + "description": "Unique identifier for an elicitation.", + "type": "string" + }, + "ElicitationUrlMode": { + "description": "URL-based elicitation mode where the client directs the user to a URL.", + "type": "object", + "properties": { + "elicitationId": { + "description": "The unique identifier for this elicitation.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationId" + } + ] + }, + "url": { + "description": "The URL to direct the user to.", + "type": "string", + "format": "uri" + } + }, + "anyOf": [ + { + "title": "Session", + "description": "Tied to a session, optionally to a specific tool call within that session.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationSessionScope" + } + ] + }, + { + "title": "Request", + "description": "Tied to a specific JSON-RPC request outside of a session\n(e.g., during auth/configuration phases before any session is started).", + "allOf": [ + { + "$ref": "#/$defs/ElicitationRequestScope" + } + ] + } + ], + "required": ["elicitationId", "url"] + }, + "ExtRequest": { + "description": "Allows for sending an arbitrary request that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "AgentResponse": { + "description": "A JSON-RPC response object.", + "anyOf": [ + { + "title": "Result", + "description": "A successful JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "result": { + "description": "Method-specific response data.", + "anyOf": [ + { + "title": "InitializeResponse", + "description": "Successful result returned for a `initialize` request.", + "allOf": [ + { + "$ref": "#/$defs/InitializeResponse" + } + ] + }, + { + "title": "AuthenticateResponse", + "description": "Successful result returned for a `authenticate` request.", + "allOf": [ + { + "$ref": "#/$defs/AuthenticateResponse" + } + ] + }, + { + "title": "LogoutResponse", + "description": "Successful result returned for a `logout` request.", + "allOf": [ + { + "$ref": "#/$defs/LogoutResponse" + } + ] + }, + { + "title": "NewSessionResponse", + "description": "Successful result returned for a `session/new` request.", + "allOf": [ + { + "$ref": "#/$defs/NewSessionResponse" + } + ] + }, + { + "title": "LoadSessionResponse", + "description": "Successful result returned for a `session/load` request.", + "allOf": [ + { + "$ref": "#/$defs/LoadSessionResponse" + } + ] + }, + { + "title": "ListSessionsResponse", + "description": "Successful result returned for a `session/list` request.", + "allOf": [ + { + "$ref": "#/$defs/ListSessionsResponse" + } + ] + }, + { + "title": "DeleteSessionResponse", + "description": "Successful result returned for a `session/delete` request.", + "allOf": [ + { + "$ref": "#/$defs/DeleteSessionResponse" + } + ] + }, + { + "title": "ResumeSessionResponse", + "description": "Successful result returned for a `session/resume` request.", + "allOf": [ + { + "$ref": "#/$defs/ResumeSessionResponse" + } + ] + }, + { + "title": "CloseSessionResponse", + "description": "Successful result returned for a `session/close` request.", + "allOf": [ + { + "$ref": "#/$defs/CloseSessionResponse" + } + ] + }, + { + "title": "SetSessionModeResponse", + "description": "Successful result returned for a `session/set_mode` request.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeResponse" + } + ] + }, + { + "title": "SetSessionConfigOptionResponse", + "description": "Successful result returned for a `session/set_config_option` request.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionConfigOptionResponse" + } + ] + }, + { + "title": "PromptResponse", + "description": "Successful result returned for a `session/prompt` request.", + "allOf": [ + { + "$ref": "#/$defs/PromptResponse" + } + ] + }, + { + "title": "ExtMethodResponse", + "description": "Successful result returned by an extension method outside the core ACP method set.", + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ] + } + ] + } + }, + "required": ["id", "result"] + }, + { + "title": "Error", + "description": "A failed JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "error": { + "description": "Method-specific error data.", + "allOf": [ + { + "$ref": "#/$defs/Error" + } + ] + } + }, + "required": ["id", "error"] + } + ], + "x-docs-ignore": true + }, + "InitializeResponse": { + "description": "Response to the `initialize` method.\n\nContains the negotiated protocol version and agent capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "type": "object", + "properties": { + "protocolVersion": { + "description": "The protocol version the client specified if supported by the agent,\nor the latest protocol version supported by the agent.\n\nThe client should disconnect, if it doesn't support this version.", + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ] + }, + "agentCapabilities": { + "description": "Capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "loadSession": false, + "promptCapabilities": { + "image": false, + "audio": false, + "embeddedContext": false + }, + "mcpCapabilities": { + "http": false, + "sse": false + }, + "sessionCapabilities": {}, + "auth": {} + }, + "allOf": [ + { + "$ref": "#/$defs/AgentCapabilities" + } + ] + }, + "authMethods": { + "description": "Authentication methods supported by the agent.", + "type": "array", + "items": { + "$ref": "#/$defs/AuthMethod" + }, + "default": [], + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "agentInfo": { + "description": "Information about the Agent name and version sent to the Client.\n\nNote: in future versions of the protocol, this will be required.", + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["protocolVersion"], + "x-side": "agent", + "x-method": "initialize" + }, + "ProtocolVersion": { + "description": "Protocol version identifier.\n\nThis version is only bumped for breaking changes.\nNon-breaking changes should be introduced via capabilities.", + "type": "integer", + "format": "uint16", + "minimum": 0, + "maximum": 65535 + }, + "AgentCapabilities": { + "description": "Capabilities supported by the agent.\n\nAdvertised during initialization to inform the client about\navailable features and content types.\n\nSee protocol docs: [Agent Capabilities](https://agentclientprotocol.com/protocol/initialization#agent-capabilities)", + "type": "object", + "properties": { + "loadSession": { + "description": "Whether the agent supports `session/load`.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "promptCapabilities": { + "description": "Prompt capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "image": false, + "audio": false, + "embeddedContext": false + }, + "allOf": [ + { + "$ref": "#/$defs/PromptCapabilities" + } + ] + }, + "mcpCapabilities": { + "description": "MCP capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": { + "http": false, + "sse": false + }, + "allOf": [ + { + "$ref": "#/$defs/McpCapabilities" + } + ] + }, + "sessionCapabilities": { + "description": "Session lifecycle and prompt capabilities advertised by the agent.", + "x-deserialize-default-on-error": true, + "default": {}, + "allOf": [ + { + "$ref": "#/$defs/SessionCapabilities" + } + ] + }, + "auth": { + "description": "Authentication-related capabilities supported by the agent.", + "x-deserialize-default-on-error": true, + "default": {}, + "allOf": [ + { + "$ref": "#/$defs/AgentAuthCapabilities" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "PromptCapabilities": { + "description": "Prompt capabilities supported by the agent in `session/prompt` requests.\n\nBaseline agent functionality requires support for [`ContentBlock::Text`]\nand [`ContentBlock::ResourceLink`] in prompt requests.\n\nOther variants must be explicitly opted in to.\nCapabilities for different types of content in prompt requests.\n\nIndicates which content types beyond the baseline (text and resource links)\nthe agent can process.\n\nSee protocol docs: [Prompt Capabilities](https://agentclientprotocol.com/protocol/initialization#prompt-capabilities)", + "type": "object", + "properties": { + "image": { + "description": "Agent supports [`ContentBlock::Image`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "audio": { + "description": "Agent supports [`ContentBlock::Audio`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "embeddedContext": { + "description": "Agent supports embedded context in `session/prompt` requests.\n\nWhen enabled, the Client is allowed to include [`ContentBlock::Resource`]\nin prompt requests for pieces of context that are referenced in the message.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "McpCapabilities": { + "description": "MCP capabilities supported by the agent", + "type": "object", + "properties": { + "http": { + "description": "Agent supports [`McpServer::Http`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "sse": { + "description": "Agent supports [`McpServer::Sse`].", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCapabilities": { + "description": "Session capabilities supported by the agent.\n\nAs a baseline, all Agents **MUST** support `session/new`, `session/prompt`, `session/cancel`, and `session/update`.\n\nOptionally, they **MAY** support other session methods and notifications by specifying additional capabilities.\n\nNote: `session/load` is still handled by the top-level `load_session` capability. This will be unified in future versions of the protocol.\n\nSee protocol docs: [Session Capabilities](https://agentclientprotocol.com/protocol/initialization#session-capabilities)", + "type": "object", + "properties": { + "list": { + "description": "Whether the agent supports `session/list`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports listing sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionListCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "delete": { + "description": "Whether the agent supports `session/delete`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports deleting sessions from `session/list`.", + "anyOf": [ + { + "$ref": "#/$defs/SessionDeleteCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "additionalDirectories": { + "description": "Whether the agent supports `additionalDirectories` on supported session lifecycle requests.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports `additionalDirectories` on\nsupported session lifecycle requests.\n\nAgents that also support `session/list` may return\n`SessionInfo.additionalDirectories` to report the complete ordered\nadditional-root list associated with a listed session.", + "anyOf": [ + { + "$ref": "#/$defs/SessionAdditionalDirectoriesCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "resume": { + "description": "Whether the agent supports `session/resume`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports resuming sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionResumeCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "close": { + "description": "Whether the agent supports `session/close`.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports closing sessions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionCloseCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionListCapabilities": { + "description": "Capabilities for the `session/list` method.\n\nSupplying `{}` means the agent supports listing sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionDeleteCapabilities": { + "description": "Capabilities for the `session/delete` method.\n\nSupplying `{}` means the agent supports deleting sessions from `session/list`.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionAdditionalDirectoriesCapabilities": { + "description": "Capabilities for additional session directories support.\n\nSupplying `{}` means the agent supports the `additionalDirectories` field on\nsupported session lifecycle requests. Agents that also support\n`session/list` may return `SessionInfo.additionalDirectories` to report the\ncomplete ordered additional-root list associated with a listed session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionResumeCapabilities": { + "description": "Capabilities for the `session/resume` method.\n\nSupplying `{}` means the agent supports resuming sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionCloseCapabilities": { + "description": "Capabilities for the `session/close` method.\n\nSupplying `{}` means the agent supports closing sessions.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AgentAuthCapabilities": { + "description": "Authentication-related capabilities supported by the agent.", + "type": "object", + "properties": { + "logout": { + "description": "Whether the agent supports the logout method.\n\nOptional. Omitted or `null` both mean the agent does not advertise support.\nSupplying `{}` means the agent supports the logout method.", + "anyOf": [ + { + "$ref": "#/$defs/LogoutCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "LogoutCapabilities": { + "description": "Logout capabilities supported by the agent.\n\nSupplying `{}` means the agent supports the logout method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AuthMethod": { + "description": "Describes an available authentication method.\n\nThe `type` field acts as the discriminator in the serialized JSON form.\nWhen no `type` is present, the method is treated as `agent`.", + "anyOf": [ + { + "description": "Client runs the configured agent program as a separate interactive\nprocess, without passing this method to `authenticate`.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "terminal" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/AuthMethodTerminal" + } + ] + }, + { + "title": "agent", + "description": "Agent handles authentication itself through `authenticate`.\n\nThis is the default when no `type` is specified.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodAgent" + } + ] + } + ] + }, + "AuthMethodId": { + "description": "Typed identifier used for auth method values on the wire.", + "type": "string" + }, + "AuthMethodTerminal": { + "description": "Terminal-based authentication method.\n\nThe client runs the configured agent program as a separate interactive\nprocess for the user to authenticate via a TUI. Agents MUST advertise this\nmethod only when the client enabled its terminal authentication capability.\nA zero exit status signals success; any other termination signals failure.\nThe client MUST NOT pass this method to `authenticate`.", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for this authentication method.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodId" + } + ] + }, + "name": { + "description": "Human-readable name of the authentication method.", + "type": "string" + }, + "description": { + "description": "Optional description providing more details about this authentication method.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "args": { + "description": "Additional arguments to append to the configured agent invocation for terminal auth.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "env": { + "description": "Additional environment variables to set on the configured agent invocation for terminal auth.\nThese values override same-named variables in the base launch configuration.", + "type": "object", + "x-deserialize-default-on-error": true, + "additionalProperties": { + "type": "string" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"] + }, + "AuthMethodAgent": { + "description": "Agent handles authentication itself through `authenticate`.\n\nThis is the default authentication method type.", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for this authentication method.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodId" + } + ] + }, + "name": { + "description": "Human-readable name of the authentication method.", + "type": "string" + }, + "description": { + "description": "Optional description providing more details about this authentication method.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"] + }, + "Implementation": { + "description": "Metadata about the implementation of the client or agent.\nDescribes the name and version of an ACP implementation, with an optional\ntitle for UI representation.", + "type": "object", + "properties": { + "name": { + "description": "Intended for programmatic or logical use, but can be used as a display\nname fallback if title isn’t present.", + "type": "string" + }, + "title": { + "description": "Intended for UI and end-user contexts — optimized to be human-readable\nand easily understood.\n\nIf not provided, the name should be used for display.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "version": { + "description": "Version of the implementation. Can be displayed to the user or used\nfor debugging or metrics purposes. (e.g. \"1.0.0\").", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "version"] + }, + "AuthenticateResponse": { + "description": "Response to the `authenticate` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "authenticate" + }, + "LogoutResponse": { + "description": "Response to the `logout` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "logout" + }, + "NewSessionResponse": { + "description": "Response from creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "type": "object", + "properties": { + "sessionId": { + "description": "Unique identifier for the created session.\n\nUsed in all subsequent requests for this conversation.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/new" + }, + "SessionModeState": { + "description": "The set of modes and the one currently active.", + "type": "object", + "properties": { + "currentModeId": { + "description": "The current mode the Agent is in.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "availableModes": { + "description": "The set of modes that the Agent can operate in", + "type": "array", + "items": { + "$ref": "#/$defs/SessionMode" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["currentModeId", "availableModes"] + }, + "SessionModeId": { + "description": "Unique identifier for a Session Mode.", + "type": "string" + }, + "SessionMode": { + "description": "A mode the agent can operate in.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "id": { + "description": "Stable identifier used to refer to this protocol object in later messages.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "name": { + "description": "Human-readable name shown for this protocol object.", + "type": "string" + }, + "description": { + "description": "Optional human-readable details shown with this protocol object.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"] + }, + "SessionConfigOption": { + "description": "A session configuration option selector and its current state.", + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for the configuration option.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigId" + } + ] + }, + "name": { + "description": "Human-readable label for the option.", + "type": "string" + }, + "description": { + "description": "Optional description for the Client to display to the user.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "category": { + "description": "Optional semantic category for this option (UX only).", + "anyOf": [ + { + "$ref": "#/$defs/SessionConfigOptionCategory" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["id", "name"], + "oneOf": [ + { + "description": "Single-value selector (dropdown).", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "select" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionConfigSelect" + } + ] + }, + { + "description": "Boolean on/off toggle.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/SessionConfigBoolean" + } + ] + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "SessionConfigId": { + "description": "Unique identifier for a session configuration option.", + "type": "string" + }, + "SessionConfigOptionCategory": { + "description": "Semantic category for a session configuration option.\n\nThis is intended to help Clients distinguish broadly common selectors (e.g. model selector vs\nsession mode selector vs thought/reasoning level) for UX purposes (keyboard shortcuts, icons,\nplacement). It MUST NOT be required for correctness. Clients MUST handle missing or unknown\ncategories gracefully.\n\nCategory names beginning with `_` are free for custom use, like other ACP extension methods.\nCategory names that do not begin with `_` are reserved for the ACP spec.", + "anyOf": [ + { + "description": "Session mode selector.", + "type": "string", + "const": "mode" + }, + { + "description": "Model selector.", + "type": "string", + "const": "model" + }, + { + "description": "Model-related configuration parameter.", + "type": "string", + "const": "model_config" + }, + { + "description": "Thought/reasoning level selector.", + "type": "string", + "const": "thought_level" + }, + { + "title": "other", + "description": "Unknown / uncategorized selector.", + "type": "string" + } + ] + }, + "SessionConfigValueId": { + "description": "Unique identifier for a session configuration option value.", + "type": "string" + }, + "SessionConfigSelectOptions": { + "description": "Possible values for a session configuration option.", + "anyOf": [ + { + "title": "Ungrouped", + "description": "A flat list of options with no grouping.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectOption" + } + }, + { + "title": "Grouped", + "description": "A list of options grouped under headers.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectGroup" + } + } + ] + }, + "SessionConfigSelectOption": { + "description": "A possible value for a session configuration option.", + "type": "object", + "properties": { + "value": { + "description": "Unique identifier for this option value.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + }, + "name": { + "description": "Human-readable label for this option value.", + "type": "string" + }, + "description": { + "description": "Optional description for this option value.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["value", "name"] + }, + "SessionConfigSelectGroup": { + "description": "A group of possible values for a session configuration option.", + "type": "object", + "properties": { + "group": { + "description": "Unique identifier for this group.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigGroupId" + } + ] + }, + "name": { + "description": "Human-readable label for this group.", + "type": "string" + }, + "options": { + "description": "The set of option values in this group.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigSelectOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["group", "name", "options"] + }, + "SessionConfigGroupId": { + "description": "Unique identifier for a session configuration option value group.", + "type": "string" + }, + "SessionConfigSelect": { + "description": "A single-value selector (dropdown) session configuration option payload.", + "type": "object", + "properties": { + "currentValue": { + "description": "The currently selected value.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + }, + "options": { + "description": "The set of selectable options.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigSelectOptions" + } + ] + } + }, + "required": ["currentValue", "options"] + }, + "SessionConfigBoolean": { + "description": "A boolean on/off toggle session configuration option payload.", + "type": "object", + "properties": { + "currentValue": { + "description": "The current value of the boolean option.", + "type": "boolean" + } + }, + "required": ["currentValue"] + }, + "LoadSessionResponse": { + "description": "Response from loading an existing session.", + "type": "object", + "properties": { + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/load" + }, + "ListSessionsResponse": { + "description": "Response from listing sessions.", + "type": "object", + "properties": { + "sessions": { + "description": "Array of session information objects", + "type": "array", + "items": { + "$ref": "#/$defs/SessionInfo" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "nextCursor": { + "description": "Opaque cursor token. If present, pass this in the next request's cursor parameter\nto fetch the next page. If absent, there are no more results.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessions"], + "x-side": "agent", + "x-method": "session/list" + }, + "SessionInfo": { + "description": "Information about a session returned by session/list", + "type": "object", + "properties": { + "sessionId": { + "description": "Unique identifier for the session", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots reported for this session. Each path must be absolute.\n\nWhen present, this is the complete ordered additional-root list reported\nby the Agent. Omitted and empty values are equivalent: the response\nreports no additional roots.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "title": { + "description": "Human-readable title for the session", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "updatedAt": { + "description": "ISO 8601 timestamp of last activity", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "cwd"] + }, + "DeleteSessionResponse": { + "description": "Response from deleting a session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/delete" + }, + "ResumeSessionResponse": { + "description": "Response from resuming an existing session.", + "type": "object", + "properties": { + "modes": { + "description": "Initial mode state if supported by the Agent\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "anyOf": [ + { + "$ref": "#/$defs/SessionModeState" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "configOptions": { + "description": "Initial session configuration options if supported by the Agent.", + "type": ["array", "null"], + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/resume" + }, + "CloseSessionResponse": { + "description": "Response from closing a session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/close" + }, + "SetSessionModeResponse": { + "description": "Response to `session/set_mode` method.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/set_mode" + }, + "SetSessionConfigOptionResponse": { + "description": "Response to `session/set_config_option` method.", + "type": "object", + "properties": { + "configOptions": { + "description": "The full set of configuration options and their current values.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["configOptions"], + "x-side": "agent", + "x-method": "session/set_config_option" + }, + "PromptResponse": { + "description": "Response from processing a user prompt.\n\nSee protocol docs: [Check for Completion](https://agentclientprotocol.com/protocol/prompt-turn#4-check-for-completion)", + "type": "object", + "properties": { + "stopReason": { + "description": "Indicates why the agent stopped processing the turn.", + "allOf": [ + { + "$ref": "#/$defs/StopReason" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["stopReason"], + "x-side": "agent", + "x-method": "session/prompt" + }, + "StopReason": { + "description": "Reasons why an agent stops processing a prompt turn.\n\nSee protocol docs: [Stop Reasons](https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons)", + "oneOf": [ + { + "description": "The turn ended successfully.", + "type": "string", + "const": "end_turn" + }, + { + "description": "The turn ended because the agent reached the maximum number of tokens.", + "type": "string", + "const": "max_tokens" + }, + { + "description": "The turn ended because the agent reached the maximum number of allowed\nagent requests between user turns.", + "type": "string", + "const": "max_turn_requests" + }, + { + "description": "The turn ended because the agent refused to continue. The user prompt\nand everything that comes after it won't be included in the next\nprompt, so this should be reflected in the UI.", + "type": "string", + "const": "refusal" + }, + { + "description": "The turn was cancelled by the client via `session/cancel`.\n\nThis stop reason MUST be returned when the client sends a `session/cancel`\nnotification, even if the cancellation causes exceptions in underlying operations.\nAgents should catch these exceptions and return this semantically meaningful\nresponse to confirm successful cancellation.", + "type": "string", + "const": "cancelled" + } + ] + }, + "ExtResponse": { + "description": "Allows for sending an arbitrary response to an [`ExtRequest`] that is not part of the ACP spec.\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "Error": { + "description": "JSON-RPC error object.\n\nRepresents an error that occurred during method execution, following the\nJSON-RPC 2.0 error object specification with optional additional data.\n\nSee protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object)", + "type": "object", + "properties": { + "code": { + "description": "A number indicating the error type that occurred.\nThis must be an integer as defined in the JSON-RPC specification.", + "allOf": [ + { + "$ref": "#/$defs/ErrorCode" + } + ] + }, + "message": { + "description": "A string providing a short description of the error.\nThe message should be limited to a concise single sentence.", + "type": "string" + }, + "data": { + "description": "Optional primitive or structured value that contains additional information about the error.\nThis may include debugging information or context-specific details.", + "x-deserialize-default-on-error": true + } + }, + "required": ["code", "message"] + }, + "ErrorCode": { + "description": "Predefined error codes for common JSON-RPC and ACP-specific errors.\n\nThese codes follow the JSON-RPC 2.0 specification for standard errors\nand use the reserved range (-32000 to -32099) for protocol-specific errors.", + "anyOf": [ + { + "title": "Parse error", + "description": "**Parse error**: Invalid JSON was received by the server.\nAn error occurred on the server while parsing the JSON text.", + "type": "integer", + "format": "int32", + "const": -32700 + }, + { + "title": "Invalid request", + "description": "**Invalid request**: The JSON sent is not a valid Request object.", + "type": "integer", + "format": "int32", + "const": -32600 + }, + { + "title": "Method not found", + "description": "**Method not found**: The method does not exist or is not available.", + "type": "integer", + "format": "int32", + "const": -32601 + }, + { + "title": "Invalid params", + "description": "**Invalid params**: Invalid method parameter(s).", + "type": "integer", + "format": "int32", + "const": -32602 + }, + { + "title": "Internal error", + "description": "**Internal error**: Internal JSON-RPC error.\nReserved for implementation-defined server errors.", + "type": "integer", + "format": "int32", + "const": -32603 + }, + { + "title": "Request cancelled", + "description": "**Request cancelled**: Execution of the method was aborted either due to a cancellation request from the caller or\nbecause of resource constraints or shutdown.", + "type": "integer", + "format": "int32", + "const": -32800 + }, + { + "title": "Authentication required", + "description": "**Authentication required**: Authentication is required before this operation can be performed.", + "type": "integer", + "format": "int32", + "const": -32000 + }, + { + "title": "Resource not found", + "description": "**Resource not found**: A given resource, such as a file, was not found.", + "type": "integer", + "format": "int32", + "const": -32002 + }, + { + "title": "Other", + "description": "Other undefined error code.", + "type": "integer", + "format": "int32" + } + ] + }, + "AgentNotification": { + "description": "A JSON-RPC notification object.", + "type": "object", + "properties": { + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "All possible notifications that an agent can send to a client.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "SessionNotification", + "description": "Handles session update notifications from the agent.\n\nThis is a notification endpoint (no response expected) that receives\nreal-time updates about session progress, including message chunks,\ntool calls, and execution plans.\n\nNote: Clients SHOULD continue accepting tool call updates even after\nsending a `session/cancel` notification, as the agent may send final\nupdates before responding with the cancelled stop reason.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "allOf": [ + { + "$ref": "#/$defs/SessionNotification" + } + ] + }, + { + "title": "CompleteElicitationNotification", + "description": "Notification that a URL-based elicitation has completed.\n\nSee protocol docs: [Elicitation](https://agentclientprotocol.com/protocol/elicitation#url-completion)", + "allOf": [ + { + "$ref": "#/$defs/CompleteElicitationNotification" + } + ] + }, + { + "title": "ExtNotification", + "description": "Handles extension notifications from the agent.\n\nAllows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "x-docs-ignore": true + }, + "SessionNotification": { + "description": "Notification containing a session update from the agent.\n\nUsed to stream real-time progress and results during prompt processing.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session this update pertains to.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "update": { + "description": "The actual update content.", + "allOf": [ + { + "$ref": "#/$defs/SessionUpdate" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "update"], + "x-side": "client", + "x-method": "session/update" + }, + "SessionUpdate": { + "description": "Different types of updates that can be sent during session processing.\n\nThese updates provide real-time feedback about the agent's progress.\n\nSee protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output)", + "oneOf": [ + { + "description": "A chunk of the user's message being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "user_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "A chunk of the agent's response being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "agent_message_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "A chunk of the agent's internal reasoning being streamed.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "agent_thought_chunk" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ContentChunk" + } + ] + }, + { + "description": "Notification that a new tool call has been initiated.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "tool_call" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ToolCall" + } + ] + }, + { + "description": "Update on the status or results of a tool call.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "tool_call_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ToolCallUpdate" + } + ] + }, + { + "description": "The agent's execution plan for complex tasks.\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "plan" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/Plan" + } + ] + }, + { + "description": "Available commands are ready or have changed", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "available_commands_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/AvailableCommandsUpdate" + } + ] + }, + { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "current_mode_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/CurrentModeUpdate" + } + ] + }, + { + "description": "Session configuration options have been updated.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "config_option_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/ConfigOptionUpdate" + } + ] + }, + { + "description": "Session metadata has been updated (title, timestamps, custom metadata)", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "session_info_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/SessionInfoUpdate" + } + ] + }, + { + "description": "Context window and cost update for the session.", + "type": "object", + "properties": { + "sessionUpdate": { + "type": "string", + "const": "usage_update" + } + }, + "required": ["sessionUpdate"], + "allOf": [ + { + "$ref": "#/$defs/UsageUpdate" + } + ] + } + ], + "discriminator": { + "propertyName": "sessionUpdate" + } + }, + "MessageId": { + "description": "Unique identifier for a message within a session.", + "type": "string" + }, + "ContentChunk": { + "description": "A streamed item of content", + "type": "object", + "properties": { + "content": { + "description": "A single item of content", + "allOf": [ + { + "$ref": "#/$defs/ContentBlock" + } + ] + }, + "messageId": { + "description": "A unique identifier for the message this chunk belongs to.\n\nAll chunks belonging to the same message share the same `messageId`.\nA change in `messageId` indicates a new message has started.", + "anyOf": [ + { + "$ref": "#/$defs/MessageId" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"] + }, + "ToolCall": { + "description": "Represents a tool call that the language model has requested.\n\nTool calls are actions that the agent executes on behalf of the language model,\nsuch as reading files, executing code, or fetching data from external sources.\n\nSee protocol docs: [Tool Calls](https://agentclientprotocol.com/protocol/tool-calls)", + "type": "object", + "properties": { + "toolCallId": { + "description": "Unique identifier for this tool call within the session.", + "allOf": [ + { + "$ref": "#/$defs/ToolCallId" + } + ] + }, + "title": { + "description": "Human-readable title describing what the tool is doing.", + "type": "string" + }, + "name": { + "description": "Programmatic name of the tool being invoked.\n\nThis field is optional. Omitting it or sending `null` both mean that no\ntool name is available.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "kind": { + "description": "The category of tool being invoked.\nHelps clients choose appropriate icons and UI treatment.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/ToolKind" + } + ] + }, + "status": { + "description": "Current execution status of the tool call.", + "x-deserialize-default-on-error": true, + "allOf": [ + { + "$ref": "#/$defs/ToolCallStatus" + } + ] + }, + "content": { + "description": "Content produced by the tool call.", + "type": "array", + "items": { + "$ref": "#/$defs/ToolCallContent" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "locations": { + "description": "File locations affected by this tool call.\nEnables \"follow-along\" features in clients.", + "type": "array", + "items": { + "$ref": "#/$defs/ToolCallLocation" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "rawInput": { + "description": "Raw input parameters sent to the tool.", + "x-deserialize-default-on-error": true + }, + "rawOutput": { + "description": "Raw output returned by the tool.", + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["toolCallId", "title"] + }, + "PlanEntry": { + "description": "A single entry in the execution plan.\n\nRepresents a task or goal that the assistant intends to accomplish\nas part of fulfilling the user's request.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "type": "object", + "properties": { + "content": { + "description": "Human-readable description of what this task aims to accomplish.", + "type": "string" + }, + "priority": { + "description": "The relative importance of this task.\nUsed to indicate which tasks are most critical to the overall goal.", + "allOf": [ + { + "$ref": "#/$defs/PlanEntryPriority" + } + ] + }, + "status": { + "description": "Current execution status of this task.", + "allOf": [ + { + "$ref": "#/$defs/PlanEntryStatus" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content", "priority", "status"] + }, + "PlanEntryPriority": { + "description": "Priority levels for plan entries.\n\nUsed to indicate the relative importance or urgency of different\ntasks in the execution plan.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "description": "High priority task - critical to the overall goal.", + "type": "string", + "const": "high" + }, + { + "description": "Medium priority task - important but not critical.", + "type": "string", + "const": "medium" + }, + { + "description": "Low priority task - nice to have but not essential.", + "type": "string", + "const": "low" + } + ] + }, + "PlanEntryStatus": { + "description": "Status of a plan entry in the execution flow.\n\nTracks the lifecycle of each task from planning through completion.\nSee protocol docs: [Plan Entries](https://agentclientprotocol.com/protocol/agent-plan#plan-entries)", + "oneOf": [ + { + "description": "The task has not started yet.", + "type": "string", + "const": "pending" + }, + { + "description": "The task is currently being worked on.", + "type": "string", + "const": "in_progress" + }, + { + "description": "The task has been successfully completed.", + "type": "string", + "const": "completed" + } + ] + }, + "Plan": { + "description": "An execution plan for accomplishing complex tasks.\n\nPlans consist of multiple entries representing individual tasks or goals.\nAgents report plans to clients to provide visibility into their execution strategy.\nPlans can evolve during execution as the agent discovers new requirements or completes tasks.\n\nSee protocol docs: [Agent Plan](https://agentclientprotocol.com/protocol/agent-plan)", + "type": "object", + "properties": { + "entries": { + "description": "The list of tasks to be accomplished.\n\nWhen updating a plan, the agent must send a complete list of all entries\nwith their current status. The client replaces the entire plan with each update.", + "type": "array", + "items": { + "$ref": "#/$defs/PlanEntry" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["entries"] + }, + "AvailableCommand": { + "description": "Information about a command.", + "type": "object", + "properties": { + "name": { + "description": "Command name (e.g., `create_plan`, `research_codebase`).", + "type": "string" + }, + "description": { + "description": "Human-readable description of what the command does.", + "type": "string" + }, + "input": { + "description": "Input for the command if required", + "anyOf": [ + { + "$ref": "#/$defs/AvailableCommandInput" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "description"] + }, + "AvailableCommandInput": { + "description": "The input specification for a command.", + "anyOf": [ + { + "title": "unstructured", + "description": "All text that was typed after the command name is provided as input.", + "allOf": [ + { + "$ref": "#/$defs/UnstructuredCommandInput" + } + ] + } + ] + }, + "UnstructuredCommandInput": { + "description": "All text that was typed after the command name is provided as input.", + "type": "object", + "properties": { + "hint": { + "description": "A hint to display when the input hasn't been provided yet", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["hint"] + }, + "AvailableCommandsUpdate": { + "description": "Available commands are ready or have changed", + "type": "object", + "properties": { + "availableCommands": { + "description": "Commands the agent can execute", + "type": "array", + "items": { + "$ref": "#/$defs/AvailableCommand" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["availableCommands"] + }, + "CurrentModeUpdate": { + "description": "The current mode of the session has changed\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "type": "object", + "properties": { + "currentModeId": { + "description": "The ID of the current mode", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["currentModeId"] + }, + "ConfigOptionUpdate": { + "description": "Session configuration options have been updated.", + "type": "object", + "properties": { + "configOptions": { + "description": "The full set of configuration options and their current values.", + "type": "array", + "items": { + "$ref": "#/$defs/SessionConfigOption" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["configOptions"] + }, + "SessionInfoUpdate": { + "description": "Update to session metadata. All fields are optional to support partial updates.\n\nAgents send this notification to update session information like title or custom metadata.\nThis allows clients to display dynamic session names and track session state changes.", + "type": "object", + "properties": { + "title": { + "description": "Human-readable title for the session. Set to null to clear.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "updatedAt": { + "description": "ISO 8601 timestamp of last activity. Set to null to clear.", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "Cost": { + "description": "Cost information for a session.", + "type": "object", + "properties": { + "amount": { + "description": "Total cumulative cost for session.", + "type": "number", + "format": "double" + }, + "currency": { + "description": "ISO 4217 currency code (e.g., \"USD\", \"EUR\").", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["amount", "currency"] + }, + "UsageUpdate": { + "description": "Context window and cost update for a session.", + "type": "object", + "properties": { + "used": { + "description": "Tokens currently in context.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "size": { + "description": "Total context window size in tokens.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "cost": { + "description": "Cumulative session cost (optional).", + "anyOf": [ + { + "$ref": "#/$defs/Cost" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["used", "size"] + }, + "CompleteElicitationNotification": { + "description": "Notification sent by the agent when a URL-based elicitation is complete.", + "type": "object", + "properties": { + "elicitationId": { + "description": "The ID of the elicitation that completed.", + "allOf": [ + { + "$ref": "#/$defs/ElicitationId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["elicitationId"], + "x-side": "client", + "x-method": "elicitation/complete" + }, + "ExtNotification": { + "description": "Allows the Agent to send an arbitrary notification that is not part of the ACP spec.\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)" + }, + "ClientRequest": { + "description": "A JSON-RPC request object.", + "type": "object", + "properties": { + "id": { + "description": "The request id used to correlate the matching response.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "method": { + "description": "The method name to invoke.", + "type": "string" + }, + "params": { + "description": "Method-specific request parameters.", + "anyOf": [ + { + "description": "All possible requests that a client can send to an agent.\n\nThis enum is used internally for routing RPC requests. You typically won't need\nto use this directly.\n\nThis enum encompasses all method calls from client to agent.", + "anyOf": [ + { + "title": "InitializeRequest", + "description": "Establishes the connection with a client and negotiates protocol capabilities.\n\nThis method is called once at the beginning of the connection to:\n- Negotiate the protocol version to use\n- Exchange capability information between client and agent\n- Determine available authentication methods\n\nThe agent should respond with its supported protocol version and capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "allOf": [ + { + "$ref": "#/$defs/InitializeRequest" + } + ] + }, + { + "title": "AuthenticateRequest", + "description": "Authenticates the client using the specified authentication method.\n\nCalled when the agent requires authentication before allowing session creation.\nThe client provides an authentication method ID that was advertised during\ninitialization and whose type defines the `authenticate` flow.\n\nAfter successful authentication, the client can proceed to create sessions with\n`new_session` without receiving an `auth_required` error.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "allOf": [ + { + "$ref": "#/$defs/AuthenticateRequest" + } + ] + }, + { + "title": "LogoutRequest", + "description": "Logs out of the current authenticated state.\n\nAfter a successful logout, all new sessions will require authentication.\nThere is no guarantee about the behavior of already running sessions.", + "allOf": [ + { + "$ref": "#/$defs/LogoutRequest" + } + ] + }, + { + "title": "NewSessionRequest", + "description": "Creates a new conversation session with the agent.\n\nSessions represent independent conversation contexts with their own history and state.\n\nThe agent should:\n- Create a new session context\n- Connect to any specified MCP servers\n- Return a unique session ID for future requests\n\nMay return an `auth_required` error if the agent requires authentication.\n\nSee protocol docs: [Session Setup](https://agentclientprotocol.com/protocol/session-setup)", + "allOf": [ + { + "$ref": "#/$defs/NewSessionRequest" + } + ] + }, + { + "title": "LoadSessionRequest", + "description": "Loads an existing session to resume a previous conversation.\n\nThis method is only available if the agent advertises the `loadSession` capability.\n\nThe agent should:\n- Restore the session context and conversation history\n- Connect to the specified MCP servers\n- Stream the entire conversation history back to the client via notifications\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "allOf": [ + { + "$ref": "#/$defs/LoadSessionRequest" + } + ] + }, + { + "title": "ListSessionsRequest", + "description": "Lists existing sessions known to the agent.\n\nThis method is only available if the agent advertises the `sessionCapabilities.list` capability.\n\nThe agent should return metadata about sessions with optional filtering and pagination support.", + "allOf": [ + { + "$ref": "#/$defs/ListSessionsRequest" + } + ] + }, + { + "title": "DeleteSessionRequest", + "description": "Deletes an existing session from `session/list`.\n\nThis method is only available if the agent advertises the `sessionCapabilities.delete` capability.", + "allOf": [ + { + "$ref": "#/$defs/DeleteSessionRequest" + } + ] + }, + { + "title": "ResumeSessionRequest", + "description": "Resumes an existing session without returning previous messages.\n\nThis method is only available if the agent advertises the `sessionCapabilities.resume` capability.\n\nThe agent should resume the session context, allowing the conversation to continue\nwithout replaying the message history (unlike `session/load`).", + "allOf": [ + { + "$ref": "#/$defs/ResumeSessionRequest" + } + ] + }, + { + "title": "CloseSessionRequest", + "description": "Closes an active session and frees up any resources associated with it.\n\nThis method is only available if the agent advertises the `sessionCapabilities.close` capability.\n\nThe agent must cancel any ongoing work (as if `session/cancel` was called)\nand then free up any resources associated with the session.", + "allOf": [ + { + "$ref": "#/$defs/CloseSessionRequest" + } + ] + }, + { + "title": "SetSessionModeRequest", + "description": "Sets the current mode for a session.\n\nAllows switching between different agent modes (e.g., \"ask\", \"architect\", \"code\")\nthat affect system prompts, tool availability, and permission behaviors.\n\nThe mode must be one of the modes advertised in `availableModes` during session\ncreation or loading. Agents may also change modes autonomously and notify the\nclient via `current_mode_update` notifications.\n\nThis method can be called at any time during a session, whether the Agent is\nidle or actively generating a response.\n\nSee protocol docs: [Session Modes](https://agentclientprotocol.com/protocol/session-modes)", + "allOf": [ + { + "$ref": "#/$defs/SetSessionModeRequest" + } + ] + }, + { + "title": "SetSessionConfigOptionRequest", + "description": "Sets the current value for a session configuration option.", + "allOf": [ + { + "$ref": "#/$defs/SetSessionConfigOptionRequest" + } + ] + }, + { + "title": "PromptRequest", + "description": "Processes a user prompt within a session.\n\nThis method handles the whole lifecycle of a prompt:\n- Receives user messages with optional context (files, images, etc.)\n- Processes the prompt using language models\n- Reports language model content and tool calls to the Clients\n- Requests permission to run tools\n- Executes any requested tool calls\n- Returns when the turn is complete with a stop reason\n\nSee protocol docs: [Prompt Turn](https://agentclientprotocol.com/protocol/prompt-turn)", + "allOf": [ + { + "$ref": "#/$defs/PromptRequest" + } + ] + }, + { + "title": "ExtMethodRequest", + "description": "Handles extension method requests from the client.\n\nExtension methods provide a way to add custom functionality while maintaining\nprotocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtRequest" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "method"], + "x-docs-ignore": true + }, + "InitializeRequest": { + "description": "Request parameters for the initialize method.\n\nSent by the client to establish connection and negotiate capabilities.\n\nSee protocol docs: [Initialization](https://agentclientprotocol.com/protocol/initialization)", + "type": "object", + "properties": { + "protocolVersion": { + "description": "The latest protocol version supported by the client.", + "allOf": [ + { + "$ref": "#/$defs/ProtocolVersion" + } + ] + }, + "clientCapabilities": { + "description": "Capabilities supported by the client.", + "x-deserialize-default-on-error": true, + "default": { + "fs": { + "readTextFile": false, + "writeTextFile": false + }, + "terminal": false, + "auth": { + "terminal": false + } + }, + "allOf": [ + { + "$ref": "#/$defs/ClientCapabilities" + } + ] + }, + "clientInfo": { + "description": "Information about the Client name and version sent to the Agent.\n\nNote: in future versions of the protocol, this will be required.", + "anyOf": [ + { + "$ref": "#/$defs/Implementation" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["protocolVersion"], + "x-side": "agent", + "x-method": "initialize" + }, + "ClientCapabilities": { + "description": "Capabilities supported by the client.\n\nAdvertised during initialization to inform the agent about\navailable features and methods.\n\nSee protocol docs: [Client Capabilities](https://agentclientprotocol.com/protocol/initialization#client-capabilities)", + "type": "object", + "properties": { + "fs": { + "description": "File system capabilities supported by the client.\nDetermines which file operations the agent can request.", + "x-deserialize-default-on-error": true, + "default": { + "readTextFile": false, + "writeTextFile": false + }, + "allOf": [ + { + "$ref": "#/$defs/FileSystemCapabilities" + } + ] + }, + "terminal": { + "description": "Whether the Client support all `terminal/*` methods.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "session": { + "description": "Session-related capabilities supported by the client.\n\nOptional. Omitted or `null` both mean the client does not advertise any\nsession-related extensions.", + "anyOf": [ + { + "$ref": "#/$defs/ClientSessionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "auth": { + "description": "Authentication capabilities supported by the client.\nDetermines which authentication method types the agent may include\nin its `InitializeResponse`.", + "x-deserialize-default-on-error": true, + "default": { + "terminal": false + }, + "allOf": [ + { + "$ref": "#/$defs/AuthCapabilities" + } + ] + }, + "elicitation": { + "description": "Elicitation capabilities supported by the client.\nDetermines which elicitation modes the agent may use.\n\nOptional. Omitted or `null` both mean the client does not advertise\nelicitation support.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "FileSystemCapabilities": { + "description": "File system capabilities that a client may support.\n\nSee protocol docs: [FileSystem](https://agentclientprotocol.com/protocol/initialization#filesystem)", + "type": "object", + "properties": { + "readTextFile": { + "description": "Whether the Client supports `fs/read_text_file` requests.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "writeTextFile": { + "description": "Whether the Client supports `fs/write_text_file` requests.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ClientSessionCapabilities": { + "description": "Session-related capabilities supported by the client.", + "type": "object", + "properties": { + "configOptions": { + "description": "Config option capabilities supported by the client.\n\nOmitted or `null` both mean the client does not advertise support for any\nconfig option extensions.", + "anyOf": [ + { + "$ref": "#/$defs/SessionConfigOptionsCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "SessionConfigOptionsCapabilities": { + "description": "Session configuration option capabilities supported by the client.", + "type": "object", + "properties": { + "boolean": { + "description": "Whether the client supports boolean session configuration options.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means agents may include `type: \"boolean\"` entries in\n`configOptions`, and the client may send `session/set_config_option`\nrequests with `type: \"boolean\"` and a boolean `value`.", + "anyOf": [ + { + "$ref": "#/$defs/BooleanConfigOptionCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "BooleanConfigOptionCapabilities": { + "description": "Capabilities for boolean session configuration options.\n\nSupplying `{}` means the client supports boolean session configuration options.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AuthCapabilities": { + "description": "Authentication capabilities supported by the client.\n\nAdvertised during initialization to inform the agent which authentication\nmethod types the client can handle. This governs opt-in types that require\nadditional client-side support.", + "type": "object", + "properties": { + "terminal": { + "description": "Whether the client supports `terminal` authentication methods.\n\nThe client should set this to `true` only when it can reproduce the\nconfigured agent invocation in an interactive terminal. When `true`, the\nagent may include `terminal` entries in its authentication methods.", + "type": "boolean", + "default": false, + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationCapabilities": { + "description": "Elicitation capabilities supported by the client.", + "type": "object", + "properties": { + "form": { + "description": "Whether the client supports form-based elicitation.\n\nOptional. Omitted and `null` are equivalent and mean form support is not advertised.\nSupplying `{}` explicitly advertises form support.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationFormCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "url": { + "description": "Whether the client supports URL-based elicitation.\n\nOptional. Omitted or `null` both mean the client does not advertise support.\nSupplying `{}` means the client supports URL-based elicitation.", + "anyOf": [ + { + "$ref": "#/$defs/ElicitationUrlCapabilities" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationFormCapabilities": { + "description": "Form-based elicitation capabilities.\n\nSupplying `{}` means the client supports form-based elicitation.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ElicitationUrlCapabilities": { + "description": "URL-based elicitation capabilities.\n\nSupplying `{}` means the client supports URL-based elicitation.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "AuthenticateRequest": { + "description": "Request parameters for the authenticate method.\n\nSpecifies which authentication method to use.", + "type": "object", + "properties": { + "methodId": { + "description": "The ID of the authentication method to use.\nMust be one of the methods advertised in the initialize response.", + "allOf": [ + { + "$ref": "#/$defs/AuthMethodId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["methodId"], + "x-side": "agent", + "x-method": "authenticate" + }, + "LogoutRequest": { + "description": "Request parameters for the logout method.\n\nTerminates the current authenticated session.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "logout" + }, + "NewSessionRequest": { + "description": "Request parameters for creating a new session.\n\nSee protocol docs: [Creating a Session](https://agentclientprotocol.com/protocol/session-setup#creating-a-session)", + "type": "object", + "properties": { + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots for this session. Each path must be absolute.\n\nThese expand the session's filesystem scope without changing `cwd`, which\nremains the base for relative paths. When omitted or empty, no\nadditional roots are activated for the new session.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "mcpServers": { + "description": "List of MCP (Model Context Protocol) servers the agent should connect to.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["cwd", "mcpServers"], + "x-side": "agent", + "x-method": "session/new" + }, + "McpServer": { + "description": "Configuration for connecting to an MCP (Model Context Protocol) server.\n\nMCP servers provide tools and context that the agent can use when\nprocessing prompts.\n\nSee protocol docs: [MCP Servers](https://agentclientprotocol.com/protocol/session-setup#mcp-servers)", + "anyOf": [ + { + "description": "HTTP transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.http` is `true`.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "http" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/McpServerHttp" + } + ] + }, + { + "description": "SSE transport configuration\n\nOnly available when the Agent capabilities indicate `mcp_capabilities.sse` is `true`.", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sse" + } + }, + "required": ["type"], + "allOf": [ + { + "$ref": "#/$defs/McpServerSse" + } + ] + }, + { + "title": "stdio", + "description": "Stdio transport configuration\n\nAll Agents MUST support this transport.", + "allOf": [ + { + "$ref": "#/$defs/McpServerStdio" + } + ] + } + ] + }, + "HttpHeader": { + "description": "An HTTP header to set when making requests to the MCP server.", + "type": "object", + "properties": { + "name": { + "description": "The name of the HTTP header.", + "type": "string" + }, + "value": { + "description": "The value to set for the HTTP header.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "value"] + }, + "McpServerHttp": { + "description": "HTTP transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "url", "headers"] + }, + "McpServerSse": { + "description": "SSE transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "url": { + "description": "URL to the MCP server.", + "type": "string" + }, + "headers": { + "description": "HTTP headers to set when making requests to the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/HttpHeader" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "url", "headers"] + }, + "McpServerStdio": { + "description": "Stdio transport configuration for MCP.", + "type": "object", + "properties": { + "name": { + "description": "Human-readable name identifying this MCP server.", + "type": "string" + }, + "command": { + "description": "Absolute path to the MCP server executable.", + "type": "string" + }, + "args": { + "description": "Command-line arguments to pass to the MCP server.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Environment variables to set when launching the MCP server.", + "type": "array", + "items": { + "$ref": "#/$defs/EnvVariable" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["name", "command", "args", "env"] + }, + "LoadSessionRequest": { + "description": "Request parameters for loading an existing session.\n\nOnly available if the Agent supports the `loadSession` capability.\n\nSee protocol docs: [Loading Sessions](https://agentclientprotocol.com/protocol/session-setup#loading-sessions)", + "type": "object", + "properties": { + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots to activate for this session. Each path must be absolute.\n\nWhen omitted or empty, no additional roots are activated. When non-empty,\nthis is the complete resulting additional-root list for the loaded\nsession. It may differ from any previously used or reported list as long as\nthe request `cwd` matches the session's `cwd`.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "sessionId": { + "description": "The ID of the session to load.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["mcpServers", "cwd", "sessionId"], + "x-side": "agent", + "x-method": "session/load" + }, + "ListSessionsRequest": { + "description": "Request parameters for listing existing sessions.\n\nOnly available if the Agent supports the `sessionCapabilities.list` capability.", + "type": "object", + "properties": { + "cwd": { + "description": "Filter sessions by working directory. Must be an absolute path.", + "type": ["string", "null"] + }, + "cursor": { + "description": "Opaque cursor token from a previous response's nextCursor field for cursor-based pagination", + "type": ["string", "null"] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "agent", + "x-method": "session/list" + }, + "DeleteSessionRequest": { + "description": "Request parameters for deleting an existing session from `session/list`.\n\nOnly available if the Agent supports the `sessionCapabilities.delete` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to delete.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/delete" + }, + "ResumeSessionRequest": { + "description": "Request parameters for resuming an existing session.\n\nResumes an existing session without returning previous messages (unlike `session/load`).\nThis is useful for agents that can resume sessions but don't implement full session loading.\n\nOnly available if the Agent supports the `sessionCapabilities.resume` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to resume.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "cwd": { + "description": "The working directory for this session. Must be an absolute path.", + "type": "string" + }, + "additionalDirectories": { + "description": "Additional workspace roots to activate for this session. Each path must be absolute.\n\nWhen omitted or empty, no additional roots are activated. When non-empty,\nthis is the complete resulting additional-root list for the resumed\nsession. It may differ from any previously used or reported list as long as\nthe request `cwd` matches the session's `cwd`.", + "type": "array", + "items": { + "type": "string" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "mcpServers": { + "description": "List of MCP servers to connect to for this session.", + "type": "array", + "items": { + "$ref": "#/$defs/McpServer" + }, + "x-deserialize-default-on-error": true, + "x-deserialize-skip-invalid-items": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "cwd"], + "x-side": "agent", + "x-method": "session/resume" + }, + "CloseSessionRequest": { + "description": "Request parameters for closing an active session.\n\nIf supported, the agent **must** cancel any ongoing work related to the session\n(treat it as if `session/cancel` was called) and then free up any resources\nassociated with the session.\n\nOnly available if the Agent supports the `sessionCapabilities.close` capability.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to close.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/close" + }, + "SetSessionModeRequest": { + "description": "Request parameters for setting a session mode.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to set the mode for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "modeId": { + "description": "The ID of the mode to set.", + "allOf": [ + { + "$ref": "#/$defs/SessionModeId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "modeId"], + "x-side": "agent", + "x-method": "session/set_mode" + }, + "SetSessionConfigOptionRequest": { + "description": "Request parameters for setting a session configuration option.", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to set the configuration option for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "configId": { + "description": "The ID of the configuration option to set.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "configId"], + "anyOf": [ + { + "description": "A boolean value (`type: \"boolean\"`).", + "type": "object", + "properties": { + "value": { + "description": "The boolean value.", + "type": "boolean" + }, + "type": { + "type": "string", + "const": "boolean" + } + }, + "required": ["type", "value"] + }, + { + "title": "value_id", + "description": "A [`SessionConfigValueId`] string value.\n\nThis is the default when `type` is absent on the wire. Unknown `type`\nvalues with string payloads also gracefully deserialize into this\nvariant.", + "type": "object", + "properties": { + "value": { + "description": "The value ID.", + "allOf": [ + { + "$ref": "#/$defs/SessionConfigValueId" + } + ] + } + }, + "required": ["value"] + } + ], + "x-side": "agent", + "x-method": "session/set_config_option" + }, + "PromptRequest": { + "description": "Request parameters for sending a user prompt to the agent.\n\nContains the user's message and any additional context.\n\nSee protocol docs: [User Message](https://agentclientprotocol.com/protocol/prompt-turn#1-user-message)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to send this user message to", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "prompt": { + "description": "The blocks of content that compose the user's message.\n\nAs a baseline, the Agent MUST support [`ContentBlock::Text`] and [`ContentBlock::ResourceLink`],\nwhile other variants are optionally enabled via [`PromptCapabilities`].\n\nThe Client MUST adapt its interface according to [`PromptCapabilities`].\n\nThe client MAY include referenced pieces of context as either\n[`ContentBlock::Resource`] or [`ContentBlock::ResourceLink`].\n\nWhen available, [`ContentBlock::Resource`] is preferred\nas it avoids extra round-trips and allows the message to include\npieces of context from sources the agent may not have access to.", + "type": "array", + "items": { + "$ref": "#/$defs/ContentBlock" + } + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId", "prompt"], + "x-side": "agent", + "x-method": "session/prompt" + }, + "ClientResponse": { + "description": "A JSON-RPC response object.", + "anyOf": [ + { + "title": "Result", + "description": "A successful JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "result": { + "description": "Method-specific response data.", + "anyOf": [ + { + "title": "WriteTextFileResponse", + "description": "Successful result returned for a `fs/write_text_file` request.", + "allOf": [ + { + "$ref": "#/$defs/WriteTextFileResponse" + } + ] + }, + { + "title": "ReadTextFileResponse", + "description": "Successful result returned for a `fs/read_text_file` request.", + "allOf": [ + { + "$ref": "#/$defs/ReadTextFileResponse" + } + ] + }, + { + "title": "RequestPermissionResponse", + "description": "Successful result returned for a `session/request_permission` request.", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionResponse" + } + ] + }, + { + "title": "CreateTerminalResponse", + "description": "Successful result returned for a `terminal/create` request.", + "allOf": [ + { + "$ref": "#/$defs/CreateTerminalResponse" + } + ] + }, + { + "title": "TerminalOutputResponse", + "description": "Successful result returned for a `terminal/output` request.", + "allOf": [ + { + "$ref": "#/$defs/TerminalOutputResponse" + } + ] + }, + { + "title": "ReleaseTerminalResponse", + "description": "Successful result returned for a `terminal/release` request.", + "allOf": [ + { + "$ref": "#/$defs/ReleaseTerminalResponse" + } + ] + }, + { + "title": "WaitForTerminalExitResponse", + "description": "Successful result returned for a `terminal/wait_for_exit` request.", + "allOf": [ + { + "$ref": "#/$defs/WaitForTerminalExitResponse" + } + ] + }, + { + "title": "KillTerminalResponse", + "description": "Successful result returned for a `terminal/kill` request.", + "allOf": [ + { + "$ref": "#/$defs/KillTerminalResponse" + } + ] + }, + { + "title": "CreateElicitationResponse", + "description": "Successful result returned for a `elicitation/create` request.", + "allOf": [ + { + "$ref": "#/$defs/CreateElicitationResponse" + } + ] + }, + { + "title": "ExtMethodResponse", + "description": "Successful result returned by an extension method outside the core ACP method set.", + "allOf": [ + { + "$ref": "#/$defs/ExtResponse" + } + ] + } + ] + } + }, + "required": ["id", "result"] + }, + { + "title": "Error", + "description": "A failed JSON-RPC response.", + "type": "object", + "properties": { + "id": { + "description": "The id of the request this response answers.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "error": { + "description": "Method-specific error data.", + "allOf": [ + { + "$ref": "#/$defs/Error" + } + ] + } + }, + "required": ["id", "error"] + } + ], + "x-docs-ignore": true + }, + "WriteTextFileResponse": { + "description": "Response to `fs/write_text_file`", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "fs/write_text_file" + }, + "ReadTextFileResponse": { + "description": "Response containing the contents of a text file.", + "type": "object", + "properties": { + "content": { + "description": "Content payload returned by this response.", + "type": "string" + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["content"], + "x-side": "client", + "x-method": "fs/read_text_file" + }, + "RequestPermissionResponse": { + "description": "Response to a permission request.", + "type": "object", + "properties": { + "outcome": { + "description": "The user's decision on the permission request.", + "allOf": [ + { + "$ref": "#/$defs/RequestPermissionOutcome" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["outcome"], + "x-side": "client", + "x-method": "session/request_permission" + }, + "RequestPermissionOutcome": { + "description": "The outcome of a permission request.", + "oneOf": [ + { + "description": "The prompt turn was cancelled before the user responded.\n\nWhen a client sends a `session/cancel` notification to cancel an ongoing\nprompt turn, it MUST respond to all pending `session/request_permission`\nrequests with this `Cancelled` outcome.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "cancelled" + } + }, + "required": ["outcome"] + }, + { + "description": "The user selected one of the provided options.", + "type": "object", + "properties": { + "outcome": { + "type": "string", + "const": "selected" + } + }, + "required": ["outcome"], + "allOf": [ + { + "$ref": "#/$defs/SelectedPermissionOutcome" + } + ] + } + ], + "discriminator": { + "propertyName": "outcome" + } + }, + "SelectedPermissionOutcome": { + "description": "The user selected one of the provided options.", + "type": "object", + "properties": { + "optionId": { + "description": "The ID of the option the user selected.", + "allOf": [ + { + "$ref": "#/$defs/PermissionOptionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["optionId"] + }, + "CreateTerminalResponse": { + "description": "Response containing the ID of the created terminal.", + "type": "object", + "properties": { + "terminalId": { + "description": "The unique identifier for the created terminal.", + "allOf": [ + { + "$ref": "#/$defs/TerminalId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["terminalId"], + "x-side": "client", + "x-method": "terminal/create" + }, + "TerminalOutputResponse": { + "description": "Response containing the terminal output and exit status.", + "type": "object", + "properties": { + "output": { + "description": "The terminal output captured so far.", + "type": "string" + }, + "truncated": { + "description": "Whether the output was truncated due to byte limits.", + "type": "boolean" + }, + "exitStatus": { + "description": "Exit status if the command has completed.", + "anyOf": [ + { + "$ref": "#/$defs/TerminalExitStatus" + }, + { + "type": "null" + } + ], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["output", "truncated"], + "x-side": "client", + "x-method": "terminal/output" + }, + "TerminalExitStatus": { + "description": "Exit status of a terminal command.", + "type": "object", + "properties": { + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + } + }, + "ReleaseTerminalResponse": { + "description": "Response to terminal/release method", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/release" + }, + "WaitForTerminalExitResponse": { + "description": "Response containing the exit status of a terminal command.", + "type": "object", + "properties": { + "exitCode": { + "description": "The process exit code (may be null if terminated by signal).", + "type": ["integer", "null"], + "format": "uint32", + "minimum": 0, + "x-deserialize-default-on-error": true + }, + "signal": { + "description": "The signal that terminated the process (may be null if exited normally).", + "type": ["string", "null"], + "x-deserialize-default-on-error": true + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/wait_for_exit" + }, + "KillTerminalResponse": { + "description": "Response to `terminal/kill` method", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "x-side": "client", + "x-method": "terminal/kill" + }, + "CreateElicitationResponse": { + "description": "Response from the client to an elicitation request.", + "type": "object", + "properties": { + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nOptional. Omitted and `null` are equivalent and mean no metadata.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "anyOf": [ + { + "description": "The user accepted and provided content.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"], + "allOf": [ + { + "$ref": "#/$defs/ElicitationAcceptAction" + } + ] + }, + { + "description": "The user declined the elicitation.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "description": "The elicitation was cancelled.", + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + }, + { + "title": "other", + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.\n\nAgents that do not understand this action should preserve the raw\npayload when storing, replaying, proxying, or forwarding elicitation\nresponses. They MUST NOT treat it as a known elicitation action.", + "type": "object", + "properties": { + "action": { + "description": "Custom or future elicitation action.\n\nValues beginning with `_` are reserved for implementation-specific\nextensions. Unknown values that do not begin with `_` are reserved for\nfuture ACP variants.", + "type": "string" + } + }, + "required": ["action"], + "not": { + "anyOf": [ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "accept" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "decline" + } + }, + "required": ["action"] + }, + { + "type": "object", + "properties": { + "action": { + "type": "string", + "const": "cancel" + } + }, + "required": ["action"] + } + ] + }, + "additionalProperties": true + } + ], + "x-side": "client", + "x-method": "elicitation/create" + }, + "ElicitationContentValue": { + "description": "Allowed wire representations for [`ElicitationContentValue`].", + "anyOf": [ + { + "title": "String", + "description": "String value accepted in elicitation response content.", + "type": "string" + }, + { + "title": "Integer", + "description": "Integer value accepted in elicitation response content.", + "type": "integer", + "format": "int64" + }, + { + "title": "Number", + "description": "Number value accepted in elicitation response content.", + "type": "number", + "format": "double" + }, + { + "title": "Boolean", + "description": "Boolean value accepted in elicitation response content.", + "type": "boolean" + }, + { + "title": "StringArray", + "description": "String array value accepted in elicitation response content.", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "ElicitationAcceptAction": { + "description": "The user accepted the elicitation and provided content.", + "type": "object", + "properties": { + "content": { + "description": "The user-provided content, if any, as an object matching the requested schema.", + "type": ["object", "null"], + "additionalProperties": { + "$ref": "#/$defs/ElicitationContentValue" + } + } + } + }, + "ClientNotification": { + "description": "A JSON-RPC notification object.", + "type": "object", + "properties": { + "method": { + "description": "The notification method name.", + "type": "string" + }, + "params": { + "description": "Method-specific notification parameters.", + "anyOf": [ + { + "description": "All possible notifications that a client can send to an agent.\n\nThis enum is used internally for routing RPC notifications. You typically won't need\nto use this directly.\n\nNotifications do not expect a response.", + "anyOf": [ + { + "title": "CancelNotification", + "description": "Cancels ongoing operations for a session.\n\nThis is a notification sent by the client to cancel an ongoing prompt turn.\n\nUpon receiving this notification, the Agent SHOULD:\n- Stop all language model requests as soon as possible\n- Abort all tool call invocations in progress\n- Send any pending `session/update` notifications\n- Respond to the original `session/prompt` request with `StopReason::Cancelled`\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "allOf": [ + { + "$ref": "#/$defs/CancelNotification" + } + ] + }, + { + "title": "ExtNotification", + "description": "Handles extension notifications from the client.\n\nExtension notifications provide a way to send one-way messages for custom functionality\nwhile maintaining protocol compatibility.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "allOf": [ + { + "$ref": "#/$defs/ExtNotification" + } + ] + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": ["method"], + "x-docs-ignore": true + }, + "CancelNotification": { + "description": "Notification to cancel ongoing operations for a session.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/prompt-turn#cancellation)", + "type": "object", + "properties": { + "sessionId": { + "description": "The ID of the session to cancel operations for.", + "allOf": [ + { + "$ref": "#/$defs/SessionId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["sessionId"], + "x-side": "agent", + "x-method": "session/cancel" + }, + "CancelRequestNotification": { + "description": "Notification to cancel an ongoing request.\n\nSee protocol docs: [Cancellation](https://agentclientprotocol.com/protocol/cancellation)", + "type": "object", + "properties": { + "requestId": { + "description": "The ID of the request to cancel.", + "allOf": [ + { + "$ref": "#/$defs/RequestId" + } + ] + }, + "_meta": { + "description": "The _meta property is reserved by ACP to allow clients and agents to attach additional\nmetadata to their interactions. Implementations MUST NOT make assumptions about values at\nthese keys.\n\nSee protocol docs: [Extensibility](https://agentclientprotocol.com/protocol/extensibility)", + "type": ["object", "null"], + "x-deserialize-default-on-error": true, + "additionalProperties": true + } + }, + "required": ["requestId"], + "x-side": "protocol", + "x-method": "$/cancel_request" + } + } +} diff --git a/acp-http-core/pom.xml b/acp-http-core/pom.xml new file mode 100644 index 00000000..68688a39 --- /dev/null +++ b/acp-http-core/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-http-core + jar + + ACP HTTP Core + The framework-neutral ACP Streamable HTTP and WebSocket endpoint: routing, sessions, SSE mailboxes, the status and close-code table, the Origin check and shutdown, behind the host contract every server adapter implements + + + + 0.88 + 0.79 + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-core + + + + com.agentclientprotocol + acp-json-jackson2 + + + + org.junit.jupiter + junit-jupiter + test + + + com.tngtech.archunit + archunit-junit5 + test + + + org.assertj + assertj-core + test + + + ch.qos.logback + logback-classic + test + + + org.jetbrains.lincheck + lincheck + test + + + io.projectreactor + reactor-test + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + @{argLine} -XX:+IgnoreUnrecognizedVMOptions -XX:+EnableDynamicAgentLoading + + + + + + diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicy.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicy.java new file mode 100644 index 00000000..e1bcdcab --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicy.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; + +/** + * The endpoint's {@code Origin} rule, one for every host: a request without the header (a + * non-browser client) and one from a loopback origin is accepted; any other origin only when + * the application lists it. A browser sends {@code Origin} on every cross-origin request and + * on every WebSocket handshake, so this stops a web page the user visits from driving a local + * agent, directly or through DNS rebinding. + * + * @author Mark Pollack + */ +final class OriginPolicy { + + /** The allowed-origins entry that accepts any origin. */ + static final String ANY = "*"; + + private static final Set LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "[::1]"); + + private OriginPolicy() { + } + + /** The listed origins as they are compared: lower case, without a trailing slash. */ + static Set normalize(Set origins) { + return origins.stream().map(OriginPolicy::normalize).collect(Collectors.toUnmodifiableSet()); + } + + private static String normalize(String origin) { + String trimmed = origin.trim().toLowerCase(Locale.ROOT); + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + static boolean isAllowed(@Nullable String origin, Set allowedOrigins) { + if (origin == null) { + return true; + } + String normalized = normalize(origin); + return isLoopback(normalized) || allowedOrigins.contains(ANY) || allowedOrigins.contains(normalized); + } + + private static boolean isLoopback(String origin) { + try { + URI uri = new URI(origin); + String scheme = uri.getScheme(); + String host = uri.getHost(); + return ("http".equals(scheme) || "https".equals(scheme)) && host != null + && LOOPBACK_HOSTS.contains(host) && (uri.getRawPath() == null || uri.getRawPath().isEmpty()) + && uri.getRawUserInfo() == null; + } + catch (URISyntaxException e) { + return false; + } + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java new file mode 100644 index 00000000..6bd8cbb2 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java @@ -0,0 +1,404 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.time.Duration; +import java.util.Collection; +import java.util.Set; +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** + * The limits and timings of an ACP Streamable HTTP and WebSocket endpoint, for + * every host of the endpoint alike: the SDK's listener ({@code StreamableHttpAcpAgentTransport}, + * module {@code acp-streamable-http-jetty}), its servlet ({@code StreamableHttpAcpServlet}, + * module {@code acp-http-servlet}) and the framework integrations, all of which serve + * {@link com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint}. Every + * buffer the endpoint keeps for a client is bounded by one of these, so a slow or hostile + * client cannot make the agent's memory grow without bound. Pass one to the listener's or the + * servlet's constructor when a default does not fit; start from {@link #builder()}, or use + * {@link #defaults()}: + * + *
    {@code
    + * var options = StreamableHttpAcpAgentTransportOptions.builder()
    + *     .maxPostBodyBytes(64L * 1024 * 1024)
    + *     .shutdownTimeout(Duration.ofSeconds(10))
    + *     .build();
    + * }
    + * + * @param maxPostBodyBytes the largest message accepted from a client: a larger POST body is + * answered 413, and a larger WebSocket text message closes the connection (1009, message too + * big); default 16 MB + * @param mailboxCapacity the events one SSE stream keeps unsent, as while no client is reading + * it, to send when the client reconnects; one more closes the connection; default 1024 + * @param maxPendingSseEvents the events queued for a client that is reading an SSE stream; + * one more detaches that client, keeping the events for its next GET; default 1024 + * @param maxWebSocketPendingFrames the frames queued for one WebSocket connection; one more + * closes the connection; default 1024 + * @param maxProvisionalSessions the session streams a connection may open before the agent + * knows the session, as a client does before {@code session/load}; a further one is refused; + * default 64 + * @param keepAliveInterval the interval between keep-alive comments on open SSE streams, which + * stop proxies from cutting idle streams; {@link Duration#ZERO} turns them off; default 15 + * seconds + * @param maxConcurrentStreamsPerConnection the HTTP/2 streams one client connection may hold + * open at once, each open SSE stream holding one; applied by the listener only, since a + * servlet container configures its own HTTP/2; default 1024 + * @param shutdownTimeout how long closing the endpoint + * ({@code closeGracefully()} of the endpoint, the servlet and the listener, and the servlet's + * {@code destroy()}) waits for its connections' + * agents to close before it closes the rest at once; closing never waits for a client, since + * the SSE responses are completed, not drained; default 5 seconds + * @param executor the application's executor that the listener's Jetty server runs its tasks + * on, or null (the default) for virtual threads of the listener's own on JDK 21 and later, and + * Jetty's own pool of platform threads ({@code qtp*}) before. On JDK 21 and later the listener + * serves on Jetty's {@code VirtualThreadPool} either way, so it creates no pool, and times on + * a virtual thread; Jetty still parks one platform thread + * ({@code jetty-virtual-thread-pool-keepalive}) while the server runs. An executor needs JDK 21 + * or later, and must be meant for virtual threads, such as + * {@code Executors.newVirtualThreadPerTaskExecutor()} or a framework's virtual-thread + * executor: Jetty's selectors and acceptors hold their tasks for as long as the server runs. + * The application owns it; the listener does not shut it down. Applied by the listener only; + * the servlet runs on its container's threads + * @param virtualThreads whether the listener serves on virtual threads where the JDK has them + * (21 and later); false keeps Jetty's pool of platform threads on every JDK, for an + * application that has not opted into virtual threads, and then no executor may be set; + * default true. Applied by the listener only + * @param host the address the listener binds, or null (the default) for the loopback interface + * only: {@code 127.0.0.1}, and {@code ::1} too where the machine has IPv6. A host name or + * address binds that one address; {@code 0.0.0.0} (or {@code ::}) binds every interface and + * so exposes the agent to the network. The endpoint has no authentication of its own, so + * opt in to remote exposure only behind a proxy or firewall that controls who may connect. + * Applied by the listener only; a servlet container binds where it is configured to + * @param allowedOrigins the browser origins, besides the loopback ones, whose requests the + * endpoint accepts, such as {@code https://app.example.com}; {@code *} accepts any origin. + * A request without an {@code Origin} header (any non-browser client) and one from + * {@code http(s)://localhost}, {@code 127.0.0.1} or {@code [::1]} on any port is always + * accepted; any other origin is answered 403, over HTTP and on the WebSocket handshake, so a + * web page the user visits cannot drive a local agent (DNS rebinding, cross-site requests). + * Default empty + * @param webSocketIdleTimeout how long a WebSocket connection may pass no frame in either + * direction before the endpoint closes it (1001, going away), on every host; default 30 + * minutes + * @param initializeTimeout how long a connection has to complete {@code initialize}: a + * WebSocket that has not sent it by then is closed (1008, policy violation), and a POST + * {@code initialize} whose agent has not answered by then is answered 500; default 30 seconds + * @author Mark Pollack + */ +public record StreamableHttpAcpAgentTransportOptions(long maxPostBodyBytes, int mailboxCapacity, + int maxPendingSseEvents, int maxWebSocketPendingFrames, int maxProvisionalSessions, + Duration keepAliveInterval, int maxConcurrentStreamsPerConnection, Duration shutdownTimeout, + @Nullable Executor executor, boolean virtualThreads, @Nullable String host, Set allowedOrigins, + Duration webSocketIdleTimeout, Duration initializeTimeout) { + + private static final long DEFAULT_MAX_POST_BODY_BYTES = 16L * 1024 * 1024; + + private static final int DEFAULT_MAILBOX_CAPACITY = 1024; + + private static final int DEFAULT_MAX_PENDING_SSE_EVENTS = 1024; + + private static final int DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES = 1024; + + private static final int DEFAULT_MAX_PROVISIONAL_SESSIONS = 64; + + private static final Duration DEFAULT_KEEP_ALIVE_INTERVAL = Duration.ofSeconds(15); + + private static final int DEFAULT_MAX_CONCURRENT_STREAMS_PER_CONNECTION = 1024; + + private static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(5); + + private static final Duration DEFAULT_WEBSOCKET_IDLE_TIMEOUT = Duration.ofMinutes(30); + + private static final Duration DEFAULT_INITIALIZE_TIMEOUT = Duration.ofSeconds(30); + + /** + * Creates options with these values; prefer {@link #builder()}, which starts from the + * defaults. + * @param maxPostBodyBytes the largest message accepted from a client, in bytes + * @param mailboxCapacity the events kept for an SSE stream no client is reading + * @param maxPendingSseEvents the events queued for a client reading an SSE stream + * @param maxWebSocketPendingFrames the frames queued for one WebSocket connection + * @param maxProvisionalSessions the session streams opened before the session is known + * @param keepAliveInterval the interval between SSE keep-alive comments + * @param maxConcurrentStreamsPerConnection the HTTP/2 streams one connection may hold + * @param shutdownTimeout how long closing waits for the connections' agents + * @param executor the executor the listener's Jetty server runs on, or null for Jetty's + * own pool + * @param virtualThreads whether the listener serves on virtual threads where the JDK has them + * @param host the address the listener binds, or null for loopback only + * @param allowedOrigins the browser origins accepted besides the loopback ones + * @param webSocketIdleTimeout how long a WebSocket connection may stay idle + * @param initializeTimeout how long a connection has to complete {@code initialize} + * @throws IllegalArgumentException if a count or a timeout other than + * {@code keepAliveInterval} is not positive, + * {@code keepAliveInterval} is negative or null, an executor is set with + * {@code virtualThreads} false, {@code host} is blank, or {@code allowedOrigins} is null + */ + public StreamableHttpAcpAgentTransportOptions { + Assert.isTrue(maxPostBodyBytes > 0, "maxPostBodyBytes must be positive"); + Assert.isTrue(mailboxCapacity > 0, "mailboxCapacity must be positive"); + Assert.isTrue(maxPendingSseEvents > 0, "maxPendingSseEvents must be positive"); + Assert.isTrue(maxWebSocketPendingFrames > 0, "maxWebSocketPendingFrames must be positive"); + Assert.isTrue(maxProvisionalSessions > 0, "maxProvisionalSessions must be positive"); + Assert.notNull(keepAliveInterval, "keepAliveInterval must not be null"); + Assert.isTrue(!keepAliveInterval.isNegative(), "keepAliveInterval must not be negative"); + Assert.isTrue(maxConcurrentStreamsPerConnection > 0, "maxConcurrentStreamsPerConnection must be positive"); + Assert.notNull(shutdownTimeout, "shutdownTimeout must not be null"); + Assert.isTrue(!shutdownTimeout.isNegative() && !shutdownTimeout.isZero(), "shutdownTimeout must be positive"); + Assert.isTrue(executor == null || virtualThreads, "An executor needs virtualThreads"); + Assert.isTrue(host == null || !host.isBlank(), "host must not be blank; null binds loopback"); + Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); + allowedOrigins = OriginPolicy.normalize(allowedOrigins); + Assert.isTrue(isPositive(webSocketIdleTimeout), "webSocketIdleTimeout must be positive"); + Assert.isTrue(isPositive(initializeTimeout), "initializeTimeout must be positive"); + } + + private static boolean isPositive(@Nullable Duration duration) { + return duration != null && !duration.isNegative() && !duration.isZero(); + } + + /** + * Returns whether a request with this {@code Origin} header may use the endpoint: no header, + * a loopback origin ({@code http} or {@code https} on {@code localhost}, {@code 127.0.0.1} or + * {@code [::1]}, any port), or one of {@link #allowedOrigins()}. Every host of the endpoint + * applies it to every request and to the WebSocket handshake, and answers a refused one 403. + * @param origin the request's {@code Origin} header, or null when it has none + * @return whether the request is accepted + */ + public boolean isOriginAllowed(@Nullable String origin) { + return OriginPolicy.isAllowed(origin, allowedOrigins); + } + + /** + * Returns the default limits and timings, as listed on the record's components. + * @return the default options + */ + public static StreamableHttpAcpAgentTransportOptions defaults() { + return builder().build(); + } + + /** + * Returns a builder that starts from the defaults. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds {@link StreamableHttpAcpAgentTransportOptions}, starting from the defaults; set + * only the values that should differ. Get one from + * {@link StreamableHttpAcpAgentTransportOptions#builder()}. + */ + public static final class Builder { + + private long maxPostBodyBytes = DEFAULT_MAX_POST_BODY_BYTES; + + private int mailboxCapacity = DEFAULT_MAILBOX_CAPACITY; + + private int maxPendingSseEvents = DEFAULT_MAX_PENDING_SSE_EVENTS; + + private int maxWebSocketPendingFrames = DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES; + + private int maxProvisionalSessions = DEFAULT_MAX_PROVISIONAL_SESSIONS; + + private @Nullable Executor executor; + + private boolean virtualThreads = true; + + private Duration keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL; + + private int maxConcurrentStreamsPerConnection = DEFAULT_MAX_CONCURRENT_STREAMS_PER_CONNECTION; + + private Duration shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT; + + private @Nullable String host; + + private Set allowedOrigins = Set.of(); + + private Duration webSocketIdleTimeout = DEFAULT_WEBSOCKET_IDLE_TIMEOUT; + + private Duration initializeTimeout = DEFAULT_INITIALIZE_TIMEOUT; + + private Builder() { + } + + /** + * Sets the largest message accepted from a client; default 16 MB. + * @param maxPostBodyBytes the limit in bytes; positive + * @return this builder + */ + public Builder maxPostBodyBytes(long maxPostBodyBytes) { + this.maxPostBodyBytes = maxPostBodyBytes; + return this; + } + + /** + * Sets the events kept for an SSE stream that no client is reading; default 1024. + * @param mailboxCapacity the event count; positive + * @return this builder + */ + public Builder mailboxCapacity(int mailboxCapacity) { + this.mailboxCapacity = mailboxCapacity; + return this; + } + + /** + * Sets the events queued for a client that is reading an SSE stream; default 1024. + * @param maxPendingSseEvents the event count; positive + * @return this builder + */ + public Builder maxPendingSseEvents(int maxPendingSseEvents) { + this.maxPendingSseEvents = maxPendingSseEvents; + return this; + } + + /** + * Sets the frames queued for one WebSocket connection; default 1024. + * @param maxWebSocketPendingFrames the frame count; positive + * @return this builder + */ + public Builder maxWebSocketPendingFrames(int maxWebSocketPendingFrames) { + this.maxWebSocketPendingFrames = maxWebSocketPendingFrames; + return this; + } + + /** + * Sets the session streams a connection may open before the agent knows the session; + * default 64. + * @param maxProvisionalSessions the stream count; positive + * @return this builder + */ + public Builder maxProvisionalSessions(int maxProvisionalSessions) { + this.maxProvisionalSessions = maxProvisionalSessions; + return this; + } + + /** + * Sets the interval between keep-alive comments on open SSE streams; default 15 + * seconds. + * @param keepAliveInterval the interval, or {@link Duration#ZERO} for none; not + * negative + * @return this builder + */ + public Builder keepAliveInterval(Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * Sets the HTTP/2 streams one client connection may hold open at once; default 1024. + * Only the listener applies it. + * @param maxConcurrentStreamsPerConnection the stream count; positive + * @return this builder + */ + public Builder maxConcurrentStreamsPerConnection(int maxConcurrentStreamsPerConnection) { + this.maxConcurrentStreamsPerConnection = maxConcurrentStreamsPerConnection; + return this; + } + + /** + * Sets how long closing the endpoint waits for its connections' agents before it + * closes the rest at once; default 5 seconds. + * @param shutdownTimeout the timeout; positive + * @return this builder + */ + public Builder shutdownTimeout(Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + return this; + } + + /** + * Sets the application's executor that the listener's Jetty server runs on, in place + * of Jetty's own thread pool; the listener does not shut it down. It needs JDK 21 or + * later; see {@link StreamableHttpAcpAgentTransportOptions#executor()}. The servlet + * ignores it. + * @param executor the executor, such as a virtual-thread executor + * @return this builder + */ + public Builder executor(Executor executor) { + Assert.notNull(executor, "executor must not be null"); + this.executor = executor; + return this; + } + + /** + * Sets whether the listener serves on virtual threads where the JDK has them; default + * true. False keeps Jetty's pool of platform threads on every JDK. + * @param virtualThreads false for platform threads + * @return this builder + */ + public Builder virtualThreads(boolean virtualThreads) { + this.virtualThreads = virtualThreads; + return this; + } + + /** + * Sets the address the listener binds; by default (null) the loopback interface only, + * {@code 127.0.0.1} and {@code ::1}. {@code 0.0.0.0} binds every interface, which exposes + * the agent to anyone who can reach the machine: the endpoint has no authentication of + * its own. The servlet ignores it. + * @param host a host name or address, or null for loopback only + * @return this builder + */ + public Builder host(@Nullable String host) { + this.host = host; + return this; + } + + /** + * Sets the browser origins accepted besides the loopback ones, such as + * {@code https://app.example.com} (scheme, host and port, as browsers send them); + * {@code *} accepts any origin. Default none. + * @param allowedOrigins the origins + * @return this builder + */ + public Builder allowedOrigins(Collection allowedOrigins) { + Assert.notNull(allowedOrigins, "allowedOrigins must not be null"); + this.allowedOrigins = Set.copyOf(allowedOrigins); + return this; + } + + /** + * Sets how long a WebSocket connection may pass no frame in either direction before the + * endpoint closes it with 1001 (going away); default 30 minutes. Every host applies it. + * @param webSocketIdleTimeout the timeout; positive + * @return this builder + */ + public Builder webSocketIdleTimeout(Duration webSocketIdleTimeout) { + this.webSocketIdleTimeout = webSocketIdleTimeout; + return this; + } + + /** + * Sets how long a connection has to complete {@code initialize}; default 30 seconds. A + * WebSocket that has not sent {@code initialize} by then is closed with 1008 (policy + * violation); a POST {@code initialize} the agent has not answered by then is answered + * 500. + * @param initializeTimeout the timeout; positive + * @return this builder + */ + public Builder initializeTimeout(Duration initializeTimeout) { + this.initializeTimeout = initializeTimeout; + return this; + } + + /** + * Returns the options. + * @return the options + * @throws IllegalArgumentException if a value is out of range + */ + public StreamableHttpAcpAgentTransportOptions build() { + return new StreamableHttpAcpAgentTransportOptions(maxPostBodyBytes, mailboxCapacity, maxPendingSseEvents, + maxWebSocketPendingFrames, maxProvisionalSessions, keepAliveInterval, + maxConcurrentStreamsPerConnection, shutdownTimeout, executor, virtualThreads, host, allowedOrigins, + webSocketIdleTimeout, initializeTimeout); + } + + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java new file mode 100644 index 00000000..8b97fc68 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The options of the ACP Streamable HTTP and WebSocket endpoint (acp-http-core); this module's + * part of the package shared with acp-core, acp-http-servlet and acp-streamable-http-jetty. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.agent.transport; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpoint.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpoint.java new file mode 100644 index 00000000..925d1589 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpoint.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import reactor.core.publisher.Mono; + +/** + * The ACP Streamable HTTP and WebSocket endpoint, independent of any server: one per mounted + * path, holding every client connection on it and creating one agent per connection with an + * {@link AcpAgentFactory}. A host (a servlet, a framework's router) mounts it and adapts I/O; + * every protocol rule is here. + * + *

    A host: + *

      + *
    • hands every request on the path to {@link #handle}, or a WebSocket upgrade request + * ({@link AcpHttpExchange#isWebSocketUpgrade()}) to {@link #webSocketHandshake}, and writes the + * answer as it is;
    • + *
    • calls {@link #start()} when its server starts serving the path, and + * {@link #closeGracefully()} before its server's own graceful shutdown, since open SSE + * streams and WebSockets never finish by themselves;
    • + *
    • never blocks an I/O thread on the endpoint: {@link #handle} returns a {@link Mono} and + * the agent's work runs on the SDK's schedulers.
    • + *
    + * + *

    The endpoint answers 403 to a request whose {@code Origin} is neither a loopback origin nor + * listed in {@link StreamableHttpAcpAgentTransportOptions#allowedOrigins()}, over HTTP and on the + * handshake. It has no authentication: the host's framework security protects the path, and + * hands the principal over in {@link AcpHttpExchange#principal()}. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public interface AcpHttpEndpoint { + + /** + * Creates an endpoint. + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @param options the endpoint's limits, timings and allowed origins + * @return the endpoint, not started + * @throws IllegalArgumentException if an argument is null + */ + static AcpHttpEndpoint create(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, + StreamableHttpAcpAgentTransportOptions options) { + return new DefaultAcpHttpEndpoint(jsonMapper, agentFactory, options); + } + + /** + * Returns the options the endpoint was created with; a host applies the ones that concern + * its server, such as the inbound size limit. + * @return the options + */ + StreamableHttpAcpAgentTransportOptions options(); + + /** + * Answers one HTTP request: a POST carrying a JSON-RPC message, a GET opening an SSE stream, + * or a DELETE closing a connection. The returned Mono never errors: every refusal, and any + * unexpected failure (500), is a reply. + * @param exchange the request + * @return the reply to write + */ + Mono handle(AcpHttpExchange exchange); + + /** + * Decides a WebSocket upgrade request: refused (with the reply to write) or accepted. + * @param handshake the upgrade request + * @return the decision + */ + AcpWsHandshake webSocketHandshake(AcpHttpExchange handshake); + + /** + * Starts the SSE keep-alive. Idempotent; the endpoint serves requests without it too. + */ + void start(); + + /** + * Closes every connection: new connections are refused (503), an {@code initialize} still in + * flight is answered 503, open SSE streams get a closing comment and complete, WebSockets are + * closed with 1001 (going away), and each connection's agent is closed, cancelling in-flight + * prompts. It waits for each WebSocket's close to go out ({@link AcpWsOutbound#close}), so + * that a host stopped next does not drop the connection before the client has its close + * code, and for nothing else from a client. A connection whose agent or socket has not + * closed within the + * {@linkplain StreamableHttpAcpAgentTransportOptions#shutdownTimeout() shutdown timeout} is + * closed at once. Only the first call has an effect. + * @return a Mono that completes when every connection has closed + */ + Mono closeGracefully(); + + /** + * Returns the number of open connections, HTTP and WebSocket, not counting those whose + * {@code initialize} is still being answered. + * @return the connection count + */ + int activeConnectionCount(); + + /** + * Sets the handler for the transport errors of every connection, including those opened + * before the call. The default logs them. + * @param handler receives the connections' transport errors + */ + void setExceptionHandler(Consumer handler); + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpExchange.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpExchange.java new file mode 100644 index 00000000..be761b67 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpExchange.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.security.Principal; +import java.util.Locale; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * A host's view of one HTTP request to the ACP endpoint, or of a WebSocket handshake: the + * method, the headers, the body and the authenticated principal, as the host's framework + * received them. The host creates one per request and hands it to + * {@link AcpHttpEndpoint#handle} or {@link AcpHttpEndpoint#webSocketHandshake}; the endpoint + * reads it on the calling thread, except for {@link #body}, which it subscribes to once. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public interface AcpHttpExchange { + + /** + * Returns the request method, such as {@code POST}. + * @return the method, in upper case + */ + String method(); + + /** + * Returns the first value of a request header, compared without regard to case. + * @param name the header name, such as {@code Acp-Connection-Id} + * @return the value, or null when the request has no such header + */ + @Nullable String header(String name); + + /** + * Reads the request body, reading at most {@code maxBytes + 1} bytes so that the endpoint can + * tell an oversized body from one at the limit without the host buffering more. A servlet host + * reads it from the request's input stream; a reactive host aggregates it without blocking. + * @param maxBytes the largest body the endpoint accepts + * @return the body, or its first {@code maxBytes + 1} bytes; empty bytes for no body + */ + Mono body(long maxBytes); + + /** + * Returns the principal the host's security authenticated for this request, such as the + * servlet's {@code getUserPrincipal()}. Framework security runs in the host, before the + * endpoint; the endpoint has no authentication of its own. + * @return the principal, or null for an unauthenticated request + */ + @Nullable Principal principal(); + + /** + * Returns whether this request asks to upgrade to WebSocket ({@code Upgrade: websocket}). A + * host that serves WebSocket gives such a request to + * {@link AcpHttpEndpoint#webSocketHandshake} instead of {@link AcpHttpEndpoint#handle}. + * @return true for a WebSocket upgrade request + */ + default boolean isWebSocketUpgrade() { + String upgrade = header("Upgrade"); + return upgrade != null && upgrade.trim().toLowerCase(Locale.ROOT).equals("websocket"); + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpReply.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpReply.java new file mode 100644 index 00000000..588a2596 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpHttpReply.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import reactor.core.publisher.Flux; + +/** + * What the endpoint answers one HTTP request with; the host writes it as it is. The status, + * the headers and the body are the endpoint's: a host adds none of its own protocol headers and + * changes no status. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public sealed interface AcpHttpReply permits AcpHttpReply.Body, AcpHttpReply.Empty, AcpHttpReply.EventStream { + + /** The content type of an SSE response. */ + String EVENT_STREAM = "text/event-stream"; + + /** + * Returns the response status. + * @return the status code + */ + int status(); + + /** + * Returns the response headers the endpoint sets, besides the content type. + * @return the headers, by name + */ + Map headers(); + + /** + * A response with a body: a JSON-RPC answer or a plain-text refusal. + * @param status the status code + * @param headers the response headers besides the content type + * @param contentType the body's content type, such as {@code application/json} + * @param body the body, written as UTF-8 + */ + record Body(int status, Map headers, String contentType, String body) implements AcpHttpReply { + + /** + * Creates a reply, copying the headers. + */ + public Body { + headers = Map.copyOf(headers); + } + + /** + * Returns the body as UTF-8 bytes. + * @return the bytes + */ + public byte[] bodyBytes() { + return body.getBytes(StandardCharsets.UTF_8); + } + + } + + /** + * A response with a status and no body, such as 202 for an accepted message. + * @param status the status code + * @param headers the response headers + */ + record Empty(int status, Map headers) implements AcpHttpReply { + + /** + * Creates a reply, copying the headers. + */ + public Empty { + headers = Map.copyOf(headers); + } + + } + + /** + * A 200 response of content type {@value #EVENT_STREAM}: an SSE stream that stays open until + * the endpoint completes {@code frames}. + * + *

    The host sets the status, the content type and {@code headers}, then subscribes to + * {@code frames} and writes each frame's {@link SseFrame#encode() bytes}, flushing each one. + * It requests a frame only when it can write it, and the next only once the previous write + * has completed; a reactive host may let its framework's backpressure decide instead. When + * the client goes away or a write fails, it cancels the subscription; when {@code frames} + * completes, it completes the response. It must not let its container time the response out + * (for a servlet, {@code AsyncContext.setTimeout(0)}): the endpoint's keep-alive comments + * keep proxies from cutting the stream. + * @param headers the response headers besides the content type, including + * {@code Cache-Control: no-cache} and {@code X-Accel-Buffering: no} + * @param frames the frames to write, the first an SSE comment that commits the response + */ + record EventStream(Map headers, Flux frames) implements AcpHttpReply { + + /** + * Creates a reply, copying the headers. + */ + public EventStream { + headers = Map.copyOf(headers); + } + + @Override + public int status() { + return 200; + } + + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandler.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandler.java new file mode 100644 index 00000000..de3ee4c8 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandler.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import org.jspecify.annotations.Nullable; + +/** + * The endpoint's side of one accepted WebSocket: the host passes it every event of the socket, + * from its I/O or container thread. No method blocks; the agent's work runs on the SDK's own + * schedulers. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public interface AcpWsHandler { + + /** + * A text frame arrived, whole. The endpoint checks its size against the inbound limit + * too, so a host whose container already enforces that limit loses nothing. + * @param text the frame + */ + void onText(String text); + + /** + * The socket closed, by the client, by the container or after + * {@link AcpWsOutbound#close}. + * @param code the close code, or 1006 when the connection dropped without one + * @param reason the close reason, if any + */ + void onClose(int code, @Nullable String reason); + + /** + * The socket failed, such as on a corrupt frame. The endpoint closes the connection; the + * host still reports the close that follows through {@link #onClose}. + * @param error the error + */ + void onError(Throwable error); + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandshake.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandshake.java new file mode 100644 index 00000000..8bc30495 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsHandshake.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.time.Duration; +import java.util.Map; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; + +/** + * The endpoint's decision on a WebSocket upgrade request, from + * {@link AcpHttpEndpoint#webSocketHandshake}: either refuse it with an HTTP reply, or accept it. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public sealed interface AcpWsHandshake permits AcpWsHandshake.Refused, AcpWsHandshake.Accepted { + + /** + * The upgrade is refused: the host writes {@code reply} instead of upgrading, as for 403 + * (a foreign {@code Origin}) or 503 (the endpoint is shutting down). + * @param reply the HTTP reply to write + */ + record Refused(AcpHttpReply reply) implements AcpWsHandshake { + } + + /** + * The upgrade is accepted. The host completes it with the response headers of + * {@link #headers()}, applies the limits below to the socket, and once the socket is open + * calls {@link #open} exactly once. If the upgrade fails in the host after this decision, + * it calls nothing: no connection exists until {@link #open}. + */ + non-sealed interface Accepted extends AcpWsHandshake { + + /** + * Returns the headers of the upgrade response, such as {@code Acp-Connection-Id}. + * @return the headers, by name + */ + Map headers(); + + /** + * Returns the largest text message the socket accepts, for the container's own limit; + * a larger one closes the socket with 1009. + * @return the limit in bytes + */ + long maxTextMessageBytes(); + + /** + * Returns the idle timeout for a container that has one of its own: five seconds past + * the endpoint's {@code webSocketIdleTimeout}. The endpoint closes an idle connection + * itself (1001) on every host; the container's timeout only backs it up. + * @return the container's idle timeout + */ + Duration idleTimeout(); + + /** + * Starts the connection on the open socket: creates its agent and returns the handler + * the host gives every socket event to. Frames may arrive at once; the endpoint keeps + * them until the agent has started. + * @param outbound the host's side of the socket + * @return the handler for the socket's events + */ + AcpWsHandler open(AcpWsOutbound outbound); + + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsOutbound.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsOutbound.java new file mode 100644 index 00000000..46806728 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/AcpWsOutbound.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; + +/** + * The host's side of one accepted WebSocket: the endpoint sends frames and closes the socket + * through it. Both methods are non-blocking. + * + *

    The endpoint sends one frame at a time: it calls {@link #sendText} again only once the + * stage of the previous call has completed, so a host needs no queue of its own and a container + * that allows one outstanding write (Jakarta WebSocket's {@code RemoteEndpoint.Async}, Tomcat) + * is never given a second one. The bound on queued frames, and the close that follows a failed + * or overflowing send, are the endpoint's. + * + * @author Mark Pollack + */ +@UnstableAcpApi +public interface AcpWsOutbound { + + /** + * Sends one text frame. + * @param text the frame, one JSON-RPC message + * @return a stage that completes when the container has written the frame, or completes + * exceptionally when the write failed + */ + CompletionStage sendText(String text); + + /** + * Closes the socket with a close frame. The endpoint chooses the code: 1000 normal, 1001 + * going away (shutdown, idle timeout), 1002 protocol error, 1008 policy violation (no + * {@code initialize} in time), 1009 message too big, 1011 server error. + *

    A graceful shutdown waits for the returned stage, bounded by the shutdown timeout, + * before it reports the endpoint closed: a container may send the close frame + * asynchronously, and one stopped before the frame has gone out drops the connection + * without it (the client sees 1006). + * @param code the close code + * @param reason the close reason, short + * @return a stage that completes once the close frame has been sent, or the socket has + * closed; it may complete exceptionally when the close failed + */ + CompletionStage close(int code, String reason); + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/DefaultAcpHttpEndpoint.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/DefaultAcpHttpEndpoint.java new file mode 100644 index 00000000..1ba4c75c --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/DefaultAcpHttpEndpoint.java @@ -0,0 +1,545 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.HEADER_CONNECTION_ID; +import static com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.HEADER_ORIGIN; +import static com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.HEADER_SESSION_ID; + +/** + * The endpoint: the status table of the Streamable HTTP profile, the WebSocket handshake, the + * connections on both, keep-alive and shutdown. + * + *

    Status table. POST: a body that is not {@code application/json} 415, one over the size + * limit 413, a JSON-RPC batch 501, other invalid input 400; {@code initialize} without + * Acp-Connection-Id opens a connection (200 with the agent's answer and the new id, 400 with the + * header, 503 while closing, 500 if the agent fails or does not answer within the + * initialize timeout); any other message + * names its connection (400 without the header, 404 for an unknown connection or session) and is + * answered 202. GET: 406 unless the client accepts {@code text/event-stream}, else the SSE + * stream of the connection or of a session. DELETE: 202, or 400/404 as for POST. Any method: + * 403 for a foreign {@code Origin}; 405 for another method. + * + * @author Kaiser Dandangi + * @author Mark Pollack + */ +final class DefaultAcpHttpEndpoint implements AcpHttpEndpoint { + + private static final Logger logger = LoggerFactory.getLogger(DefaultAcpHttpEndpoint.class); + + private static final String CONTENT_TYPE_JSON = "application/json"; + + private static final String CONTENT_TYPE_TEXT = "text/plain"; + + /** + * How much longer than the endpoint a container waits before it closes an idle WebSocket + * itself: the endpoint's 1001 close comes first, and the container's only backs it up. Both + * at once can drop the connection without a close frame (1006). + */ + private static final Duration CONTAINER_IDLE_GRACE = Duration.ofSeconds(5); + + private final AcpJsonMapper jsonMapper; + + private final AcpAgentFactory agentFactory; + + private final StreamableHttpAcpAgentTransportOptions options; + + private final StreamableHttpRouting routing; + + private final ConcurrentMap connections = new ConcurrentHashMap<>(); + + private final ConcurrentMap webSocketConnections = new ConcurrentHashMap<>(); + + /** + * Connections whose {@code initialize} is still being answered, each with the action that + * refuses it; they join {@link #connections} once answered. + */ + private final ConcurrentMap initializing = new ConcurrentHashMap<>(); + + private final AtomicBoolean closing = new AtomicBoolean(false); + + private volatile Consumer exceptionHandler = error -> logger + .error("Streamable HTTP ACP connection error", error); + + private volatile @Nullable Disposable keepAliveTask; + + DefaultAcpHttpEndpoint(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, + StreamableHttpAcpAgentTransportOptions options) { + Assert.notNull(jsonMapper, "The JsonMapper can not be null"); + Assert.notNull(agentFactory, "The agentFactory can not be null"); + Assert.notNull(options, "The options can not be null"); + this.jsonMapper = jsonMapper; + this.agentFactory = agentFactory; + this.options = options; + this.routing = new StreamableHttpRouting(jsonMapper); + } + + @Override + public StreamableHttpAcpAgentTransportOptions options() { + return options; + } + + @Override + public synchronized void start() { + Duration interval = options.keepAliveInterval(); + if (interval.isZero() || keepAliveTask != null || closing.get()) { + return; + } + // A comment every interval keeps proxies from cutting idle streams and surfaces dead + // subscribers (the write fails) without waiting for the next real event. Timed on the + // SDK's shared timer and handed over off it, so a slow host cannot delay a timeout. + this.keepAliveTask = Flux.interval(interval, interval, AcpSchedulers.timeouts()) + .onBackpressureDrop() + .publishOn(AcpSchedulers.timeoutDelivery(), 1) + .subscribe(tick -> connections.values().forEach(connection -> { + try { + connection.keepAlive(); + } + catch (RuntimeException e) { + // One broken connection must not stop keep-alives for every other one. + logger.debug("Keep-alive failed for connection {}: {}", connection.id(), e.getMessage()); + } + })); + } + + @Override + public void setExceptionHandler(Consumer handler) { + Assert.notNull(handler, "The handler can not be null"); + this.exceptionHandler = handler; + } + + private void reportException(Throwable error) { + this.exceptionHandler.accept(error); + } + + @Override + public int activeConnectionCount() { + return connections.size() + webSocketConnections.size(); + } + + // HTTP + + @Override + public Mono handle(AcpHttpExchange exchange) { + return Mono.defer(() -> { + if (!options.isOriginAllowed(exchange.header(HEADER_ORIGIN))) { + return Mono.just(text(403, "Origin not allowed")); + } + try { + return switch (exchange.method().toUpperCase(Locale.ROOT)) { + case "POST" -> post(exchange); + case "GET" -> Mono.just(get(exchange)); + case "DELETE" -> Mono.just(delete(exchange)); + default -> Mono.just(new AcpHttpReply.Empty(405, Map.of("Allow", "GET, POST, DELETE"))); + }; + } + catch (Rejection rejection) { + return Mono.just(rejection.reply()); + } + }).onErrorResume(error -> { + reportException(error); + return Mono.just(text(500, "internal error")); + }); + } + + private Mono post(AcpHttpExchange exchange) throws Rejection { + if (!hasContentType(exchange, CONTENT_TYPE_JSON)) { + throw new Rejection(text(415, "Content-Type must be application/json")); + } + long maxBytes = options.maxPostBodyBytes(); + String contentLength = exchange.header("Content-Length"); + if (contentLength != null && parseLength(contentLength) > maxBytes) { + throw bodyTooLarge(); + } + return exchange.body(maxBytes).defaultIfEmpty(new byte[0]).flatMap(bytes -> { + try { + if (bytes.length > maxBytes) { + throw bodyTooLarge(); + } + return accept(exchange, new String(bytes, StandardCharsets.UTF_8)); + } + catch (Rejection rejection) { + return Mono.just(rejection.reply()); + } + }); + } + + private static long parseLength(String value) { + try { + return Long.parseLong(value.trim()); + } + catch (NumberFormatException e) { + return -1; + } + } + + private Mono accept(AcpHttpExchange exchange, String body) throws Rejection { + JSONRPCMessage message; + try { + message = parseMessage(body); + } + catch (Rejection rejection) { + if (answerInvalidRequest(exchange, body)) { + return Mono.just(accepted()); + } + throw rejection; + } + if (StreamableHttpRouting.isInitialize(message)) { + return initialize(exchange, (AcpSchema.JSONRPCRequest) message); + } + StreamableHttpConnection connection = requireConnection(exchange, connections::get); + try { + connection.acceptClientPost(message, header(exchange, HEADER_SESSION_ID)); + } + catch (UnknownSessionException e) { + throw new Rejection(text(404, e.getMessage())); + } + catch (AcpConnectionException | IllegalArgumentException e) { + throw new Rejection(text(400, e.getMessage())); + } + return Mono.just(accepted()); + } + + /** + * A JSON object posted on a connection that is no valid JSON-RPC request (JSON-RPC 2.0 + * section 4) is answered as JSON-RPC prescribes, -32600 Invalid Request, on the connection + * stream, as the TypeScript server leaves an object-shaped body to the connection to + * validate. A body that is not JSON, or not an object, or posted without a connection, is + * still refused with 400. + * @return whether the body was answered + */ + private boolean answerInvalidRequest(AcpHttpExchange exchange, String body) throws Rejection { + if (!body.stripLeading().startsWith("{") || header(exchange, HEADER_CONNECTION_ID) == null) { + return false; + } + AcpSchema.JSONRPCResponse answer = AcpSchema.unreadableMessageResponse(jsonMapper, body); + AcpSchema.JSONRPCError error = answer.error(); + if (error == null || error.code() != AcpErrorCodes.INVALID_REQUEST) { + return false; + } + requireConnection(exchange, connections::get).answerInvalid(answer); + return true; + } + + /** One JSON-RPC message; a batch and anything that is not JSON-RPC are rejected. */ + private JSONRPCMessage parseMessage(String body) throws Rejection { + if (body.stripLeading().startsWith("[")) { + throw new Rejection(text(501, "JSON-RPC batches are not supported")); + } + try { + return AcpSchema.deserializeJsonRpcMessage(jsonMapper, body); + } + catch (Exception e) { + throw new Rejection(text(400, "Invalid JSON-RPC")); + } + } + + private Rejection bodyTooLarge() { + return new Rejection(text(413, "POST body exceeds " + options.maxPostBodyBytes() + " bytes")); + } + + private AcpHttpReply get(AcpHttpExchange exchange) throws Rejection { + if (!accepts(exchange, AcpHttpReply.EVENT_STREAM)) { + throw new Rejection(text(406, "client must accept text/event-stream")); + } + StreamableHttpConnection connection = requireConnection(exchange, connections::get); + try { + return connection.openStream(header(exchange, HEADER_SESSION_ID)); + } + catch (UnknownSessionException e) { + return text(404, e.getMessage()); + } + } + + private AcpHttpReply delete(AcpHttpExchange exchange) throws Rejection { + StreamableHttpConnection connection = requireConnection(exchange, connections::remove); + connection.close(); + return accepted(); + } + + /** + * The connection the request's connection header names, looked up (or removed) by + * {@code lookup}: refused with 400 when the header is missing, 404 when no such connection. + */ + private StreamableHttpConnection requireConnection(AcpHttpExchange exchange, + Function lookup) throws Rejection { + String connectionId = header(exchange, HEADER_CONNECTION_ID); + if (connectionId == null) { + throw new Rejection(text(400, HEADER_CONNECTION_ID + " header required")); + } + StreamableHttpConnection connection = lookup.apply(connectionId); + if (connection == null) { + throw new Rejection(new AcpHttpReply.Empty(404, Map.of())); + } + return connection; + } + + private Mono initialize(AcpHttpExchange exchange, AcpSchema.JSONRPCRequest request) { + if (header(exchange, HEADER_CONNECTION_ID) != null) { + return Mono.just(text(400, "initialize must not include " + HEADER_CONNECTION_ID)); + } + if (closing.get()) { + return Mono.just(shuttingDown()); + } + StreamableHttpConnection connection = createConnection(); + InitializeAttempt attempt = new InitializeAttempt(connection); + initializing.put(connection, attempt::refuse); + if (closing.get()) { + // closeGracefully() ran between the check above and the registration. + attempt.refuse(); + } + Duration initializeTimeout = options.initializeTimeout(); + Mono answer = connection.start() + .then(Mono.defer(() -> connection.initialize(request))) + .timeout(initializeTimeout, AcpSchedulers.timeouts()) + .map(attempt::succeeded) + .onErrorResume(error -> Mono.just(attempt.failed(500, "initialize failed"))); + // Agent creation is cheap and the handler runs on the agent's own scheduler, so this + // runs on the host's thread; the SDK does not use the global boundedElastic scheduler. + return Mono.firstWithSignal(attempt.refusal.asMono(), answer) + .doOnCancel(() -> attempt.failed(503, "initialize abandoned")); + } + + /** One {@code initialize} being answered: exactly one outcome wins. */ + private final class InitializeAttempt { + + private final StreamableHttpConnection connection; + + private final AtomicBoolean completed = new AtomicBoolean(false); + + private final Sinks.One refusal = Sinks.one(); + + InitializeAttempt(StreamableHttpConnection connection) { + this.connection = connection; + } + + void refuse() { + refusal.tryEmitValue(failed(503, "ACP endpoint is shutting down")); + } + + AcpHttpReply succeeded(JSONRPCMessage response) { + if (!completed.compareAndSet(false, true)) { + return shuttingDown(); + } + initializing.remove(connection); + try { + if (!(response instanceof AcpSchema.JSONRPCResponse)) { + throw new AcpConnectionException("initialize did not produce a JSON-RPC response"); + } + connections.put(connection.id(), connection); + if (closing.get()) { + // Closing began while the agent answered: closeGracefully() may already have + // gone through the connections, so this one is refused and closed here. + connections.remove(connection.id(), connection); + connection.close(); + return shuttingDown(); + } + return new AcpHttpReply.Body(200, Map.of(HEADER_CONNECTION_ID, connection.id()), CONTENT_TYPE_JSON, + jsonMapper.writeValueAsString(response)); + } + catch (Exception e) { + connections.remove(connection.id(), connection); + connection.close(); + return text(500, "initialize failed"); + } + } + + AcpHttpReply failed(int status, String body) { + if (completed.compareAndSet(false, true)) { + initializing.remove(connection); + connections.remove(connection.id(), connection); + connection.close(); + } + return text(status, body); + } + + } + + private StreamableHttpConnection createConnection() { + return new StreamableHttpConnection(UUID.randomUUID().toString(), jsonMapper, agentFactory, routing, options, + new StreamableHttpConnection.Owner(connection -> connections.remove(connection.id(), connection), + this::reportException)); + } + + // WebSocket + + @Override + public AcpWsHandshake webSocketHandshake(AcpHttpExchange handshake) { + if (!options.isOriginAllowed(handshake.header(HEADER_ORIGIN))) { + return new AcpWsHandshake.Refused(text(403, "Origin not allowed")); + } + if (closing.get()) { + return new AcpWsHandshake.Refused(shuttingDown()); + } + return new Acceptance(UUID.randomUUID().toString()); + } + + /** An accepted upgrade; the connection exists from {@link #open} on. */ + private final class Acceptance implements AcpWsHandshake.Accepted { + + private final String connectionId; + + private final AtomicBoolean opened = new AtomicBoolean(false); + + Acceptance(String connectionId) { + this.connectionId = connectionId; + } + + @Override + public Map headers() { + return Map.of(HEADER_CONNECTION_ID, connectionId); + } + + @Override + public long maxTextMessageBytes() { + return options.maxPostBodyBytes(); + } + + @Override + public Duration idleTimeout() { + return options.webSocketIdleTimeout().plus(CONTAINER_IDLE_GRACE); + } + + @Override + public AcpWsHandler open(AcpWsOutbound outbound) { + Assert.notNull(outbound, "The outbound can not be null"); + if (!opened.compareAndSet(false, true)) { + throw new IllegalStateException("Already opened"); + } + WebSocketConnection connection = new WebSocketConnection(connectionId, jsonMapper, options, outbound, + new WebSocketConnection.Owner(closed -> webSocketConnections.remove(closed.id(), closed), + DefaultAcpHttpEndpoint.this::reportException), + WebSocketConnection.Timing.SHARED); + webSocketConnections.put(connectionId, connection); + if (closing.get()) { + // Shutdown began during the upgrade: closeGracefully() may have gone through + // the connections already. + connection.closeForShutdown().subscribe(v -> { + }, error -> logger.debug("Error closing ACP WebSocket connection {}", connectionId, error)); + return connection; + } + logger.debug("ACP WebSocket connection {} opened", connectionId); + connection.start(agentFactory); + return connection; + } + + } + + // Lifecycle + + @Override + public Mono closeGracefully() { + return Mono.defer(() -> { + if (!closing.compareAndSet(false, true)) { + return Mono.empty(); + } + List.copyOf(initializing.values()).forEach(Runnable::run); + Disposable task = this.keepAliveTask; + if (task != null) { + task.dispose(); + } + List closingConnections = List.copyOf(connections.values()); + connections.clear(); + List closingWebSockets = List.copyOf(webSocketConnections.values()); + webSocketConnections.clear(); + List> closures = new ArrayList<>(); + closingConnections.forEach(connection -> closures.add(connection.closeForShutdown())); + closingWebSockets.forEach(connection -> closures.add(connection.closeForShutdown())); + Duration timeout = options.shutdownTimeout(); + return Mono.whenDelayError(closures) + .timeout(timeout, AcpSchedulers.timeouts()) + .onErrorResume(TimeoutException.class, timedOut -> { + logger.warn("ACP connections did not close within {}; closing them now", timeout); + closingConnections.forEach(StreamableHttpConnection::closeNow); + closingWebSockets.forEach(WebSocketConnection::closeNow); + return Mono.empty(); + }) + .onErrorResume(error -> { + reportException(error); + return Mono.empty(); + }); + }); + } + + // Helpers + + private static AcpHttpReply.Body text(int status, @Nullable String body) { + return new AcpHttpReply.Body(status, Map.of(), CONTENT_TYPE_TEXT, body != null ? body : ""); + } + + private static AcpHttpReply accepted() { + return new AcpHttpReply.Empty(202, Map.of()); + } + + private static AcpHttpReply shuttingDown() { + return text(503, "ACP endpoint is shutting down"); + } + + private static boolean hasContentType(AcpHttpExchange exchange, String expected) { + String contentType = exchange.header("Content-Type"); + return contentType != null + && contentType.toLowerCase(Locale.ROOT).split(";", 2)[0].trim().equals(expected); + } + + private static boolean accepts(AcpHttpExchange exchange, String expected) { + String accept = exchange.header("Accept"); + return accept != null && accept.toLowerCase(Locale.ROOT).contains(expected); + } + + private static @Nullable String header(AcpHttpExchange exchange, String name) { + String value = exchange.header(name); + return (value == null || value.isBlank()) ? null : value; + } + + /** A request refused with a reply; thrown so each method states the happy path once. */ + private static final class Rejection extends Exception { + + private static final long serialVersionUID = 1L; + + private final transient AcpHttpReply reply; + + Rejection(AcpHttpReply reply) { + super(null, null, false, false); + this.reply = reply; + } + + AcpHttpReply reply() { + return reply; + } + + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseFrame.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseFrame.java new file mode 100644 index 00000000..ef778de4 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseFrame.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.nio.charset.StandardCharsets; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; + +/** + * One frame of an SSE stream: an event carrying one JSON-RPC message, or a comment (the + * stream's opening comment, a keep-alive, or the closing comment of a shutdown). The endpoint + * creates them; a host writes {@link #encode()} as it is. + * + * @param data the event's data, one JSON-RPC message, or the comment's text + * @param comment whether this frame is a comment, which clients ignore + * @author Mark Pollack + */ +@UnstableAcpApi +public record SseFrame(String data, boolean comment) { + + /** + * Returns an event frame. + * @param json the JSON-RPC message, on one line + * @return the frame + */ + public static SseFrame event(String json) { + return new SseFrame(json, false); + } + + /** + * Returns a comment frame. + * @param text the comment, on one line + * @return the frame + */ + public static SseFrame comment(String text) { + return new SseFrame(text, true); + } + + /** + * Returns the frame as written on the wire, in UTF-8: {@code data: } or + * {@code : }, followed by the blank line that ends it. + * @return the bytes to write + */ + public byte[] encode() { + return ((comment ? ": " : "data: ") + data + "\n\n").getBytes(StandardCharsets.UTF_8); + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseOutboundStream.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseOutboundStream.java new file mode 100644 index 00000000..6804d311 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/SseOutboundStream.java @@ -0,0 +1,326 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.ArrayDeque; +import java.util.concurrent.locks.ReentrantLock; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; + +/** + * One outbound SSE stream of the Streamable HTTP transport (the connection stream or one + * session stream), with at most one attached subscriber: the frames of the host's response for + * the GET that opened it. + * + *

    + * A mailbox, as in the Rust and TypeScript servers. Every event goes into one bounded + * queue owned by the stream. The attached subscriber takes an event from the queue when the + * host requests a frame, and holds it as unconfirmed until the host requests again: a host + * requests the next frame only once the previous write has completed, so a request confirms + * the frames before it. When a subscriber goes away (the client dropped and the host cancelled, + * the server detached it for backpressure, or a new GET took the stream over) its unconfirmed + * events go back to the front of the queue in order, and the next subscriber continues from + * there. An event whose write was still pending when its subscriber was detached may therefore + * arrive twice rather than not at all. Events a client discarded after the host's write + * completed (its reset crossed the data on the wire) cannot be detected here; only event ids + * and {@code Last-Event-ID}, which the RFD defers, could close that. + *

    + * + *

    + * All state changes happen under the stream's private lock, including the host's request and + * cancel signals from its own threads, so a reconnect racing the old subscriber's close is + * serialised. A lock, not a monitor: a host may signal from a virtual thread, which a monitor + * would pin to its carrier on JDK 21. A frame is handed to the host under the lock; a host's + * re-entrant request from inside that hand-off is folded into the running loop, never recursed. + *

    + * + * @author Kaiser Dandangi + * @author Mark Pollack + */ +final class SseOutboundStream { + + private static final Logger logger = LoggerFactory.getLogger(SseOutboundStream.class); + + /** Commits the response at once, before any event, without emitting an ACP message. */ + static final SseFrame OPEN_COMMENT = SseFrame.comment("connected"); + + static final SseFrame KEEP_ALIVE_COMMENT = SseFrame.comment("keep-alive"); + + /** The last frame of a stream the endpoint closes while shutting down. */ + static final SseFrame SHUTDOWN_COMMENT = SseFrame.comment("shutting down"); + + /** Guards all state of this stream and its subscriber. */ + private final ReentrantLock lock = new ReentrantLock(); + + private final int mailboxCapacity; + + private final int maxPendingSseEvents; + + /** Events not yet handed to any subscriber, oldest first. Guarded by {@code lock}. */ + private final ArrayDeque mailbox = new ArrayDeque<>(); + + /** Guarded by {@code lock}. */ + private @Nullable Subscriber current; + + /** Guarded by {@code lock}. */ + private boolean closed; + + SseOutboundStream(int mailboxCapacity, int maxPendingSseEvents) { + this.mailboxCapacity = mailboxCapacity; + this.maxPendingSseEvents = maxPendingSseEvents; + } + + /** + * Queues an event and hands the attached subscriber as much as it has asked for. + * @throws AcpConnectionException when the mailbox is full; the caller closes the connection + * rather than drop an event + */ + void push(String payload) { + lock.lock(); + try { + if (closed) { + return; + } + Subscriber subscriber = current; + if (mailbox.size() + (subscriber != null ? subscriber.unconfirmed.size() : 0) >= mailboxCapacity) { + throw new AcpConnectionException("Outbound SSE replay buffer exceeded " + mailboxCapacity + " events"); + } + mailbox.addLast(payload); + if (subscriber == null) { + return; + } + if (mailbox.size() > maxPendingSseEvents) { + // The attached client is not reading. Detach it; the events stay queued for the + // next GET, and a full mailbox then closes the connection. + logger.warn("Closing backpressured SSE subscriber after {} pending events", maxPendingSseEvents); + detach(subscriber); + return; + } + subscriber.drain(); + } + finally { + lock.unlock(); + } + } + + /** + * The frames of a new GET on this stream: an opening comment, then the queued events as the + * host asks for them. One subscriber per stream: a new GET takes the stream over from a + * previous one the server may not yet know is dead (proxy drop, client restart), completing + * the old one's frames. Rust and TypeScript answer 409 instead; taking over is friendlier to a + * reconnecting client and never duplicates events. A closed stream's frames complete at once. + */ + Flux subscribe() { + return Flux.create(this::attach); + } + + private void attach(FluxSink sink) { + lock.lock(); + try { + if (closed) { + // DELETE may close the connection after the GET was answered. + sink.complete(); + return; + } + Subscriber previous = current; + if (previous != null) { + logger.debug("New SSE subscriber replaces the attached one"); + detach(previous); + } + Subscriber subscriber = new Subscriber(sink); + current = subscriber; + subscriber.comments.addLast(OPEN_COMMENT); + sink.onCancel(() -> detach(subscriber)); + // Called at once with any demand already signalled, then on every request. + sink.onRequest(subscriber::request); + } + finally { + lock.unlock(); + } + } + + void keepAlive() { + lock.lock(); + try { + Subscriber subscriber = current; + if (!closed && subscriber != null) { + subscriber.sendKeepAlive(); + } + } + finally { + lock.unlock(); + } + } + + /** Closes the stream: its subscriber's frames complete and queued events are dropped. */ + void close() { + close(false); + } + + /** + * Closes the stream as {@link #close()} does, after handing the subscriber a closing comment + * if it has asked for a frame: the endpoint is shutting down. + */ + void closeForShutdown() { + close(true); + } + + private void close(boolean shutdownComment) { + lock.lock(); + try { + if (closed) { + return; + } + closed = true; + Subscriber subscriber = current; + if (subscriber != null) { + if (shutdownComment) { + subscriber.comments.clear(); + subscriber.comments.addLast(SHUTDOWN_COMMENT); + subscriber.drainCommentsOnly(); + } + detach(subscriber); + } + mailbox.clear(); + } + finally { + lock.unlock(); + } + } + + boolean isClosed() { + lock.lock(); + try { + return closed; + } + finally { + lock.unlock(); + } + } + + /** Detaches a subscriber if it is still attached, and completes its frames. */ + private void detach(Subscriber subscriber) { + lock.lock(); + try { + if (subscriber.detached) { + return; + } + subscriber.detached = true; + if (current == subscriber) { + current = null; + } + // Not known to have reached the client: back to the front, in order. + while (!subscriber.unconfirmed.isEmpty()) { + mailbox.addFirst(subscriber.unconfirmed.removeLast()); + } + subscriber.sink.complete(); + } + finally { + lock.unlock(); + } + } + + /** + * One GET's frames. Holds no events of its own except comments and the events handed to the + * host whose write has not been confirmed; every method runs under the stream's lock. + */ + private final class Subscriber { + + private final FluxSink sink; + + /** Comments (open, keep-alive, shutdown) not yet handed over; they go before events. */ + private final ArrayDeque comments = new ArrayDeque<>(); + + /** Events handed to the host, oldest first, whose write is not yet confirmed. */ + private final ArrayDeque unconfirmed = new ArrayDeque<>(); + + /** Frames the host has asked for and not yet been given. */ + private long demand; + + private boolean detached; + + /** Set while frames are being handed over, so a re-entrant request does not recurse. */ + private boolean draining; + + Subscriber(FluxSink sink) { + this.sink = sink; + } + + void request(long n) { + lock.lock(); + try { + if (detached) { + return; + } + // The host asks for more only once what it was given has been written. + unconfirmed.clear(); + demand = (demand + n < 0) ? Long.MAX_VALUE : demand + n; + drain(); + } + finally { + lock.unlock(); + } + } + + void sendKeepAlive() { + if (!detached && comments.isEmpty() && mailbox.isEmpty()) { + comments.addLast(KEEP_ALIVE_COMMENT); + drain(); + } + } + + void drain() { + drain(true); + } + + void drainCommentsOnly() { + drain(false); + } + + private void drain(boolean events) { + if (draining) { + return; + } + draining = true; + try { + while (!detached && demand > 0) { + SseFrame frame = nextFrame(events); + if (frame == null) { + return; + } + demand--; + sink.next(frame); + } + } + finally { + draining = false; + } + } + + /** The next comment, else the next mailbox event (then unconfirmed); null when none. */ + private @Nullable SseFrame nextFrame(boolean events) { + SseFrame comment = comments.pollFirst(); + if (comment != null) { + return comment; + } + if (!events) { + return null; + } + String payload = mailbox.pollFirst(); + if (payload == null) { + return null; + } + unconfirmed.addLast(payload); + return SseFrame.event(payload); + } + + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpConnection.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpConnection.java new file mode 100644 index 00000000..159b4c14 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpConnection.java @@ -0,0 +1,312 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.ClientRequestRoute; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.MethodCall; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.RequestKind; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.ResolvedInboundRoute; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.RouteScope; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.HEADER_CONNECTION_ID; +import static com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.HEADER_SESSION_ID; + +/** + * One remote ACP connection over Streamable HTTP (POST/SSE): its agent runtime, its + * connection stream, its sessions and their streams ({@link StreamableHttpSessions}), and + * the request-id route maps that send each response back on the stream its request came + * from. + * + * @author Kaiser Dandangi + */ +final class StreamableHttpConnection { + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpConnection.class); + + + private final String id; + + private final RemoteAcpConnection connection; + + private final SseOutboundStream connectionStream; + + private final StreamableHttpSessions sessions; + + // Client-originated request id -> route expected for the later agent response. + private final ConcurrentMap clientRequestRoutes = new ConcurrentHashMap<>(); + + // Agent-originated request id -> route required for the later client response. + private final ConcurrentMap agentRequestRoutes = new ConcurrentHashMap<>(); + + private final Sinks.One initializeResponse = Sinks.one(); + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private volatile @Nullable Object initializeRequestId; + + private final AcpJsonMapper jsonMapper; + + private final AcpAgentFactory agentFactory; + + private final StreamableHttpRouting routing; + + private final Consumer deregister; + + /** + * What a connection needs from the servlet that holds it. + * @param deregister removes the connection from the servlet once it closes + * @param exceptionHandler receives the connection's transport errors + */ + record Owner(Consumer deregister, Consumer exceptionHandler) { + } + + StreamableHttpConnection(String id, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, + StreamableHttpRouting routing, StreamableHttpAcpAgentTransportOptions options, Owner owner) { + this.id = id; + this.jsonMapper = jsonMapper; + this.agentFactory = agentFactory; + this.routing = routing; + this.deregister = owner.deregister(); + this.connectionStream = new SseOutboundStream(options.mailboxCapacity(), options.maxPendingSseEvents()); + this.sessions = new StreamableHttpSessions(id, options); + this.connection = new RemoteAcpConnection(id, jsonMapper, this::routeAgentMessage, owner.exceptionHandler()); + } + + String id() { + return id; + } + + Mono start() { + return this.connection.start(agentFactory); + } + + Mono initialize(AcpSchema.JSONRPCRequest request) { + this.initializeRequestId = request.id(); + connection.acceptInbound(request); + return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true)); + } + + void acceptClientPost(JSONRPCMessage message, @Nullable String sessionHeader) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + validateClientResponseScope(response, sessionHeader); + connection.acceptInbound(message); + return; + } + + ResolvedInboundRoute resolved = routing.resolveInboundRoute(message, sessionHeader); + ClientRequestRoute route = resolved.requestRoute(); + if (resolved.requestScope().isSession()) { + boolean hasStream = sessions.admitInbound(resolved.requestScope().boundSessionId(), + route != null && route.kind() == RequestKind.SESSION_LOAD); + if (!hasStream && route != null && route.responseScope().isSession()) { + // A session this connection does not know and has no stream for: answer on the + // connection stream, which the client always holds. + route = new ClientRequestRoute(route.kind(), route.requestScope(), RouteScope.connection()); + } + } + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null && route != null) { + clientRequestRoutes.put(request.id(), route); + } + connection.acceptInbound(message); + } + + /** Answers a message the client posted that is no valid JSON-RPC request, on the connection stream. */ + void answerInvalid(AcpSchema.JSONRPCResponse answer) { + logger.warn("Answered an invalid JSON-RPC request on connection {} with {}", id, answer.error()); + try { + connectionStream.push(jsonMapper.writeValueAsString(answer)); + } + catch (IOException e) { + connection.signalException(e); + } + } + + /** + * The SSE response of a GET: the connection's stream, or a session's. + * @throws UnknownSessionException when a new session stream would exceed the provisional + * bound + */ + AcpHttpReply.EventStream openStream(@Nullable String sessionId) { + RouteScope scope = sessionId == null ? RouteScope.connection() : RouteScope.session(sessionId); + SseOutboundStream stream = scope.isSession() ? sessions.openStream(scope.boundSessionId()) : connectionStream; + Map headers = new LinkedHashMap<>(); + headers.put("Cache-Control", "no-cache"); + // nginx and other proxies buffer responses by default, which holds SSE events back. + headers.put("X-Accel-Buffering", "no"); + headers.put(HEADER_CONNECTION_ID, id); + if (scope.isSession()) { + headers.put(HEADER_SESSION_ID, scope.boundSessionId()); + } + return new AcpHttpReply.EventStream(headers, stream.subscribe()); + } + + Mono closeGracefully() { + deregister.accept(this); + connectionStream.close(); + sessions.close(); + return connection.closeGracefully(); + } + + /** + * Closes the connection as {@link #closeGracefully()} does, giving each open SSE stream a + * closing comment first: the endpoint is shutting down. + */ + Mono closeForShutdown() { + deregister.accept(this); + connectionStream.closeForShutdown(); + sessions.closeForShutdown(); + return connection.closeGracefully(); + } + + void close() { + closeGracefully().subscribe(v -> { + }, error -> logger.warn("Error closing Streamable HTTP ACP connection {}", id, error)); + } + + /** + * Closes the connection and its agent runtime at once, also after a graceful close that + * has not finished: the shutdown timeout has passed. + */ + void closeNow() { + deregister.accept(this); + connectionStream.close(); + sessions.close(); + connection.close(); + } + + private void routeAgentMessage(JSONRPCMessage message) { + try { + if (message instanceof AcpSchema.JSONRPCResponse response + && Objects.equals(response.id(), initializeRequestId) && !initialized.get()) { + initializeResponse.tryEmitValue(message); + return; + } + + RouteScope scope = resolveAgentOutboundScope(message); + String payload = jsonMapper.writeValueAsString(message); + if (scope.isSession()) { + sessions.stream(scope.boundSessionId()).push(payload); + } + else { + connectionStream.push(payload); + } + } + catch (Exception e) { + connection.signalException(e); + close(); + } + } + + private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCResponse response) { + return resolveResponseScope(response); + } + MethodCall call = MethodCall.of(message, "outbound"); + RouteScope scope = routing.resolveAgentRequestOrNotificationScope(call.method(), call.params()); + Object requestId = call.id(); + if (requestId != null) { + agentRequestRoutes.put(requestId, scope); + } + return scope; + } + + /** The stream the client request answered by {@code response} asked for its reply. */ + private RouteScope resolveResponseScope(AcpSchema.JSONRPCResponse response) { + Object responseId = response.id(); + if (responseId == null) { + // The answer to a request posted with "id": null, which was never routed (a + // ConcurrentMap holds no null key); only the connection stream can carry it. + return RouteScope.connection(); + } + ClientRequestRoute route = clientRequestRoutes.remove(responseId); + if (route == null) { + logger.warn("Agent emitted response for unknown client request id {}; routing to connection stream", + responseId); + return RouteScope.connection(); + } + recordSessionOutcome(route, response); + return route.responseScope(); + } + + /** Applies what a reply to session/new, session/fork or session/load says about its session. */ + private void recordSessionOutcome(ClientRequestRoute route, AcpSchema.JSONRPCResponse response) { + boolean succeeded = response.error() == null; + switch (route.kind()) { + case SESSION_NEW, SESSION_FORK -> { + // Both replies carry the id of a session that now exists on this connection. + if (succeeded) { + sessions.markKnown(routing.extractSessionIdFromNewSessionResponse(response)); + } + } + case SESSION_LOAD -> { + if (succeeded) { + sessions.markKnown(route.requestScope().boundSessionId()); + } + else { + sessions.discardProvisional(route.requestScope().boundSessionId()); + } + } + default -> { + // No session changes state. + } + } + } + + private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, @Nullable String sessionHeader) { + Object responseId = response.id(); + if (responseId == null) { + // JSON-RPC's answer to a request the client could not parse: it names no agent + // request, so there is no scope to check. The session logs and drops it. + return; + } + RouteScope expected = agentRequestRoutes.get(responseId); + if (expected == null) { + logger.warn("Client posted response for unknown agent request id {}", responseId); + return; + } + if (sessionHeader == null) { + // The RFD asks for Acp-Session-Id on permission responses; the Rust and Python + // clients omit it on every response. The id alone identifies the exchange, so a + // missing header is accepted. A header naming a different scope is still an error. + logger.debug("Client response {} carried no {}; accepting it for {}", responseId, + HEADER_SESSION_ID, expected); + agentRequestRoutes.remove(responseId, expected); + return; + } + RouteScope actual = RouteScope.session(sessionHeader); + if (!Objects.equals(expected, actual)) { + throw new AcpConnectionException( + "Response id " + responseId + " arrived on " + actual + " but expected " + expected); + } + agentRequestRoutes.remove(responseId, expected); + } + + void keepAlive() { + connectionStream.keepAlive(); + sessions.keepAlive(); + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRouting.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRouting.java new file mode 100644 index 00000000..f02fe738 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRouting.java @@ -0,0 +1,317 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import org.jspecify.annotations.Nullable; + +/** + * Routing rules of the Streamable HTTP transport: which stream (connection or session) + * each ACP method travels on, and how session ids are read from params and headers. + * + *

    + * Stateless apart from the JSON mapper used to look into untyped params. Holds no + * connection state and has no servlet or Jetty dependency; per-connection bookkeeping + * lives in {@link StreamableHttpConnection}. + *

    + * + * @author Kaiser Dandangi + */ +final class StreamableHttpRouting { + + static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; + + static final String HEADER_SESSION_ID = "Acp-Session-Id"; + + static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; + + static final String HEADER_ORIGIN = "Origin"; + + /** Agent-to-client methods that always concern one session, so params must name it. */ + private static final Set SESSION_SCOPED_AGENT_METHODS = Set.of(AcpSchema.METHOD_SESSION_REQUEST_PERMISSION, + AcpSchema.METHOD_SESSION_UPDATE, AcpSchema.METHOD_FS_READ_TEXT_FILE, AcpSchema.METHOD_FS_WRITE_TEXT_FILE, + AcpSchema.METHOD_TERMINAL_CREATE, AcpSchema.METHOD_TERMINAL_OUTPUT, AcpSchema.METHOD_TERMINAL_RELEASE, + AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT, AcpSchema.METHOD_TERMINAL_KILL); + + private final AcpJsonMapper jsonMapper; + + /** + * Every client-to-agent method of ACP v1, stable and unstable (schema/v1/meta.json, + * meta.unstable.json), and the {@code $/} protocol methods, with its routing rule. A + * method whose params require a {@code sessionId} is session-scoped and needs + * {@code Acp-Session-Id} (the RFD's Identity Model), as in the Rust and TypeScript SDKs' + * tables. Any other method, such as an extension method, uses {@link #defaultRoute}. + */ + private final Map inboundRules; + + StreamableHttpRouting(AcpJsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + InboundRule connection = (method, params, header) -> connectionRoute(RequestKind.GENERIC); + this.inboundRules = Map.ofEntries(Map.entry(AcpSchema.METHOD_AUTHENTICATE, connection), + Map.entry(AcpSchema.METHOD_LOGOUT, connection), + Map.entry(AcpSchema.METHOD_SESSION_NEW, + (method, params, header) -> connectionRoute(RequestKind.SESSION_NEW)), + Map.entry(AcpSchema.METHOD_SESSION_LIST, connection), + Map.entry(AcpSchema.METHOD_PROVIDERS_LIST, connection), + Map.entry(AcpSchema.METHOD_PROVIDERS_SET, connection), + Map.entry(AcpSchema.METHOD_PROVIDERS_DISABLE, connection), Map.entry("nes/start", connection), + Map.entry("mcp/message", connection), Map.entry(AcpSchema.METHOD_CANCEL_REQUEST, connection), + Map.entry(AcpSchema.METHOD_SESSION_LOAD, this::loadRoute), + Map.entry(AcpSchema.METHOD_SESSION_RESUME, this::loadRoute), + // Scoped to the parent session; the reply names the forked session. + Map.entry(AcpSchema.METHOD_SESSION_FORK, + (method, params, header) -> headerOptionalRoute(RequestKind.SESSION_FORK, method, params, + header)), + Map.entry(AcpSchema.METHOD_SESSION_DELETE, + (method, params, header) -> headerOptionalRoute(RequestKind.GENERIC, method, params, header)), + Map.entry(AcpSchema.METHOD_SESSION_PROMPT, this::sessionBoundRoute), + Map.entry(AcpSchema.METHOD_SESSION_SET_MODE, this::sessionBoundRoute), + Map.entry(AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, this::sessionBoundRoute), + Map.entry(AcpSchema.METHOD_SESSION_CANCEL, this::sessionBoundRoute), + Map.entry(AcpSchema.METHOD_SESSION_CLOSE, this::sessionBoundRoute), + Map.entry("nes/suggest", this::sessionBoundRoute), Map.entry("nes/accept", this::sessionBoundRoute), + Map.entry("nes/reject", this::sessionBoundRoute), Map.entry("nes/close", this::sessionBoundRoute), + Map.entry("document/didOpen", this::sessionBoundRoute), + Map.entry("document/didChange", this::sessionBoundRoute), + Map.entry("document/didClose", this::sessionBoundRoute), + Map.entry("document/didSave", this::sessionBoundRoute), + Map.entry("document/didFocus", this::sessionBoundRoute)); + } + + /** How one client-to-agent method is routed: its request scope and its response scope. */ + @FunctionalInterface + private interface InboundRule { + + ClientRequestRoute route(String method, @Nullable Object params, @Nullable String sessionHeader); + + } + + /** The method, params and (for a request) id of a JSON-RPC request or notification. */ + record MethodCall(String method, @Nullable Object params, @Nullable Object id) { + + /** + * @throws AcpConnectionException for a message that is neither a request nor a + * notification; {@code direction} ("inbound", "outbound") names it in the message + */ + static MethodCall of(JSONRPCMessage message, String direction) { + if (message instanceof AcpSchema.JSONRPCRequest request) { + return new MethodCall(request.method(), request.params(), request.id()); + } + if (message instanceof AcpSchema.JSONRPCNotification notification) { + return new MethodCall(notification.method(), notification.params(), null); + } + throw new AcpConnectionException("Unsupported " + direction + " JSON-RPC message type: " + message); + } + + } + + enum ScopeKind { + + CONNECTION, + + SESSION + + } + + enum RequestKind { + + INITIALIZE, + + SESSION_NEW, + + SESSION_FORK, + + SESSION_LOAD, + + GENERIC + + } + + enum SessionState { + + PENDING_LOAD, + + KNOWN + + } + + record RouteScope(ScopeKind kind, @Nullable String sessionId) { + + static RouteScope connection() { + return new RouteScope(ScopeKind.CONNECTION, null); + } + + static RouteScope session(String sessionId) { + return new RouteScope(ScopeKind.SESSION, sessionId); + } + + boolean isSession() { + return kind == ScopeKind.SESSION; + } + + /** + * The session id this scope routes to. Only session scopes carry one, and every + * caller has already established that the scope is one. + */ + String boundSessionId() { + if (sessionId == null) { + throw new IllegalStateException("A " + kind + " route scope has no session id"); + } + return sessionId; + } + + } + + record ClientRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { + } + + record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope, + @Nullable ClientRequestRoute requestRoute) { + } + + static boolean isInitialize(JSONRPCMessage message) { + return message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method()); + } + + static boolean isInitializeRequest(JSONRPCMessage message) { + return message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null; + } + + RouteScope resolveAgentRequestOrNotificationScope(String method, @Nullable Object params) { + // Protocol-level ($/) messages, $/cancel_request included, are connection-scoped + // whatever their params name, as in the Rust SDK; the client's table agrees. + if (method.startsWith("$/")) { + return RouteScope.connection(); + } + if (SESSION_SCOPED_AGENT_METHODS.contains(method)) { + return RouteScope.session(requireSessionId(params, method)); + } + return extractSessionId(params).map(RouteScope::session).orElseGet(RouteScope::connection); + } + + /** The methods routed by their own rule rather than {@link #defaultRoute}. */ + Set routedMethods() { + return inboundRules.keySet(); + } + + ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, @Nullable String sessionHeader) { + MethodCall call = MethodCall.of(message, "inbound"); + ClientRequestRoute route = inboundRules.getOrDefault(call.method(), this::defaultRoute) + .route(call.method(), call.params(), sessionHeader); + // Only a request gets a response to route; a notification keeps just its scope. + ClientRequestRoute requestRoute = message instanceof AcpSchema.JSONRPCRequest ? route : null; + return new ResolvedInboundRoute(message, route.requestScope(), requestRoute); + } + + private static ClientRequestRoute connectionRoute(RequestKind kind) { + return new ClientRequestRoute(kind, RouteScope.connection(), RouteScope.connection()); + } + + /** A request answered on the stream (connection or session) it was routed to. */ + private static ClientRequestRoute sameStreamRoute(RequestKind kind, RouteScope scope) { + return new ClientRequestRoute(kind, scope, scope); + } + + /** + * session/load and session/resume. The RFD is ambiguous here: its reconnect diagram sends + * the header, its text says load answers on the connection stream because the client has + * no session yet. The Python client omits it. Accept either; the id comes from params. + */ + private ClientRequestRoute loadRoute(String method, @Nullable Object params, @Nullable String sessionHeader) { + return new ClientRequestRoute(RequestKind.SESSION_LOAD, sessionScopeFromParams(method, params, sessionHeader), + RouteScope.connection()); + } + + /** + * session/fork and session/delete. The RFD lists no session-scoped method; the Rust and + * TypeScript clients send the header on both, the Python client on neither (it sends them + * like session/load). With the header, the request is session-scoped like any other; + * without it, the session comes from params and the reply goes on the connection stream, + * the one stream such a client is sure to hold. + */ + private ClientRequestRoute headerOptionalRoute(RequestKind kind, String method, @Nullable Object params, + @Nullable String sessionHeader) { + if (sessionHeader == null) { + return new ClientRequestRoute(kind, RouteScope.session(requireSessionId(params, method)), + RouteScope.connection()); + } + return sameStreamRoute(kind, requireSessionScope(method, params, sessionHeader)); + } + + /** A method that always concerns one session: params and header must both name it. */ + private ClientRequestRoute sessionBoundRoute(String method, @Nullable Object params, + @Nullable String sessionHeader) { + return sameStreamRoute(RequestKind.GENERIC, requireSessionScope(method, params, sessionHeader)); + } + + /** Any other method: session-scoped when its params name a session, else connection-scoped. */ + private ClientRequestRoute defaultRoute(String method, @Nullable Object params, @Nullable String sessionHeader) { + RouteScope scope = extractSessionId(params).isPresent() ? requireSessionScope(method, params, sessionHeader) + : RouteScope.connection(); + return sameStreamRoute(RequestKind.GENERIC, scope); + } + + /** Like {@link #requireSessionScope} but tolerates a missing header; a conflicting one is still rejected. */ + RouteScope sessionScopeFromParams(String method, @Nullable Object params, @Nullable String sessionHeader) { + if (sessionHeader == null) { + return RouteScope.session(requireSessionId(params, method)); + } + return requireSessionScope(method, params, sessionHeader); + } + + RouteScope requireSessionScope(String method, @Nullable Object params, @Nullable String sessionHeader) { + String sessionId = requireSessionId(params, method); + if (sessionHeader == null) { + throw new AcpConnectionException( + HEADER_SESSION_ID + " header required for " + method); + } + if (!sessionId.equals(sessionHeader)) { + throw new AcpConnectionException("Header " + HEADER_SESSION_ID + + " does not match params.sessionId"); + } + return RouteScope.session(sessionId); + } + + Optional extractSessionId(@Nullable Object params) { + if (params == null) { + return Optional.empty(); + } + Map paramsMap = jsonMapper.convertValue(params, Map.class); + Object sessionId = paramsMap.get("sessionId"); + return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); + } + + String requireSessionId(@Nullable Object params, String method) { + return extractSessionId(params) + .filter(sessionId -> !sessionId.isBlank()) + .orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method)); + } + + String extractSessionIdFromNewSessionResponse(AcpSchema.JSONRPCResponse response) { + Object result = response.result(); + if (result == null) { + throw new AcpConnectionException("session/new response carried no result"); + } + AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(result, + new TypeRef() { + }); + // Required by the schema, but conversion does not enforce it. + if (sessionResponse.sessionId() == null || sessionResponse.sessionId().isBlank()) { + throw new AcpConnectionException("session/new response missing sessionId"); + } + return sessionResponse.sessionId(); + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessions.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessions.java new file mode 100644 index 00000000..0ddf28f8 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessions.java @@ -0,0 +1,188 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.SessionState; + +/** + * The sessions one Streamable HTTP connection knows and the SSE stream of each: known + * sessions, and provisional ones a client opened a stream for, or sent {@code session/load} + * for, before the agent confirmed them. Provisional sessions are bounded, so a client cannot + * grow server state with arbitrary ids. + * + *

    + * Client requests (servlet threads), the agent's outbound messages and closing the connection + * change the table concurrently, so every change runs under this object's lock: a check of + * a session's state and the change it decides stay together (a session GET racing a failed + * {@code session/load} could otherwise recreate the discarded stream outside the table). A + * stream first asked for after the connection closed used to stay open, holding its GET + * forever (found by Lincheck, StreamableHttpSessionsLincheckTest); it is now closed. + *

    + * + * @author Kaiser Dandangi + */ +final class StreamableHttpSessions { + + private final String connectionId; + + private final StreamableHttpAcpAgentTransportOptions options; + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + + private final ConcurrentMap streams = new ConcurrentHashMap<>(); + + /** + * Guards every change of the table. A lock, not a monitor: changes run on the host's threads, + * virtual ones included, and closing a stream may wait for that stream's own lock. + */ + private final ReentrantLock lock = new ReentrantLock(); + + /** Guarded by {@code lock}. */ + private boolean closed; + + StreamableHttpSessions(String connectionId, StreamableHttpAcpAgentTransportOptions options) { + this.connectionId = connectionId; + this.options = options; + } + + /** + * The stream of a session, created on first use. Once the connection has closed, the + * stream is closed too: a GET subscribing to it completes at once. + */ + SseOutboundStream stream(String sessionId) { + lock.lock(); + try { + SseOutboundStream stream = streams.computeIfAbsent(sessionId, + ignored -> new SseOutboundStream(options.mailboxCapacity(), options.maxPendingSseEvents())); + if (closed) { + stream.close(); + } + return stream; + } + finally { + lock.unlock(); + } + } + + /** The stream a session-scoped GET subscribes to; an unknown session becomes provisional. */ + SseOutboundStream openStream(String sessionId) { + lock.lock(); + try { + if (!sessions.containsKey(sessionId)) { + /* + * RFD gap: + * The current text says unknown session-scoped GET requests return 404, + * but its resume flow also asks clients to open a session stream before + * sending session/load. Keep a provisional stream so practical resume can work. + */ + addProvisional(sessionId); + } + return stream(sessionId); + } + finally { + lock.unlock(); + } + } + + /** + * Admits a client message scoped to a session: a {@code session/load} may name a new + * session, which becomes provisional. Any other message is admitted whatever it names: + * the RFD's POST decision tree has no 404 for a session the connection does not know, so + * the agent decides (a {@code session/delete} of a session that never existed succeeds, + * session-delete.mdx). + * @return whether the session has a stream to answer on; otherwise its reply belongs on + * the connection stream, and no state is kept for an id the agent never confirmed + * @throws UnknownSessionException when a load would exceed the provisional bound + */ + boolean admitInbound(String sessionId, boolean isLoad) { + lock.lock(); + try { + if (isLoad && !sessions.containsKey(sessionId)) { + addProvisional(sessionId); + stream(sessionId); + } + return sessions.containsKey(sessionId); + } + finally { + lock.unlock(); + } + } + + void markKnown(String sessionId) { + lock.lock(); + try { + sessions.put(sessionId, SessionState.KNOWN); + stream(sessionId); + } + finally { + lock.unlock(); + } + } + + /** A failed session/load leaves no provisional state behind. */ + void discardProvisional(String sessionId) { + lock.lock(); + try { + if (sessions.remove(sessionId, SessionState.PENDING_LOAD)) { + SseOutboundStream stream = streams.remove(sessionId); + if (stream != null) { + stream.close(); + } + } + } + finally { + lock.unlock(); + } + } + + void keepAlive() { + streams.values().forEach(SseOutboundStream::keepAlive); + } + + void close() { + lock.lock(); + try { + closed = true; + streams.values().forEach(SseOutboundStream::close); + } + finally { + lock.unlock(); + } + } + + /** Closes every session stream with a closing comment: the endpoint is shutting down. */ + void closeForShutdown() { + lock.lock(); + try { + closed = true; + streams.values().forEach(SseOutboundStream::closeForShutdown); + } + finally { + lock.unlock(); + } + } + + private void addProvisional(String sessionId) { + lock.lock(); + try { + long provisional = sessions.values().stream().filter(state -> state == SessionState.PENDING_LOAD).count(); + if (provisional >= options.maxProvisionalSessions() + && sessions.get(sessionId) != SessionState.PENDING_LOAD) { + throw new UnknownSessionException("Too many provisional sessions on connection " + connectionId + + " (limit " + options.maxProvisionalSessions() + ")"); + } + sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD); + } + finally { + lock.unlock(); + } + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/UnknownSessionException.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/UnknownSessionException.java new file mode 100644 index 00000000..58bbbf89 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/UnknownSessionException.java @@ -0,0 +1,21 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +/** + * A request named a session this connection does not know, or would exceed the bound on + * provisional sessions. The servlet answers it with 404. + * + * @author Kaiser Dandangi + */ +final class UnknownSessionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + UnknownSessionException(String message) { + super(message); + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/WebSocketConnection.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/WebSocketConnection.java new file mode 100644 index 00000000..2dbbc35f --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/WebSocketConnection.java @@ -0,0 +1,460 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; +import java.util.function.LongSupplier; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +/** + * One ACP connection over WebSocket, on any host: its agent runtime, the initialize-first + * rule and its deadline, the inbound size limit, the idle timeout, and a serialized, bounded + * frame sender over the host's {@link AcpWsOutbound}. The one WebSocket connection of the SDK; the Jetty-native and Vert.x + * copies it replaces each held the same rules. + * + * @author Kaiser Dandangi + * @author Mark Pollack + */ +final class WebSocketConnection implements AcpWsHandler { + + private static final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class); + + static final int NORMAL = 1000; + + static final int GOING_AWAY = 1001; + + static final int PROTOCOL_ERROR = 1002; + + static final int POLICY_VIOLATION = 1008; + + static final int MESSAGE_TOO_BIG = 1009; + + static final int SERVER_ERROR = 1011; + + private final String id; + + private final AcpJsonMapper jsonMapper; + + private final StreamableHttpAcpAgentTransportOptions options; + + private final AcpWsOutbound outbound; + + private final Consumer deregister; + + private final RemoteAcpConnection remoteConnection; + + private final AtomicBoolean initialized = new AtomicBoolean(false); + + private final AtomicBoolean closed = new AtomicBoolean(false); + + /** Guards the send queue. A lock, not a monitor: senders may be virtual threads. */ + private final ReentrantLock sendLock = new ReentrantLock(); + + /** Frames waiting for the previous send to complete. Guarded by {@code sendLock}. */ + private final ArrayDeque queue = new ArrayDeque<>(); + + /** Whether a frame is with the host. Guarded by {@code sendLock}. */ + private boolean sendInProgress; + + /** The timer of the idle check and the initialize deadline. */ + private final Scheduler timer; + + /** The clock activity is measured on, in nanoseconds. */ + private final LongSupplier clock; + + /** When a frame last arrived or was handed to the host, on {@link #clock}. */ + private volatile long lastActivity; + + /** The next idle check; disposed on close. */ + private volatile @Nullable Disposable idleCheck; + + /** The initialize deadline; disposed on initialize and on close. */ + private volatile @Nullable Disposable initializeDeadline; + + /** + * What a connection reports to its endpoint. + * @param deregister called once when the connection closes + * @param exceptionHandler the endpoint's exception handler + */ + record Owner(Consumer deregister, Consumer exceptionHandler) { + } + + /** + * What a connection's timeouts run on: the SDK's shared timer and {@link System#nanoTime()}, + * or virtual time in tests. + * @param timer the scheduler of the idle check and the initialize deadline + * @param clock the clock activity is measured on, in nanoseconds + */ + record Timing(Scheduler timer, LongSupplier clock) { + + static final Timing SHARED = new Timing(AcpSchedulers.timeouts(), System::nanoTime); + + } + + WebSocketConnection(String id, AcpJsonMapper jsonMapper, StreamableHttpAcpAgentTransportOptions options, + AcpWsOutbound outbound, Owner owner, Timing timing) { + this.id = id; + this.jsonMapper = jsonMapper; + this.options = options; + this.outbound = outbound; + this.deregister = owner.deregister(); + this.timer = timing.timer(); + this.clock = timing.clock(); + this.lastActivity = clock.getAsLong(); + this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient, owner.exceptionHandler()); + } + + String id() { + return id; + } + + /** + * Starts the agent, the initialize deadline and the idle check; frames that arrive + * meanwhile are kept until the agent runs. + */ + void start(AcpAgentFactory agentFactory) { + startTimers(); + Duration startTimeout = options.initializeTimeout(); + remoteConnection.start(agentFactory) + .timeout(startTimeout, timer) + .subscribe(ignored -> { + }, error -> close(SERVER_ERROR, "agent failed to start")); + } + + /** + * Schedules the initialize deadline and the first idle check. Each is one task on the shared + * timer at a time; closing disposes both, so none outlives the connection. + */ + private void startTimers() { + Duration deadline = options.initializeTimeout(); + this.initializeDeadline = timer.schedule(this::initializeDeadlinePassed, deadline.toNanos(), + TimeUnit.NANOSECONDS); + scheduleIdleCheck(options.webSocketIdleTimeout().toNanos()); + if (closed.get()) { + // Closed while the timers were being scheduled. + cancelTimers(); + } + } + + private void initializeDeadlinePassed() { + if (!initialized.get() && !closed.get()) { + logger.debug("Closing ACP WebSocket connection {}: no initialize within {}", id, + options.initializeTimeout()); + close(POLICY_VIOLATION, "initialize not received"); + } + } + + private void scheduleIdleCheck(long delayNanos) { + this.idleCheck = timer.schedule(this::checkIdle, delayNanos, TimeUnit.NANOSECONDS); + if (closed.get()) { + cancelTimers(); + } + } + + /** + * Closes the connection once no frame has passed for the idle timeout; otherwise checks + * again when the timeout would run out after the latest frame. Activity only records a + * time, so a busy connection costs one check per timeout. + */ + private void checkIdle() { + if (closed.get()) { + return; + } + long timeout = options.webSocketIdleTimeout().toNanos(); + long idle = clock.getAsLong() - lastActivity; + if (idle >= timeout) { + logger.debug("Closing ACP WebSocket connection {}: idle for {}", id, options.webSocketIdleTimeout()); + close(GOING_AWAY, "idle timeout"); + return; + } + scheduleIdleCheck(timeout - idle); + } + + private void recordActivity() { + this.lastActivity = clock.getAsLong(); + } + + private void cancelTimers() { + Disposable deadline = this.initializeDeadline; + if (deadline != null) { + deadline.dispose(); + } + Disposable check = this.idleCheck; + if (check != null) { + check.dispose(); + } + } + + @Override + public void onText(String text) { + logger.debug("ACP WebSocket connection {} received {} characters", id, text.length()); + recordActivity(); + if (closed.get()) { + return; + } + if (exceedsLimit(text)) { + logger.warn("Closing ACP WebSocket connection {}: a message exceeded {} bytes", id, + options.maxPostBodyBytes()); + close(MESSAGE_TOO_BIG, "message too big"); + return; + } + JSONRPCMessage message; + try { + message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, text); + } + catch (Exception e) { + // Answered as JSON-RPC 2.0 says and skipped, as every ACP transport does. + logger.warn("Skipped an ACP WebSocket frame that is not a JSON-RPC message", e); + remoteConnection.signalException(e); + sendToClient(AcpSchema.unreadableMessageResponse(jsonMapper, text)); + return; + } + acceptFromClient(message); + } + + private boolean exceedsLimit(String text) { + long max = options.maxPostBodyBytes(); + // A char is at most three UTF-8 bytes: count them only when it could matter. + return text.length() > max || (text.length() * 3L > max && text.getBytes(StandardCharsets.UTF_8).length > max); + } + + private void acceptFromClient(JSONRPCMessage message) { + if (!initialized.get()) { + // No POST initialize created this connection, so the first client-originated + // JSON-RPC message on the socket must be initialize. + if (!StreamableHttpRouting.isInitializeRequest(message)) { + close(PROTOCOL_ERROR, "first ACP WebSocket message must be initialize"); + return; + } + initialized.set(true); + Disposable deadline = this.initializeDeadline; + if (deadline != null) { + deadline.dispose(); + } + } + else if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { + sendToClient(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, + new AcpSchema.JSONRPCError(AcpErrorCodes.INVALID_REQUEST, + "Initialize not allowed on existing connection", null))); + return; + } + try { + remoteConnection.acceptInbound(message); + } + catch (AcpConnectionException e) { + logger.debug("ACP WebSocket connection {} dropped a message while closing: {}", id, e.getMessage()); + } + } + + @Override + public void onClose(int code, @Nullable String reason) { + logger.debug("ACP WebSocket connection {} closed: {} {}", id, code, reason); + closeLocally(); + } + + @Override + public void onError(Throwable error) { + if (closed.get()) { + logger.debug("ACP WebSocket connection {} failed after closing: {}", id, error.toString()); + return; + } + remoteConnection.signalException(error); + close(SERVER_ERROR, "WebSocket error"); + } + + void sendToClient(JSONRPCMessage message) { + try { + send(jsonMapper.writeValueAsString(message)); + } + catch (Exception e) { + if (!closed.get()) { + remoteConnection.signalException(e); + close(SERVER_ERROR, "failed to send ACP message"); + } + } + } + + private void send(String payload) { + sendLock.lock(); + try { + if (closed.get()) { + throw new AcpConnectionException("ACP WebSocket connection is closed"); + } + if (queue.size() >= options.maxWebSocketPendingFrames()) { + throw new AcpConnectionException( + "WebSocket send queue exceeded " + options.maxWebSocketPendingFrames() + " pending frames"); + } + queue.addLast(payload); + if (sendInProgress) { + return; + } + sendInProgress = true; + } + finally { + sendLock.unlock(); + } + drain(); + } + + /** + * Hands the host one frame at a time, the next only once the previous send completed: agent + * messages come from concurrent handlers, and a container may allow one outstanding write + * (Tomcat fails a second one). A send the host completes at once continues in this loop + * rather than recursing. + */ + private void drain() { + String payload = nextPayload(); + while (payload != null && sendCompletedAtOnce(payload)) { + payload = nextPayload(); + } + } + + /** The next frame to send, or null (and no send in progress) when there is none. */ + private @Nullable String nextPayload() { + sendLock.lock(); + try { + if (closed.get()) { + queue.clear(); + sendInProgress = false; + return null; + } + String payload = queue.pollFirst(); + if (payload == null) { + sendInProgress = false; + } + return payload; + } + finally { + sendLock.unlock(); + } + } + + /** + * Hands the host one frame. The callback handles the send's failure itself, so the stage + * {@code whenComplete} returns is not needed. + * @return true when the send completed at once, so the caller continues with the next frame; + * false when the completion continues the drain, or the send failed + */ + @SuppressWarnings("FutureReturnValueIgnored") + private boolean sendCompletedAtOnce(String payload) { + logger.debug("ACP WebSocket connection {} sends {} characters", id, payload.length()); + recordActivity(); + CompletableFuture sent; + try { + sent = outbound.sendText(payload).toCompletableFuture(); + } + catch (RuntimeException e) { + sendFailed(e); + return false; + } + if (sent.isDone() && !sent.isCompletedExceptionally()) { + return true; + } + sent.whenComplete((ignored, error) -> { + if (error != null) { + sendFailed(error); + } + else { + drain(); + } + }); + return false; + } + + private void sendFailed(Throwable error) { + if (!closed.get()) { + remoteConnection.signalException(error); + close(SERVER_ERROR, "failed to send ACP message"); + } + } + + /** + * Closes the socket with a going-away frame and the agent gracefully: the endpoint shuts + * down. Completes once both have closed; the endpoint bounds the wait. + */ + Mono closeForShutdown() { + return closeGracefully(GOING_AWAY, "server shutting down"); + } + + private Mono closeGracefully(int code, String reason) { + if (!closed.compareAndSet(false, true)) { + return Mono.empty(); + } + deregister.accept(this); + cancelTimers(); + clearQueue(); + Mono socketClosed; + try { + // Waited for: a container may send the close frame asynchronously, and one stopped + // before it has gone out drops the connection without it. + socketClosed = Mono.fromCompletionStage(outbound.close(code, reason)); + } + catch (RuntimeException e) { + socketClosed = Mono.error(e); + } + return Mono.when(socketClosed.onErrorResume(error -> { + logger.debug("Closing ACP WebSocket connection {} failed: {}", id, error.toString()); + return Mono.empty(); + }), remoteConnection.closeGracefully()); + } + + void close(int code, String reason) { + closeGracefully(code, reason).subscribe(v -> { + }, error -> logger.warn("Error closing ACP WebSocket connection {}", id, error)); + } + + /** The socket is already closed: close the agent, send nothing. */ + private void closeLocally() { + if (!closed.compareAndSet(false, true)) { + return; + } + deregister.accept(this); + cancelTimers(); + clearQueue(); + remoteConnection.closeGracefully() + .subscribe(v -> { + }, error -> logger.warn("Error closing ACP WebSocket connection {}", id, error)); + } + + /** Closes the agent at once, also after a graceful close that has not finished. */ + void closeNow() { + close(GOING_AWAY, "server shutting down"); + remoteConnection.close(); + } + + private void clearQueue() { + sendLock.lock(); + try { + queue.clear(); + } + finally { + sendLock.unlock(); + } + } + +} diff --git a/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/package-info.java b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/package-info.java new file mode 100644 index 00000000..e3f94ed0 --- /dev/null +++ b/acp-http-core/src/main/java/com/agentclientprotocol/sdk/http/server/package-info.java @@ -0,0 +1,30 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The ACP host contract: the one framework-neutral ACP endpoint ({@link AcpHttpEndpoint}) and + * the types a server adapter (a host) uses to mount it in its framework's router and lifecycle. + * + *

    Every protocol rule lives behind {@link AcpHttpEndpoint}: method and header checks, the + * status and WebSocket close-code table, connection and session routing, the bounded SSE + * mailboxes and WebSocket send queue, the {@code Origin} check, keep-alive and shutdown. A host + * only adapts I/O: it turns its framework's request into an {@link AcpHttpExchange}, writes the + * {@link AcpHttpReply} it gets back, and carries WebSocket frames between its framework's + * socket and an {@link AcpWsHandler}. Hosts never decide a status code, a header or a close + * code themselves, and every host passes the shared transport TCK in {@code acp-test}. + * + *

    Applications use the hosts the SDK ships (the servlet, the WebFlux route, the embedded listener, the Spring + * Boot, Quarkus and Micronaut integrations); this package is for writing a host, and is + * {@link com.agentclientprotocol.sdk.annotation.UnstableAcpApi unstable}: it may change in a + * minor release while the set of hosts grows. + * + *

    The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + */ +@NullMarked +@UnstableAcpApi +package com.agentclientprotocol.sdk.http.server; + +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import org.jspecify.annotations.NullMarked; diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicyTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicyTest.java new file mode 100644 index 00000000..b94292c1 --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginPolicyTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The {@code Origin} rule every host applies, and the allowed-origins option that widens it. + */ +class OriginPolicyTest { + + private static final StreamableHttpAcpAgentTransportOptions DEFAULTS = StreamableHttpAcpAgentTransportOptions + .defaults(); + + @ParameterizedTest + @ValueSource(strings = { "http://localhost", "http://localhost:3000", "https://localhost:8443", "HTTP://LOCALHOST", + "http://127.0.0.1", "http://127.0.0.1:1", "http://[::1]", "https://[::1]:8443", "http://localhost/" }) + void loopbackOriginsAreAllowed(String origin) { + assertThat(DEFAULTS.isOriginAllowed(origin)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "http://evil.example", "null", "file://", "http://localhost.evil.example", + "http://127.0.0.1.evil.example", "ws://localhost", "http://user@localhost", "http://localhost/path", + "http://127.0.0.2", "not a uri", "" }) + void otherOriginsAreRefused(String origin) { + assertThat(DEFAULTS.isOriginAllowed(origin)).isFalse(); + } + + @Test + void noOriginIsAllowed() { + assertThat(DEFAULTS.isOriginAllowed(null)).isTrue(); + } + + @Test + void listedOriginsAreAllowedIgnoringCaseAndATrailingSlash() { + StreamableHttpAcpAgentTransportOptions options = StreamableHttpAcpAgentTransportOptions.builder() + .allowedOrigins(List.of("https://App.Example.com/")) + .build(); + assertThat(options.allowedOrigins()).containsExactly("https://app.example.com"); + assertThat(options.isOriginAllowed("https://app.example.com")).isTrue(); + assertThat(options.isOriginAllowed("HTTPS://APP.EXAMPLE.COM")).isTrue(); + assertThat(options.isOriginAllowed("https://app.example.com:8443")).isFalse(); + assertThat(options.isOriginAllowed("http://evil.example")).isFalse(); + } + + @Test + void aWildcardAllowsAnyOrigin() { + StreamableHttpAcpAgentTransportOptions options = StreamableHttpAcpAgentTransportOptions.builder() + .allowedOrigins(Set.of("*")) + .build(); + assertThat(options.isOriginAllowed("http://evil.example")).isTrue(); + assertThat(options.isOriginAllowed("null")).isTrue(); + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptionsTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptionsTest.java new file mode 100644 index 00000000..51811551 --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptionsTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.function.UnaryOperator; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** Every option the builder sets reaches the options, and every limit is checked. */ +class StreamableHttpAcpAgentTransportOptionsTest { + + @Test + void theBuilderSetsEveryOption() { + Executor executor = Runnable::run; + StreamableHttpAcpAgentTransportOptions options = StreamableHttpAcpAgentTransportOptions.builder() + .maxPostBodyBytes(1) + .mailboxCapacity(2) + .maxPendingSseEvents(3) + .maxWebSocketPendingFrames(4) + .maxProvisionalSessions(5) + .keepAliveInterval(Duration.ZERO) + .maxConcurrentStreamsPerConnection(6) + .shutdownTimeout(Duration.ofSeconds(7)) + .virtualThreads(true) + .executor(executor) + .host("0.0.0.0") + .allowedOrigins(List.of("https://allowed.example")) + .build(); + assertThat(options.maxPostBodyBytes()).isEqualTo(1); + assertThat(options.mailboxCapacity()).isEqualTo(2); + assertThat(options.maxPendingSseEvents()).isEqualTo(3); + assertThat(options.maxWebSocketPendingFrames()).isEqualTo(4); + assertThat(options.maxProvisionalSessions()).isEqualTo(5); + assertThat(options.keepAliveInterval()).isZero(); + assertThat(options.maxConcurrentStreamsPerConnection()).isEqualTo(6); + assertThat(options.shutdownTimeout()).isEqualTo(Duration.ofSeconds(7)); + assertThat(options.executor()).isSameAs(executor); + assertThat(options.virtualThreads()).isTrue(); + assertThat(options.host()).isEqualTo("0.0.0.0"); + assertThat(options.isOriginAllowed("https://allowed.example")).isTrue(); + } + + @Test + void everyLimitIsChecked() { + List> invalid = List.of( + builder -> builder.maxPostBodyBytes(0), builder -> builder.mailboxCapacity(0), + builder -> builder.maxPendingSseEvents(0), builder -> builder.maxWebSocketPendingFrames(0), + builder -> builder.maxProvisionalSessions(0), builder -> builder.keepAliveInterval(Duration.ofSeconds(-1)), + builder -> builder.maxConcurrentStreamsPerConnection(0), + builder -> builder.shutdownTimeout(Duration.ZERO), + builder -> builder.shutdownTimeout(Duration.ofSeconds(-1)), + builder -> builder.executor(Runnable::run).virtualThreads(false), builder -> builder.host(" ")); + for (UnaryOperator change : invalid) { + assertThatIllegalArgumentException() + .isThrownBy(() -> change.apply(StreamableHttpAcpAgentTransportOptions.builder()).build()); + } + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpointTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpointTest.java new file mode 100644 index 00000000..d1bddf8e --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpEndpointTest.java @@ -0,0 +1,451 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The endpoint driven in process, as a host would, with fake exchanges, SSE subscribers and + * WebSocket outbounds: the status table, the routing of a session's messages, the WebSocket + * rules and the drain. Every host also runs the transport TCK over a real server. + */ +class AcpHttpEndpointTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"""; + + private final AtomicInteger sessions = new AtomicInteger(); + + private final AcpAgentFactory agents = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler( + request -> Mono.just(new AcpSchema.NewSessionResponse("s-" + sessions.incrementAndGet(), null, null))) + .promptHandler((request, context) -> context.sendMessage("hello").thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + + private final AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), agents, + StreamableHttpAcpAgentTransportOptions.builder() + .maxPostBodyBytes(1024) + .keepAliveInterval(Duration.ofMillis(50)) + .shutdownTimeout(Duration.ofSeconds(2)) + .build()); + + @AfterEach + void close() { + endpoint.closeGracefully().block(TIMEOUT); + } + + @Test + void theStatusTable() { + assertThat(status(post("text/plain", INITIALIZE))).isEqualTo(415); + assertThat(status(post("application/json", "x".repeat(1025)))).isEqualTo(413); + assertThat(status(post("application/json", INITIALIZE).header("Content-Length", "5000"))).isEqualTo(413); + assertThat(status(post("application/json", "[" + INITIALIZE + "]"))).isEqualTo(501); + assertThat(status(post("application/json", "not json"))).isEqualTo(400); + assertThat(status(post("application/json", "{\"jsonrpc\":\"2.0\",\"method\":\"x\"}"))).isEqualTo(400); + assertThat(status(post("application/json", "{\"jsonrpc\":\"2.0\",\"method\":\"x\"}").header("Acp-Connection-Id", + "nope"))) + .isEqualTo(404); + assertThat(status(post("application/json", INITIALIZE).header("Acp-Connection-Id", "any"))).isEqualTo(400); + assertThat(status(new FakeExchange("GET").header("Accept", "application/json"))).isEqualTo(406); + assertThat(status(new FakeExchange("GET").header("Accept", "text/event-stream"))).isEqualTo(400); + assertThat(status(new FakeExchange("DELETE").header("Acp-Connection-Id", "nope"))).isEqualTo(404); + AcpHttpReply put = endpoint.handle(new FakeExchange("PUT")).block(TIMEOUT); + assertThat(put.status()).isEqualTo(405); + assertThat(put.headers()).containsEntry("Allow", "GET, POST, DELETE"); + assertThat(status(post("application/json", INITIALIZE).header("Origin", "http://evil.example"))).isEqualTo(403); + } + + @Test + void aSessionRoundTripsOverPostAndSse() { + endpoint.start(); + endpoint.start(); + AcpHttpReply.Body initialized = (AcpHttpReply.Body) endpoint.handle(post("application/json", INITIALIZE)) + .block(TIMEOUT); + assertThat(initialized.status()).isEqualTo(200); + assertThat(initialized.contentType()).isEqualTo("application/json"); + String connection = initialized.headers().get("Acp-Connection-Id"); + assertThat(endpoint.activeConnectionCount()).isEqualTo(1); + + AcpHttpReply.EventStream stream = (AcpHttpReply.EventStream) endpoint + .handle(new FakeExchange("GET").header("Accept", "text/event-stream").header("Acp-Connection-Id", connection)) + .block(TIMEOUT); + assertThat(stream.headers()).containsEntry("Cache-Control", "no-cache") + .containsEntry("X-Accel-Buffering", "no") + .containsEntry("Acp-Connection-Id", connection); + Frames frames = new Frames(); + stream.frames().subscribe(frames); + + assertThat(status(post("application/json", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/new\",\"params\":{\"cwd\":\"/\",\"mcpServers\":[]}}") + .header("Acp-Connection-Id", connection))).isEqualTo(202); + awaitTrue(() -> frames.events().stream().anyMatch(event -> event.contains("s-1"))); + + // A JSON object that is no valid request is answered -32600 on the connection stream. + assertThat(status(post("application/json", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":7}").header("Acp-Connection-Id", + connection))).isEqualTo(202); + awaitTrue(() -> frames.events().stream().anyMatch(event -> event.contains("-32600"))); + awaitTrue(() -> frames.comments().contains("keep-alive")); + + assertThat(status(new FakeExchange("DELETE").header("Acp-Connection-Id", connection))).isEqualTo(202); + awaitTrue(() -> frames.completed); + assertThat(endpoint.activeConnectionCount()).isZero(); + } + + @Test + void anInitializeTheAgentFailsIsAnswered500() { + AcpHttpEndpoint failing = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), transport -> { + throw new IllegalStateException("no agent"); + }, StreamableHttpAcpAgentTransportOptions.defaults()); + List errors = new CopyOnWriteArrayList<>(); + failing.setExceptionHandler(errors::add); + assertThat(failing.handle(post("application/json", INITIALIZE)).block(TIMEOUT).status()).isEqualTo(500); + assertThat(errors).isNotEmpty(); + } + + @Test + void aWebSocketMustOpenWithInitializeAndStayUnderTheLimit() { + FakeSocket socket = open(); + socket.handler.onText("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"session/new\",\"params\":{}}"); + assertThat(socket.closeCode.get()).isEqualTo(1002); + + FakeSocket big = open(); + big.handler.onText(INITIALIZE); + awaitTrue(() -> big.sent.size() == 1); + big.handler.onText("é".repeat(600)); + assertThat(big.closeCode.get()).isEqualTo(1009); + } + + @Test + void aWebSocketAnswersAndRefusesASecondInitialize() { + FakeSocket socket = open(); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + assertThat(socket.sent.get(0)).contains("\"result\""); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 2); + assertThat(socket.sent.get(1)).contains("-32600"); + socket.handler.onText("not json"); + awaitTrue(() -> socket.sent.size() == 3); + socket.handler.onClose(1000, "bye"); + awaitTrue(() -> endpoint.activeConnectionCount() == 0); + } + + /** + * An invalid request on the socket is the client's payload and can carry the user's + * prompt: it is answered -32600, and no log at INFO or above, the endpoint's default + * exception handler's included, quotes it. + */ + @Test + void anInvalidRequestOnAWebSocketIsNotLoggedAboveDebug() { + Logger sdkLogger = (Logger) LoggerFactory.getLogger("com.agentclientprotocol.sdk"); + ListAppender logs = new ListAppender<>(); + logs.start(); + sdkLogger.addAppender(logs); + try { + FakeSocket socket = open(); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + socket.handler.onText("{\"id\":2,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s\"," + + "\"prompt\":[{\"type\":\"text\",\"text\":\"SECRET-123\"}]}}"); + awaitTrue(() -> socket.sent.size() == 2); + assertThat(socket.sent.get(1)).contains("-32600"); + } + finally { + sdkLogger.detachAppender(logs); + logs.stop(); + } + + assertThat(logs.list).as("the refusal is logged").isNotEmpty(); + assertThat(logs.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.INFO)) + .allSatisfy(event -> { + assertThat(event.getFormattedMessage()).doesNotContain("SECRET-123"); + for (IThrowableProxy proxy = event.getThrowableProxy(); proxy != null; proxy = proxy.getCause()) { + assertThat(String.valueOf(proxy.getMessage())).doesNotContain("SECRET-123"); + } + }); + } + + @Test + void aFailedSendClosesTheSocketWith1011() { + FakeSocket socket = open(); + socket.failSends = true; + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.closeCode.get() != null); + assertThat(socket.closeCode.get()).isEqualTo(1011); + socket.handler.onError(new IllegalStateException("late")); + } + + @Test + void aSocketErrorClosesTheConnection() { + FakeSocket socket = open(); + socket.handler.onError(new IllegalStateException("corrupt frame")); + assertThat(socket.closeCode.get()).isEqualTo(1011); + } + + @Test + void aForeignOriginHandshakeIsRefused() { + AcpWsHandshake handshake = endpoint + .webSocketHandshake(new FakeExchange("GET").header("Upgrade", "websocket").header("Origin", "http://evil.example")); + assertThat(handshake).isInstanceOfSatisfying(AcpWsHandshake.Refused.class, + refused -> assertThat(refused.reply().status()).isEqualTo(403)); + } + + @Test + void drainingClosesStreamsAndSocketsAndRefusesNewOnes() { + AcpHttpReply.Body initialized = (AcpHttpReply.Body) endpoint.handle(post("application/json", INITIALIZE)) + .block(TIMEOUT); + String connection = initialized.headers().get("Acp-Connection-Id"); + AcpHttpReply.EventStream stream = (AcpHttpReply.EventStream) endpoint + .handle(new FakeExchange("GET").header("Accept", "text/event-stream").header("Acp-Connection-Id", connection)) + .block(TIMEOUT); + Frames frames = new Frames(); + stream.frames().subscribe(frames); + FakeSocket socket = open(); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + endpoint.closeGracefully().block(TIMEOUT); + + assertThat(frames.comments()).contains("shutting down"); + assertThat(frames.completed).isTrue(); + assertThat(socket.closeCode.get()).isEqualTo(1001); + assertThat(status(post("application/json", INITIALIZE))).isEqualTo(503); + assertThat(endpoint.webSocketHandshake(new FakeExchange("GET").header("Upgrade", "websocket"))) + .isInstanceOf(AcpWsHandshake.Refused.class); + } + + /** + * A container may send the close frame asynchronously, and one stopped before it has gone + * out drops the connection without it: the drain waits for the socket's close. + */ + @Test + void drainingWaitsForTheSocketsClose() throws Exception { + FakeSocket socket = open(); + socket.closeStage = new CompletableFuture<>(); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + CompletableFuture drained = endpoint.closeGracefully().toFuture(); + awaitTrue(() -> socket.closeCode.get() != null); + Thread.sleep(200); + assertThat(drained).isNotDone(); + + socket.closeStage.complete(null); + drained.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(socket.closeCode.get()).isEqualTo(1001); + } + + @Test + void drainingWaitsForASocketsCloseNoLongerThanTheShutdownTimeout() { + FakeSocket socket = open(); + socket.closeStage = new CompletableFuture<>(); + socket.handler.onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + long started = System.nanoTime(); + endpoint.closeGracefully().block(TIMEOUT); + + // The shutdown timeout is 2 seconds. + assertThat(Duration.ofNanos(System.nanoTime() - started)).isLessThan(Duration.ofSeconds(4)); + } + + @Test + void theExchangeRecognisesAnUpgrade() { + assertThat(new FakeExchange("GET").header("Upgrade", " WebSocket ").isWebSocketUpgrade()).isTrue(); + assertThat(new FakeExchange("GET").isWebSocketUpgrade()).isFalse(); + assertThat(SseFrame.comment("x").encode()).isEqualTo(": x\n\n".getBytes(StandardCharsets.UTF_8)); + assertThat(SseFrame.event("{}").encode()).isEqualTo("data: {}\n\n".getBytes(StandardCharsets.UTF_8)); + } + + private static void awaitTrue(java.util.function.BooleanSupplier condition) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met within " + TIMEOUT); + } + java.util.concurrent.locks.LockSupport.parkNanos(10_000_000); + } + } + + private FakeSocket open() { + AcpWsHandshake handshake = endpoint.webSocketHandshake(new FakeExchange("GET").header("Upgrade", "websocket")); + AcpWsHandshake.Accepted accepted = (AcpWsHandshake.Accepted) handshake; + assertThat(accepted.headers()).containsKey("Acp-Connection-Id"); + assertThat(accepted.maxTextMessageBytes()).isEqualTo(1024); + assertThat(accepted.idleTimeout()).isPositive(); + FakeSocket socket = new FakeSocket(); + socket.handler = accepted.open(socket); + return socket; + } + + private int status(FakeExchange exchange) { + return endpoint.handle(exchange).block(TIMEOUT).status(); + } + + private static FakeExchange post(String contentType, String body) { + return new FakeExchange("POST").header("Content-Type", contentType).body(body); + } + + private static final class FakeExchange implements AcpHttpExchange { + + private final String method; + + private final Map headers = new HashMap<>(); + + private byte[] body = new byte[0]; + + FakeExchange(String method) { + this.method = method; + } + + FakeExchange header(String name, String value) { + headers.put(name.toLowerCase(Locale.ROOT), value); + return this; + } + + FakeExchange body(String text) { + this.body = text.getBytes(StandardCharsets.UTF_8); + return this; + } + + @Override + public String method() { + return method; + } + + @Override + public @Nullable String header(String name) { + return headers.get(name.toLowerCase(Locale.ROOT)); + } + + @Override + public Mono body(long maxBytes) { + return Mono.just(body); + } + + @Override + public @Nullable Principal principal() { + return null; + } + + } + + /** A host that writes every frame at once and asks for the next. */ + private static final class Frames implements CoreSubscriber { + + private final List received = new CopyOnWriteArrayList<>(); + + private volatile @Nullable Subscription subscription; + + private volatile boolean completed; + + @Override + public void onSubscribe(Subscription s) { + this.subscription = s; + s.request(1); + } + + @Override + public void onNext(SseFrame frame) { + received.add(frame); + Subscription current = subscription; + if (current != null) { + current.request(1); + } + } + + @Override + public void onError(Throwable error) { + completed = true; + } + + @Override + public void onComplete() { + completed = true; + } + + List events() { + List events = new ArrayList<>(); + received.stream().filter(frame -> !frame.comment()).forEach(frame -> events.add(frame.data())); + return events; + } + + List comments() { + List comments = new ArrayList<>(); + received.stream().filter(SseFrame::comment).forEach(frame -> comments.add(frame.data())); + return comments; + } + + } + + private static final class FakeSocket implements AcpWsOutbound { + + final List sent = new CopyOnWriteArrayList<>(); + + final AtomicReference<@Nullable Integer> closeCode = new AtomicReference<>(); + + volatile boolean failSends; + + /** What {@link #close} returns: the socket's close, completed at once unless a test holds it. */ + volatile CompletableFuture closeStage = CompletableFuture.completedFuture(null); + + AcpWsHandler handler; + + @Override + public CompletionStage sendText(String text) { + if (failSends) { + return CompletableFuture.failedFuture(new IllegalStateException("broken pipe")); + } + sent.add(text); + // Completes later, as a container does, so the next frame waits for it. + return CompletableFuture.runAsync(() -> { + }); + } + + @Override + public CompletionStage close(int code, String reason) { + closeCode.compareAndSet(null, code); + return closeStage; + } + + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpSessionRoutingTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpSessionRoutingTest.java new file mode 100644 index 00000000..03ae8fa8 --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/AcpHttpSessionRoutingTest.java @@ -0,0 +1,328 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.LockSupport; +import java.util.function.BooleanSupplier; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A session's traffic over the endpoint, driven in process as a host would: the session stream a + * client opens with {@code Acp-Session-Id}, the agent's requests to the client routed to it, the + * client's responses checked against the session they belong to, {@code session/load} and the + * header rules of session-bound methods. The transport TCK covers the same on a real server, but + * it runs in the host modules, so it does not count toward this module's coverage. + */ +class AcpHttpSessionRoutingTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"""; + + private static final AcpJsonMapper JSON = AcpJsonMapper.createDefault(); + + private final AtomicInteger sessions = new AtomicInteger(); + + private final List outcomes = new CopyOnWriteArrayList<>(); + + private final AcpAgentFactory agents = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler( + request -> Mono.just(new AcpSchema.NewSessionResponse("s-" + sessions.incrementAndGet(), null, null))) + .loadSessionHandler(request -> request.sessionId().equals("missing") + ? Mono.error(new IllegalArgumentException("no session missing")) + : Mono.just(new AcpSchema.LoadSessionResponse(null))) + .promptHandler((request, context) -> context.sendMessage("hello") + .then(context.client() + .requestPermission(new AcpSchema.RequestPermissionRequest(request.sessionId(), + new AcpSchema.ToolCallUpdate("call-1", "Edit file", AcpSchema.ToolKind.EDIT, + AcpSchema.ToolCallStatus.PENDING), + List.of(new AcpSchema.PermissionOption("allow", "Allow", + AcpSchema.PermissionOptionKind.ALLOW_ONCE))))) + .doOnNext(response -> outcomes.add(response.outcome().getClass().getSimpleName())) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + + private final AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(JSON, agents, + StreamableHttpAcpAgentTransportOptions.builder() + .shutdownTimeout(Duration.ofSeconds(2)) + .maxProvisionalSessions(2) + .build()); + + private String connection; + + private Frames connectionStream; + + @BeforeEach + void initialize() { + AcpHttpReply.Body initialized = (AcpHttpReply.Body) endpoint.handle(post(INITIALIZE)).block(TIMEOUT); + assertThat(initialized.status()).isEqualTo(200); + connection = initialized.headers().get("Acp-Connection-Id"); + connectionStream = open(null); + } + + @AfterEach + void close() { + endpoint.closeGracefully().block(TIMEOUT); + } + + @Test + void theAgentsRequestsGoOnTheSessionStreamAndTheirResponsesMustNameThatSession() { + String session = newSession(2); + Frames sessionStream = open(session); + + assertThat(status(post(prompt(3, session)).header("Acp-Session-Id", session))).isEqualTo(202); + Object permission = awaitRequest(sessionStream, "session/request_permission"); + assertThat(sessionStream.events()).anyMatch(event -> event.contains("hello")); + + // A response under another session's header is refused; under the right one it is taken. + assertThat(status(post(cancelled(permission)).header("Acp-Session-Id", "s-other"))).isEqualTo(400); + assertThat(status(post(cancelled(permission)).header("Acp-Session-Id", session))).isEqualTo(202); + awaitTrue(() -> sessionStream.events().stream().anyMatch(event -> event.contains("end_turn"))); + + // A response without the header is taken for the session that asked. + assertThat(status(post(prompt(4, session)).header("Acp-Session-Id", session))).isEqualTo(202); + Object second = awaitRequest(sessionStream, "session/request_permission", permission); + assertThat(status(post(cancelled(second)))).isEqualTo(202); + awaitTrue(() -> outcomes.size() == 2); + + // A response to no request of the agent's is dropped. + assertThat(status(post(cancelled("no-such-request")).header("Acp-Session-Id", session))).isEqualTo(202); + // A notification of the client's reaches the agent on the session. + assertThat(status(post("{\"jsonrpc\":\"2.0\",\"method\":\"session/cancel\",\"params\":{\"sessionId\":\"" + session + + "\"}}") + .header("Acp-Session-Id", session))).isEqualTo(202); + assertThat(outcomes).containsOnly("PermissionCancelled"); + } + + @Test + void aSessionBoundMethodNeedsAHeaderThatNamesItsSession() { + String session = newSession(2); + assertThat(status(post(prompt(3, session)))).isEqualTo(400); + assertThat(status(post(prompt(4, session)).header("Acp-Session-Id", "s-other"))).isEqualTo(400); + } + + /** + * A stream for a session the connection does not know yet is provisional (a client resuming + * opens it before {@code session/load}), up to the bound; beyond it, 404. + */ + @Test + void sessionStreamsForUnknownSessionsAreBounded() { + open("s-unknown-1"); + open("s-unknown-2"); + assertThat(endpoint.handle(get().header("Acp-Session-Id", "s-unknown-3")).block(TIMEOUT).status()) + .isEqualTo(404); + } + + @Test + void sessionLoadAnswersOnTheConnectionStream() { + assertThat(status(post(load(5, "s-loaded")))).isEqualTo(202); + awaitTrue(() -> connectionStream.events() + .stream() + .anyMatch(event -> event.contains("\"id\":5") && event.contains("\"result\""))); + open("s-loaded"); + + // A load the agent fails is answered with its error, and leaves no session behind. + assertThat(status(post(load(6, "missing")))).isEqualTo(202); + awaitTrue(() -> connectionStream.events() + .stream() + .anyMatch(event -> event.contains("\"id\":6") && event.contains("\"error\""))); + } + + @Test + void anUnreadableContentLengthIsLeftToTheBodyLimit() { + assertThat(status(post(prompt(3, "s-x")).header("Content-Length", "many"))).isEqualTo(400); + } + + private String newSession(int id) { + assertThat(status(post("{\"jsonrpc\":\"2.0\",\"id\":" + id + + ",\"method\":\"session/new\",\"params\":{\"cwd\":\"/\",\"mcpServers\":[]}}"))) + .isEqualTo(202); + String session = "s-" + sessions.get(); + awaitTrue(() -> connectionStream.events().stream().anyMatch(event -> event.contains(session))); + return session; + } + + private Frames open(@Nullable String session) { + FakeExchange get = get(); + if (session != null) { + get.header("Acp-Session-Id", session); + } + AcpHttpReply reply = endpoint.handle(get).block(TIMEOUT); + assertThat(reply).isInstanceOf(AcpHttpReply.EventStream.class); + AcpHttpReply.EventStream stream = (AcpHttpReply.EventStream) reply; + if (session != null) { + assertThat(stream.headers()).containsEntry("Acp-Session-Id", session); + } + Frames frames = new Frames(); + stream.frames().subscribe(frames); + return frames; + } + + /** Waits for a request of the agent's with the given method, other than {@code skip}, and returns its id. */ + private static Object awaitRequest(Frames frames, String method, Object... skip) { + awaitTrue(() -> request(frames, method, skip).isPresent()); + return request(frames, method, skip).orElseThrow(); + } + + private static Optional request(Frames frames, String method, Object... skip) { + return frames.events() + .stream() + .map(AcpHttpSessionRoutingTest::parse) + .filter(message -> method.equals(message.get("method")) && message.get("id") != null) + .map(message -> message.get("id")) + .filter(id -> !List.of(skip).contains(id)) + .findFirst(); + } + + private static Map parse(String json) { + try { + Map message = JSON.readValue(json, Map.class); + return (message != null) ? message : Map.of(); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static String prompt(int id, String session) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"" + + session + "\",\"prompt\":[{\"type\":\"text\",\"text\":\"hi\"}]}}"; + } + + private static String load(int id, String session) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"session/load\",\"params\":{\"sessionId\":\"" + + session + "\",\"cwd\":\"/\",\"mcpServers\":[]}}"; + } + + private static String cancelled(Object id) { + String json = (id instanceof String text) ? "\"" + text + "\"" : String.valueOf(id); + return "{\"jsonrpc\":\"2.0\",\"id\":" + json + ",\"result\":{\"outcome\":{\"outcome\":\"cancelled\"}}}"; + } + + private int status(FakeExchange exchange) { + return endpoint.handle(exchange).block(TIMEOUT).status(); + } + + private FakeExchange post(String body) { + FakeExchange post = new FakeExchange("POST").header("Content-Type", "application/json").body(body); + return body.equals(INITIALIZE) ? post : post.header("Acp-Connection-Id", connection); + } + + private FakeExchange get() { + return new FakeExchange("GET").header("Accept", "text/event-stream").header("Acp-Connection-Id", connection); + } + + private static void awaitTrue(BooleanSupplier condition) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met within " + TIMEOUT); + } + LockSupport.parkNanos(10_000_000); + } + } + + private static final class FakeExchange implements AcpHttpExchange { + + private final String method; + + private final Map headers = new HashMap<>(); + + private byte[] body = new byte[0]; + + FakeExchange(String method) { + this.method = method; + } + + FakeExchange header(String name, String value) { + headers.put(name.toLowerCase(Locale.ROOT), value); + return this; + } + + FakeExchange body(String text) { + this.body = text.getBytes(StandardCharsets.UTF_8); + return this; + } + + @Override + public String method() { + return method; + } + + @Override + public @Nullable String header(String name) { + return headers.get(name.toLowerCase(Locale.ROOT)); + } + + @Override + public Mono body(long maxBytes) { + return Mono.just(body); + } + + @Override + public @Nullable Principal principal() { + return null; + } + + } + + /** A host that writes every frame at once and asks for the next. */ + private static final class Frames implements CoreSubscriber { + + private final List received = new CopyOnWriteArrayList<>(); + + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(SseFrame frame) { + received.add(frame); + } + + @Override + public void onError(Throwable error) { + } + + @Override + public void onComplete() { + } + + List events() { + return received.stream().filter(frame -> !frame.comment()).map(SseFrame::data).toList(); + } + + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/HttpCoreArchitectureTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/HttpCoreArchitectureTest.java new file mode 100644 index 00000000..c235555b --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/HttpCoreArchitectureTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * The core is framework-neutral: it depends on no server, servlet, WebSocket or framework API, + * so every host (servlet, Vert.x, WebFlux) can mount it; and the host contract is public while + * the protocol implementation is not. + */ +@AnalyzeClasses(packages = { "com.agentclientprotocol.sdk.http.server", "com.agentclientprotocol.sdk.agent.transport" }, + importOptions = ImportOption.DoNotIncludeTests.class) +class HttpCoreArchitectureTest { + + @ArchTest + static final ArchRule isServerNeutral = noClasses().that() + .resideInAPackage("com.agentclientprotocol.sdk.http.server..") + .should() + .dependOnClassesThat(resideInAnyPackage("jakarta..", "javax.servlet..", "org.eclipse.jetty..", "org.apache..", + "io.undertow..", "io.vertx..", "io.netty..", "org.springframework..", "io.quarkus..", "io.micronaut..")) + .because("all protocol semantics live in one framework-neutral core; hosts adapt I/O"); + + @ArchTest + static final ArchRule protocolClassesAreNotPublic = classes().that() + .resideInAPackage("com.agentclientprotocol.sdk.http.server") + .and() + .haveSimpleNameStartingWith("StreamableHttp") + .or() + .haveSimpleNameContaining("SseOutboundStream") + .or() + .haveSimpleNameContaining("WebSocketConnection") + .or() + .haveSimpleNameStartingWith("Default") + .should() + .notBePublic() + .because("a host depends only on the contract: routing, sessions, mailboxes and connections are the core's"); + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamLincheckTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamLincheckTest.java new file mode 100644 index 00000000..429eeabd --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamLincheckTest.java @@ -0,0 +1,413 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import org.jetbrains.lincheck.datastructures.BooleanGen; +import org.jetbrains.lincheck.datastructures.IntGen; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.Validate; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscription; +import org.slf4j.LoggerFactory; +import reactor.core.CoreSubscriber; + +/** + * Model checks the SSE mailbox of {@link SseOutboundStream} with Lincheck: the agent pushes + * events while clients attach (a new GET takes the stream over), the container completes or + * fails pending writes on its own threads, and clients reset their streams, in any + * interleaving. + * + *

    + * The host side is a fake host that follows the host contract: it asks for the next frame only + * once the previous write has completed. Its writes either complete at once or stay pending + * until the container completes them. A reset stream behaves like Jetty's HTTP/2 output under + * the servlet host: a write is accepted and silently discarded, and the host learns of the + * reset only on its next write (the servlet host's empty-write check), when it cancels instead + * of asking for more. + *

    + * + *

    + * The invariant, checked after every interleaving once pending writes have settled and a + * healthy client has attached: every pushed event reached a client; the first deliveries are + * in push order; and an event arrived twice only if it was written to a subscriber that was + * detached before the write was confirmed (the documented pending-write takeover case). + *

    + * + *

    + * A second model races GETs attaching against the stream closing (a DELETE of the + * connection): once closed, no GET may hold an open response, whether it attached before the + * close, while a write was pending, or after it. + *

    + */ +class SseOutboundStreamLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + private static @Nullable Level previousLevel; + + /** + * The stream logs takeovers and backpressure; Lincheck model checks every instruction a + * thread runs, the log appender's included, so logging stays off while it runs. + */ + @BeforeAll + static void quietLogs() { + Logger logger = (Logger) LoggerFactory.getLogger(SseOutboundStream.class); + previousLevel = logger.getLevel(); + logger.setLevel(Level.OFF); + } + + @AfterAll + static void restoreLogs() { + ((Logger) LoggerFactory.getLogger(SseOutboundStream.class)).setLevel(previousLevel); + } + + @Test + void noEventIsLostOrReordered() { + new ModelCheckingOptions().iterations(30 * SCALE) + .invocationsPerIteration(100) + .threads(2) + .actorsPerThread(3) + .actorsBefore(1) + .actorsAfter(1) + .check(Mailbox.class); + } + + @Test + void closingLeavesNoResponseOpen() { + new ModelCheckingOptions().iterations(50 * SCALE) + .invocationsPerIteration(300) + .threads(2) + .actorsPerThread(2) + .actorsBefore(1) + .actorsAfter(0) + .check(OpenAndClose.class); + } + + /** + * GETs attaching race DELETE closing the stream, while the container completes pending + * writes. Once the stream is closed, no GET may be left holding an open response: one + * attached before the close is completed by it, one attaching after it is completed at + * once. + * + *

    + * The clients are created up front, as in the servlet-era model of this test. + *

    + */ + @Param(name = "client", gen = IntGen.class, conf = "0:1") + public static class OpenAndClose { + + /** At most one GET per actor: one before the parallel part, two in each thread. */ + private static final int MAX_GETS = 5; + + private final SseOutboundStream stream = new SseOutboundStream(64, 2); + + private final Queue unusedSync = new ConcurrentLinkedQueue<>(); + + private final Queue unusedAsync = new ConcurrentLinkedQueue<>(); + + private final List clients = new CopyOnWriteArrayList<>(); + + public OpenAndClose() { + Deliveries deliveries = new Deliveries(); + for (int i = 0; i < MAX_GETS; i++) { + unusedSync.add(new Client(false, deliveries)); + unusedAsync.add(new Client(true, deliveries)); + } + } + + @Operation + public void subscribe(@Param(gen = BooleanGen.class) boolean asyncWrites) { + Client client = Objects.requireNonNull((asyncWrites ? unusedAsync : unusedSync).poll()); + clients.add(client); + client.attach(stream); + } + + @Operation + public void completeWrite(@Param(name = "client") int index) { + if (index < clients.size()) { + clients.get(index).completePendingWrite(); + } + } + + @Operation + public void close() { + stream.close(); + } + + @Validate + public void aClosedStreamHoldsNoOpenResponse() { + stream.close(); + for (int i = 0; i < clients.size(); i++) { + if (!clients.get(i).isCompleted()) { + throw new IllegalStateException("GET " + i + " of " + clients.size() + + " still holds an open response after the stream closed"); + } + } + } + + } + + @Param(name = "client", gen = IntGen.class, conf = "0:1") + public static class Mailbox { + + /** Two pending events detach a subscriber: the backpressure path is part of the model. */ + private final SseOutboundStream stream = new SseOutboundStream(64, 2); + + private final Deliveries deliveries = new Deliveries(); + + private final List clients = new CopyOnWriteArrayList<>(); + + /** + * Events pushed so far; the event is its number. Guarded by this test's monitor on the + * stream, which makes numbering and pushing one step. + */ + private int pushed; + + @Operation + public void push() { + synchronized (stream) { + stream.push(Integer.toString(pushed)); + pushed++; + } + } + + @Operation + public void subscribe(@Param(gen = BooleanGen.class) boolean asyncWrites) { + Client client = new Client(asyncWrites, deliveries); + clients.add(client); + client.attach(stream); + } + + /** The container completes the client's pending write (or fails it, if the client reset). */ + @Operation + public void completeWrite(@Param(name = "client") int index) { + if (index < clients.size()) { + clients.get(index).completePendingWrite(); + } + } + + /** The client drops the stream; the server learns of it only on a later write. */ + @Operation + public void resetByClient(@Param(name = "client") int index) { + if (index < clients.size()) { + clients.get(index).reset(); + } + } + + @Operation + public void keepAlive() { + stream.keepAlive(); + } + + @Validate + public void everyEventDeliveredInOrder() { + clients.forEach(Client::completePendingWrite); + Client healthy = new Client(false, deliveries); + clients.add(healthy); + healthy.attach(stream); + clients.forEach(Client::completePendingWrite); + + int total; + synchronized (stream) { + total = pushed; + } + deliveries.check(total); + } + + } + + /** What clients received, across every stream that was attached. */ + static final class Deliveries { + + private final Queue received = new ConcurrentLinkedQueue<>(); + + /** Events written to a subscriber that was detached before the write was confirmed. */ + private final Set mayRepeat = ConcurrentHashMap.newKeySet(); + + void check(int pushed) { + List firsts = new ArrayList<>(); + Set seen = ConcurrentHashMap.newKeySet(); + for (Integer event : received) { + if (seen.add(event)) { + firsts.add(event); + } + else if (!mayRepeat.contains(event)) { + throw new IllegalStateException("event " + event + " delivered twice: " + received); + } + } + List expected = new ArrayList<>(); + for (int event = 0; event < pushed; event++) { + expected.add(event); + } + if (!firsts.equals(expected)) { + throw new IllegalStateException( + "pushed " + expected + " but first deliveries were " + firsts + " (all: " + received + ")"); + } + } + + } + + /** + * One GET's host: it writes each frame it is handed, synchronously or held pending until the + * container completes it, and asks for the next only once a write has completed. After a + * reset its writes are discarded, and the next write it makes reveals the reset: it cancels. + */ + static final class Client implements CoreSubscriber { + + private final boolean asyncWrites; + + private final Deliveries deliveries; + + private @Nullable Subscription subscription; + + /** A written frame the container has not completed; no request is made meanwhile. */ + private @Nullable SseFrame pending; + + /** Events delivered since the last request, not yet confirmed by the stream. */ + private final List unconfirmed = new ArrayList<>(); + + private boolean reset; + + /** A write was discarded by the reset stream: the next write fails. */ + private boolean failNextWrite; + + private boolean completed; + + Client(boolean asyncWrites, Deliveries deliveries) { + this.asyncWrites = asyncWrites; + this.deliveries = deliveries; + } + + void attach(SseOutboundStream stream) { + stream.subscribe().subscribe(this); + } + + @Override + public void onSubscribe(Subscription s) { + synchronized (this) { + this.subscription = s; + } + s.request(1); + } + + @Override + public void onNext(SseFrame frame) { + boolean next; + synchronized (this) { + if (completed) { + return; + } + if (asyncWrites) { + pending = frame; + return; + } + next = written(frame); + } + proceed(next); + } + + /** Under the monitor: the write of {@code frame} completed; true to ask for more, false to cancel. */ + private boolean written(SseFrame frame) { + if (failNextWrite) { + return false; + } + if (reset) { + failNextWrite = true; + // The servlet host checks the stream with an empty write before asking again. + return false; + } + if (!frame.comment()) { + int event = Integer.parseInt(frame.data()); + unconfirmed.add(event); + deliveries.received.add(event); + } + return true; + } + + private void proceed(boolean next) { + Subscription current; + synchronized (this) { + current = subscription; + if (next) { + // The next request confirms what was delivered. + unconfirmed.clear(); + } + } + if (current == null) { + return; + } + if (next) { + current.request(1); + } + else { + current.cancel(); + onComplete(); + } + } + + void completePendingWrite() { + boolean next; + synchronized (this) { + SseFrame frame = pending; + if (frame == null || completed) { + return; + } + pending = null; + next = written(frame); + } + proceed(next); + } + + synchronized void reset() { + reset = true; + } + + synchronized boolean isCompleted() { + return completed; + } + + @Override + public void onError(Throwable error) { + onComplete(); + } + + /** The response completes: what was delivered unconfirmed, or is pending, may also reach the client. */ + @Override + public synchronized void onComplete() { + if (completed) { + return; + } + completed = true; + deliveries.mayRepeat.addAll(unconfirmed); + SseFrame frame = pending; + if (frame != null && !frame.comment()) { + deliveries.mayRepeat.add(Integer.parseInt(frame.data())); + } + } + + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamTest.java new file mode 100644 index 00000000..b512f907 --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/SseOutboundStreamTest.java @@ -0,0 +1,280 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.ArrayList; +import java.util.List; + +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Deterministic tests of {@link SseOutboundStream}'s mailbox and backpressure rules. The host + * side is a fake subscriber the test drives: it asks for a frame as a host does once the + * previous write completed, or holds its demand as a host whose output is not ready. + */ +class SseOutboundStreamTest { + + private static final String OPEN = ": connected"; + + @Test + void eventsPushedWithoutSubscriberAreDeliveredToTheNextSubscriber() { + SseOutboundStream stream = new SseOutboundStream(4, 4); + stream.push("{\"n\":1}"); + stream.push("{\"n\":2}"); + + FakeHost client = FakeHost.writing(stream); + + assertThat(client.frames).containsExactly(OPEN, "{\"n\":1}", "{\"n\":2}"); + + stream.push("{\"n\":3}"); + assertThat(client.frames).containsExactly(OPEN, "{\"n\":1}", "{\"n\":2}", "{\"n\":3}"); + assertThat(client.completed).isFalse(); + } + + @Test + void mailboxOverflowThrows() { + SseOutboundStream stream = new SseOutboundStream(2, 4); + stream.push("a"); + stream.push("b"); + + assertThatThrownBy(() -> stream.push("c")).isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("2 events"); + } + + @Test + void backpressuredSubscriberIsClosedAndItsUndeliveredEventsReachTheNextSubscriber() { + SseOutboundStream stream = new SseOutboundStream(4, 2); + + // Never asks: every event stays queued. + FakeHost slow = FakeHost.stalled(stream); + stream.push("\"queued\""); + assertThat(slow.frames).isEmpty(); + stream.push("\"second\""); + assertThat(slow.completed).isFalse(); + + // More than maxPendingSseEvents events now wait on a subscriber that does not read: + // it is detached, and every event stays queued for the next one. + stream.push("\"overflow\""); + assertThat(slow.completed).isTrue(); + + stream.push("\"later\""); + FakeHost next = FakeHost.writing(stream); + assertThat(next.frames).as("nothing the slow subscriber never wrote is lost, and order is kept") + .containsExactly(OPEN, "\"queued\"", "\"second\"", "\"overflow\"", "\"later\""); + assertThat(next.completed).isFalse(); + } + + @Test + void replayIntoASubscriberThatCannotWriteLosesNothing() { + SseOutboundStream stream = new SseOutboundStream(8, 2); + stream.push("\"a\""); + stream.push("\"b\""); + stream.push("\"c\""); + stream.push("\"d\""); + + FakeHost.stalled(stream); + FakeHost next = FakeHost.writing(stream); + + assertThat(next.frames).containsExactly(OPEN, "\"a\"", "\"b\"", "\"c\"", "\"d\""); + } + + /** + * The host reports the old connection's failure on its own thread after the client has + * already reconnected. The late cancel must not strand or reorder anything, and must not + * detach the new subscriber. + */ + @Test + void aLateCancelOfTheOldSubscriberDoesNotDisturbTheNewOne() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost old = FakeHost.stalled(stream); + stream.push("\"a\""); + stream.push("\"b\""); + + FakeHost reconnected = FakeHost.writing(stream); + stream.push("\"c\""); + old.cancel(); + stream.push("\"d\""); + + assertThat(reconnected.frames).containsExactly(OPEN, "\"a\"", "\"b\"", "\"c\"", "\"d\""); + assertThat(reconnected.completed).isFalse(); + } + + @Test + void anEventPushedAfterTheSubscriberWentAwayIsKept() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost first = FakeHost.stalled(stream); + first.cancel(); + stream.push("\"x\""); + + FakeHost next = FakeHost.writing(stream); + assertThat(next.frames).containsExactly(OPEN, "\"x\""); + } + + /** + * A frame handed to the host whose write did not complete (no further request came) before + * the client went away is kept, ahead of later ones: it may arrive twice, never not at all. + */ + @Test + void anUnconfirmedEventIsKeptInOrder() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost pending = FakeHost.stalled(stream); + pending.request(2); + stream.push("\"update\""); + assertThat(pending.frames).containsExactly(OPEN, "\"update\""); + stream.push("\"result\""); + pending.cancel(); + + FakeHost next = FakeHost.writing(stream); + assertThat(next.frames).containsExactly(OPEN, "\"update\"", "\"result\""); + } + + /** A frame whose write completed (the host asked again) is not replayed. */ + @Test + void aConfirmedEventIsNotReplayed() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost first = FakeHost.stalled(stream); + first.request(2); + stream.push("\"a\""); + first.request(1); + first.cancel(); + + FakeHost next = FakeHost.writing(stream); + stream.push("\"b\""); + assertThat(first.frames).containsExactly(OPEN, "\"a\""); + assertThat(next.frames).containsExactly(OPEN, "\"b\""); + } + + @Test + void keepAliveIsSentOnlyToAnIdleSubscriberThatAsked() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost idle = FakeHost.writing(stream); + stream.keepAlive(); + assertThat(idle.frames).containsExactly(OPEN, ": keep-alive"); + + FakeHost stalled = FakeHost.stalled(stream); + stream.keepAlive(); + assertThat(stalled.frames).isEmpty(); + } + + @Test + void closingCompletesTheSubscriberAndASubscriberOfAClosedStream() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost attached = FakeHost.writing(stream); + stream.push("\"a\""); + stream.close(); + assertThat(attached.completed).isTrue(); + assertThat(stream.isClosed()).isTrue(); + stream.push("\"ignored\""); + + FakeHost late = FakeHost.writing(stream); + assertThat(late.frames).isEmpty(); + assertThat(late.completed).isTrue(); + } + + @Test + void aShutdownGivesTheSubscriberAClosingComment() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost attached = FakeHost.writing(stream); + stream.closeForShutdown(); + assertThat(attached.frames).containsExactly(OPEN, ": shutting down"); + assertThat(attached.completed).isTrue(); + stream.closeForShutdown(); + } + + @Test + void aNewSubscriberTakesTheStreamOver() { + SseOutboundStream stream = new SseOutboundStream(8, 8); + FakeHost first = FakeHost.writing(stream); + FakeHost second = FakeHost.writing(stream); + stream.push("\"a\""); + assertThat(first.completed).isTrue(); + assertThat(second.frames).containsExactly(OPEN, "\"a\""); + } + + /** A host's request from inside a frame's hand-off continues the loop rather than recursing. */ + @Test + void manyQueuedEventsDoNotRecurse() { + SseOutboundStream stream = new SseOutboundStream(100_000, 100_000); + for (int i = 0; i < 50_000; i++) { + stream.push(Integer.toString(i)); + } + FakeHost client = FakeHost.writing(stream); + assertThat(client.frames).hasSize(50_001); + } + + /** + * A host as the test drives it: {@code writing} asks for the next frame as each one is + * handed over, as a host whose writes complete at once does; {@code stalled} asks for + * nothing until the test says so. + */ + private static final class FakeHost implements CoreSubscriber { + + final List frames = new ArrayList<>(); + + final boolean autoRequest; + + Subscription subscription; + + boolean completed; + + private FakeHost(boolean autoRequest) { + this.autoRequest = autoRequest; + } + + static FakeHost writing(SseOutboundStream stream) { + FakeHost host = new FakeHost(true); + stream.subscribe().subscribe(host); + return host; + } + + static FakeHost stalled(SseOutboundStream stream) { + FakeHost host = new FakeHost(false); + stream.subscribe().subscribe(host); + return host; + } + + @Override + public void onSubscribe(Subscription s) { + this.subscription = s; + if (autoRequest) { + s.request(1); + } + } + + @Override + public void onNext(SseFrame frame) { + frames.add(frame.comment() ? ": " + frame.data() : frame.data()); + if (autoRequest) { + subscription.request(1); + } + } + + @Override + public void onError(Throwable error) { + completed = true; + } + + @Override + public void onComplete() { + completed = true; + } + + void request(long n) { + subscription.request(n); + } + + void cancel() { + subscription.cancel(); + } + + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRoutingTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRoutingTest.java new file mode 100644 index 00000000..ce212988 --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpRoutingTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.ClientRequestRoute; +import com.agentclientprotocol.sdk.http.server.StreamableHttpRouting.RouteScope; +import com.agentclientprotocol.sdk.error.AcpConnectionException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The server routes every client-to-agent method of ACP v1, stable and unstable, from its + * table: connection-scoped methods on the connection, session-scoped methods only with an + * {@code Acp-Session-Id} matching their params (the RFD: "Session-scoped request missing + * Acp-Session-Id? -> 400 Bad Request"). + */ +class StreamableHttpRoutingTest { + + private static final String SESSION = "sess-1"; + + private final StreamableHttpRouting routing = new StreamableHttpRouting(AcpJsonMapper.createDefault()); + + static Stream connectionScopedMethods() { + return Stream.of("authenticate", "logout", "session/new", "session/list", "providers/list", "providers/set", + "providers/disable", "nes/start", "mcp/message", "$/cancel_request") + .map(Arguments::of); + } + + static Stream sessionScopedMethods() { + return Stream.of("session/prompt", "session/cancel", "session/set_mode", "session/set_config_option", + "session/close", "session/delete", "session/fork", "nes/suggest", "nes/accept", "nes/reject", + "nes/close", "document/didOpen", "document/didChange", "document/didClose", "document/didSave", + "document/didFocus") + .map(Arguments::of); + } + + @ParameterizedTest + @MethodSource("connectionScopedMethods") + void connectionScopedMethodIsRoutedOnTheConnection(String method) { + ClientRequestRoute route = routing + .resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of("cwd", "/w")), null) + .requestRoute(); + + assertThat(route.requestScope()).isEqualTo(RouteScope.connection()); + assertThat(route.responseScope()).isEqualTo(RouteScope.connection()); + } + + /** + * The agent sends $/cancel_request on the connection stream, even when its params name a + * session, matching the client's table and the Rust SDK. + */ + @Test + void anAgentCancelRequestGoesOnTheConnectionStream() { + assertThat(routing.resolveAgentRequestOrNotificationScope(AcpSchema.METHOD_CANCEL_REQUEST, + Map.of("requestId", 3, "sessionId", SESSION))) + .isEqualTo(RouteScope.connection()); + } + + @ParameterizedTest + @MethodSource("sessionScopedMethods") + void sessionScopedMethodIsRoutedInItsSession(String method) { + ClientRequestRoute route = routing + .resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of("sessionId", SESSION)), SESSION) + .requestRoute(); + + assertThat(route.requestScope()).isEqualTo(RouteScope.session(SESSION)); + assertThat(route.responseScope()).isEqualTo(RouteScope.session(SESSION)); + } + + static Stream headerRequiredMethods() { + return sessionScopedMethods().filter(arguments -> !List.of("session/delete", "session/fork") + .contains(arguments.get()[0])); + } + + /** delete and fork, like load, are accepted without the header and answered on the connection. */ + @Test + void deleteAndForkWithoutTheSessionHeaderAreAnsweredOnTheConnection() { + for (String method : List.of(AcpSchema.METHOD_SESSION_DELETE, AcpSchema.METHOD_SESSION_FORK)) { + ClientRequestRoute route = routing + .resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of("sessionId", SESSION)), null) + .requestRoute(); + assertThat(route.requestScope()).isEqualTo(RouteScope.session(SESSION)); + assertThat(route.responseScope()).isEqualTo(RouteScope.connection()); + } + } + + @ParameterizedTest + @MethodSource("headerRequiredMethods") + void sessionScopedMethodWithoutTheSessionHeaderIsRefused(String method) { + assertThatThrownBy(() -> routing + .resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of("sessionId", SESSION)), null)) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("Acp-Session-Id header required"); + } + + @ParameterizedTest + @MethodSource("sessionScopedMethods") + void sessionScopedMethodWithoutASessionIdIsRefused(String method) { + assertThatThrownBy( + () -> routing.resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of()), SESSION)) + .isInstanceOf(AcpConnectionException.class) + .hasMessageContaining("Missing sessionId"); + } + + @Test + void loadAndResumeAreAnsweredOnTheConnectionStream() { + for (String method : List.of(AcpSchema.METHOD_SESSION_LOAD, AcpSchema.METHOD_SESSION_RESUME)) { + ClientRequestRoute route = routing + .resolveInboundRoute(new AcpSchema.JSONRPCRequest(method, 1, Map.of("sessionId", SESSION)), null) + .requestRoute(); + assertThat(route.requestScope()).isEqualTo(RouteScope.session(SESSION)); + assertThat(route.responseScope()).isEqualTo(RouteScope.connection()); + } + } + + /** The table names every method AcpSchema defines for the client to call, but initialize. */ + @Test + void everyAgentMethodConstantHasARule() { + assertThat(routing.routedMethods()).contains(AcpSchema.METHOD_AUTHENTICATE, AcpSchema.METHOD_LOGOUT, + AcpSchema.METHOD_SESSION_NEW, AcpSchema.METHOD_SESSION_LOAD, AcpSchema.METHOD_SESSION_PROMPT, + AcpSchema.METHOD_SESSION_SET_MODE, AcpSchema.METHOD_SESSION_CANCEL, AcpSchema.METHOD_SESSION_LIST, + AcpSchema.METHOD_SESSION_CLOSE, AcpSchema.METHOD_SESSION_DELETE, AcpSchema.METHOD_SESSION_RESUME, + AcpSchema.METHOD_SESSION_FORK, AcpSchema.METHOD_SESSION_SET_CONFIG_OPTION, + AcpSchema.METHOD_PROVIDERS_LIST, AcpSchema.METHOD_PROVIDERS_SET, AcpSchema.METHOD_PROVIDERS_DISABLE); + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessionsLincheckTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessionsLincheckTest.java new file mode 100644 index 00000000..025d8e3f --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/StreamableHttpSessionsLincheckTest.java @@ -0,0 +1,179 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.jetbrains.lincheck.datastructures.BooleanGen; +import org.jetbrains.lincheck.datastructures.IntGen; +import org.jetbrains.lincheck.datastructures.ModelCheckingOptions; +import org.jetbrains.lincheck.datastructures.Operation; +import org.jetbrains.lincheck.datastructures.Param; +import org.jetbrains.lincheck.datastructures.Validate; +import org.junit.jupiter.api.Test; + +/** + * Model checks the session table of one Streamable HTTP connection, + * {@link StreamableHttpSessions}, with Lincheck: client requests (session GETs, session-scoped + * POSTs) on servlet threads race the agent's outbound thread (which confirms or discards a + * provisional session from a {@code session/load} reply, and pushes events to session + * streams) and the connection closing. + * + *

    + * The sequential specification is a map from session to state (provisional or known) with + * the provisional bound, here one. Checked after every interleaving: once the connection has + * closed, every stream it handed out is closed. + *

    + */ +class StreamableHttpSessionsLincheckTest { + + /** + * Multiplies the number of scenarios explored. The default keeps this test to seconds in + * the build; CI's lincheck job raises it with -Dlincheck.scale. Scenarios and interleavings + * are generated from a fixed seed, so a run is repeatable. + */ + private static final int SCALE = Integer.getInteger("lincheck.scale", 1); + + @Test + void sessionTableIsLinearizable() { + new ModelCheckingOptions().iterations(30 * SCALE) + .invocationsPerIteration(100) + .threads(2) + .actorsPerThread(3) + .actorsBefore(1) + .actorsAfter(1) + .sequentialSpecification(SessionsSpec.class) + .check(Sessions.class); + } + + @Param(name = "session", gen = IntGen.class, conf = "0:1") + public static class Sessions { + + private final StreamableHttpSessions sessions = new StreamableHttpSessions("connection", + StreamableHttpAcpAgentTransportOptions.builder().maxProvisionalSessions(1).build()); + + /** Every stream handed out, to a GET or to the agent's outbound thread. */ + private final List handedOut = new CopyOnWriteArrayList<>(); + + private volatile boolean closed; + + /** A session-scoped GET. */ + @Operation + public String openStream(@Param(name = "session") int session) { + try { + handedOut.add(sessions.openStream("s" + session)); + return "OPEN"; + } + catch (UnknownSessionException e) { + return "REJECTED"; + } + } + + /** A session-scoped POST: a session/load, or any other request. */ + @Operation + public String admit(@Param(name = "session") int session, @Param(gen = BooleanGen.class) boolean load) { + try { + return sessions.admitInbound("s" + session, load) ? "STREAM" : "CONNECTION"; + } + catch (UnknownSessionException e) { + return "UNKNOWN"; + } + } + + /** The agent's reply to session/new or a successful session/load. */ + @Operation(nonParallelGroup = "agent") + public void markKnown(@Param(name = "session") int session) { + sessions.markKnown("s" + session); + } + + /** The agent's error reply to session/load. */ + @Operation(nonParallelGroup = "agent") + public void discardProvisional(@Param(name = "session") int session) { + sessions.discardProvisional("s" + session); + } + + /** The agent emits a message scoped to the session. */ + @Operation(nonParallelGroup = "agent") + public void push(@Param(name = "session") int session) { + handedOut.add(sessions.stream("s" + session)); + } + + @Operation + public void close() { + sessions.close(); + closed = true; + } + + @Validate + public void aClosedConnectionHasNoOpenStream() { + if (closed) { + for (SseOutboundStream stream : handedOut) { + if (!stream.isClosed()) { + throw new IllegalStateException("a stream handed out stayed open after the connection closed"); + } + } + } + } + + } + + /** Sequential specification: session states and the provisional bound of one. */ + public static class SessionsSpec { + + private static final String PROVISIONAL = "PROVISIONAL"; + + private static final String KNOWN = "KNOWN"; + + private final Map states = new HashMap<>(); + + private boolean addProvisional(int session) { + long provisional = states.values().stream().filter(PROVISIONAL::equals).count(); + if (provisional >= 1 && !PROVISIONAL.equals(states.get(session))) { + return false; + } + states.putIfAbsent(session, PROVISIONAL); + return true; + } + + public String openStream(int session) { + if (!states.containsKey(session) && !addProvisional(session)) { + return "REJECTED"; + } + return "OPEN"; + } + + /** + * A load may add a provisional session; nothing else changes the table, and no + * session id is refused (the RFD's POST tree has no 404 for an unknown session). The + * answer says whether the session has a stream to reply on. + */ + public String admit(int session, boolean load) { + if (load && !states.containsKey(session) && !addProvisional(session)) { + return "UNKNOWN"; + } + return states.containsKey(session) ? "STREAM" : "CONNECTION"; + } + + public void markKnown(int session) { + states.put(session, KNOWN); + } + + public void discardProvisional(int session) { + states.remove(session, PROVISIONAL); + } + + public void push(int session) { + } + + public void close() { + } + + } + +} diff --git a/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/WebSocketConnectionTimeoutTest.java b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/WebSocketConnectionTimeoutTest.java new file mode 100644 index 00000000..a7637e1a --- /dev/null +++ b/acp-http-core/src/test/java/com/agentclientprotocol/sdk/http/server/WebSocketConnectionTimeoutTest.java @@ -0,0 +1,290 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.server; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; +import reactor.test.scheduler.VirtualTimeScheduler; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The WebSocket connection's idle timeout and initialize deadline, timed on a scheduler the + * test owns: on virtual time for when they fire, and on a real timer queue for what stays + * scheduled, so a closed connection is shown to leave no task behind. + */ +class WebSocketConnectionTimeoutTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"""; + + private static final AcpAgentFactory AGENTS = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s-1", null, null))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build()); + + private final ScheduledThreadPoolExecutor timerQueue = new ScheduledThreadPoolExecutor(1); + + private @Nullable WebSocketConnection connection; + + WebSocketConnectionTimeoutTest() { + timerQueue.setRemoveOnCancelPolicy(true); + } + + @AfterEach + void close() { + WebSocketConnection current = connection; + if (current != null) { + current.closeNow(); + } + timerQueue.shutdownNow(); + } + + @Test + void closingLeavesNoTimerScheduled() { + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofHours(1), Duration.ofHours(1), Schedulers.fromExecutorService(timerQueue), + System::nanoTime); + // The initialize deadline and the idle check. + awaitTrue(() -> timerQueue.getQueue().size() == 2); + + connection().onClose(1000, "bye"); + + awaitTrue(() -> timerQueue.getQueue().isEmpty()); + assertThat(socket.closeCode.get()).isNull(); + } + + @Test + void closingByTheEndpointLeavesNoTimerScheduled() { + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofHours(1), Duration.ofHours(1), Schedulers.fromExecutorService(timerQueue), + System::nanoTime); + awaitTrue(() -> timerQueue.getQueue().size() == 2); + + connection().close(WebSocketConnection.NORMAL, "done"); + + awaitTrue(() -> timerQueue.getQueue().isEmpty()); + assertThat(socket.closeCode.get()).isEqualTo(1000); + } + + @Test + void initializeCancelsTheDeadline() { + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofHours(1), Duration.ofHours(1), Schedulers.fromExecutorService(timerQueue), + System::nanoTime); + awaitTrue(() -> timerQueue.getQueue().size() == 2); + + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + // Only the idle check is left. + awaitTrue(() -> timerQueue.getQueue().size() == 1); + } + + @Test + void aConnectionWithoutInitializeIsClosedWith1008AtTheDeadline() { + VirtualTimeScheduler time = VirtualTimeScheduler.create(); + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofSeconds(30), Duration.ofMinutes(30), time, nanos(time)); + + time.advanceTimeBy(Duration.ofSeconds(29)); + assertThat(socket.closeCode.get()).isNull(); + + time.advanceTimeBy(Duration.ofSeconds(1)); + assertThat(socket.closeCode.get()).isEqualTo(1008); + assertThat(socket.closeReason.get()).isEqualTo("initialize not received"); + } + + @Test + void anInitializedConnectionIsNotClosedAtTheDeadline() { + VirtualTimeScheduler time = VirtualTimeScheduler.create(); + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofSeconds(30), Duration.ofMinutes(30), time, nanos(time)); + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + time.advanceTimeBy(Duration.ofMinutes(5)); + + assertThat(socket.closeCode.get()).isNull(); + } + + @Test + void anIdleConnectionIsClosedWith1001() { + VirtualTimeScheduler time = VirtualTimeScheduler.create(); + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofSeconds(30), Duration.ofSeconds(10), time, nanos(time)); + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + time.advanceTimeBy(Duration.ofSeconds(9)); + assertThat(socket.closeCode.get()).isNull(); + + time.advanceTimeBy(Duration.ofSeconds(1)); + assertThat(socket.closeCode.get()).isEqualTo(1001); + assertThat(socket.closeReason.get()).isEqualTo("idle timeout"); + } + + @Test + void anInboundFrameResetsTheIdleTimer() { + VirtualTimeScheduler time = VirtualTimeScheduler.create(); + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofSeconds(30), Duration.ofSeconds(10), time, nanos(time)); + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + time.advanceTimeBy(Duration.ofSeconds(6)); + // A frame at 6 s: answered -32600, and both the frame and the answer are activity. + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 2); + + // The check at 10 s finds 4 s of idleness and checks again at 16 s. + time.advanceTimeBy(Duration.ofSeconds(9)); + assertThat(socket.closeCode.get()).isNull(); + + time.advanceTimeBy(Duration.ofSeconds(1)); + assertThat(socket.closeCode.get()).isEqualTo(1001); + } + + @Test + void anOutboundFrameResetsTheIdleTimer() { + VirtualTimeScheduler time = VirtualTimeScheduler.create(); + FakeSocket socket = new FakeSocket(); + open(socket, Duration.ofSeconds(30), Duration.ofSeconds(10), time, nanos(time)); + connection().onText(INITIALIZE); + awaitTrue(() -> socket.sent.size() == 1); + + time.advanceTimeBy(Duration.ofSeconds(6)); + // The agent sends at 6 s, unprompted. + connection().sendToClient(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, "_test/ping", null)); + awaitTrue(() -> socket.sent.size() == 2); + + time.advanceTimeBy(Duration.ofSeconds(9)); + assertThat(socket.closeCode.get()).isNull(); + + time.advanceTimeBy(Duration.ofSeconds(1)); + assertThat(socket.closeCode.get()).isEqualTo(1001); + } + + @Test + void theTimeoutsAreTheOptionsAndDefaultToThirtyMinutesAndThirtySeconds() { + StreamableHttpAcpAgentTransportOptions defaults = StreamableHttpAcpAgentTransportOptions.defaults(); + assertThat(defaults.webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(30)); + assertThat(defaults.initializeTimeout()).isEqualTo(Duration.ofSeconds(30)); + AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), AGENTS, + StreamableHttpAcpAgentTransportOptions.builder().webSocketIdleTimeout(Duration.ofSeconds(7)).build()); + AcpWsHandshake.Accepted accepted = (AcpWsHandshake.Accepted) endpoint + .webSocketHandshake(new HeaderExchange()); + // The container's own idle timeout backs the endpoint's up, past it. + assertThat(accepted.idleTimeout()).isEqualTo(Duration.ofSeconds(12)); + } + + private void open(FakeSocket socket, Duration initializeTimeout, Duration idleTimeout, Scheduler timer, + LongSupplier clock) { + StreamableHttpAcpAgentTransportOptions options = StreamableHttpAcpAgentTransportOptions.builder() + .initializeTimeout(initializeTimeout) + .webSocketIdleTimeout(idleTimeout) + .build(); + WebSocketConnection opened = new WebSocketConnection("ws-1", AcpJsonMapper.createDefault(), options, socket, + new WebSocketConnection.Owner(closed -> { + }, error -> { + }), new WebSocketConnection.Timing(timer, clock)); + this.connection = opened; + opened.start(AGENTS); + } + + private WebSocketConnection connection() { + WebSocketConnection current = connection; + assertThat(current).isNotNull(); + return current; + } + + private static LongSupplier nanos(VirtualTimeScheduler time) { + return () -> time.now(TimeUnit.NANOSECONDS); + } + + private static void awaitTrue(BooleanSupplier condition) { + long deadline = System.nanoTime() + TIMEOUT.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("Condition not met within " + TIMEOUT); + } + LockSupport.parkNanos(10_000_000); + } + } + + private static final class FakeSocket implements AcpWsOutbound { + + final List sent = new CopyOnWriteArrayList<>(); + + final AtomicReference<@Nullable Integer> closeCode = new AtomicReference<>(); + + final AtomicReference<@Nullable String> closeReason = new AtomicReference<>(); + + @Override + public CompletionStage sendText(String text) { + sent.add(text); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletionStage close(int code, String reason) { + if (closeCode.compareAndSet(null, code)) { + closeReason.set(reason); + } + return CompletableFuture.completedFuture(null); + } + + } + + /** A WebSocket upgrade request without headers. */ + private static final class HeaderExchange implements AcpHttpExchange { + + @Override + public String method() { + return "GET"; + } + + @Override + public @Nullable String header(String name) { + return "Upgrade".equalsIgnoreCase(name) ? "websocket" : null; + } + + @Override + public Mono body(long maxBytes) { + return Mono.empty(); + } + + @Override + public java.security.@Nullable Principal principal() { + return null; + } + + } + +} diff --git a/acp-http-core/src/test/resources/archunit.properties b/acp-http-core/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-http-core/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-http-core/src/test/resources/logback-test.xml b/acp-http-core/src/test/resources/logback-test.xml new file mode 100644 index 00000000..5243e19c --- /dev/null +++ b/acp-http-core/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/acp-http-servlet/pom.xml b/acp-http-servlet/pom.xml new file mode 100644 index 00000000..1584156a --- /dev/null +++ b/acp-http-servlet/pom.xml @@ -0,0 +1,120 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-http-servlet + jar + + ACP HTTP Servlet + The ACP endpoint as a Servlet 6 servlet, with WebSocket upgrades through Jakarta WebSocket 2.1, for any servlet container: Tomcat, Jetty, Undertow, Spring MVC + + + + 0.81 + 0.71 + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-http-core + + + + jakarta.servlet + jakarta.servlet-api + ${jakarta-servlet.version} + provided + + + jakarta.websocket + jakarta.websocket-api + ${jakarta-websocket.version} + provided + + + jakarta.websocket + jakarta.websocket-client-api + ${jakarta-websocket.version} + provided + + + + + org.apache.tomcat.embed + tomcat-embed-core + test + + + org.apache.tomcat.embed + tomcat-embed-websocket + test + + + com.agentclientprotocol + acp-test + test + + + org.junit.jupiter + junit-jupiter + test + + + com.tngtech.archunit + archunit-junit5 + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-core + test + + + ch.qos.logback + logback-classic + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + jakarta.websocket:jakarta.websocket-api + jakarta.websocket:jakarta.websocket-client-api + jakarta.servlet:jakarta.servlet-api + + + + + + + diff --git a/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgrade.java b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgrade.java new file mode 100644 index 00000000..32f5c4c1 --- /dev/null +++ b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgrade.java @@ -0,0 +1,235 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.http.server.AcpWsHandler; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.http.server.AcpWsOutbound; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.websocket.CloseReason; +import jakarta.websocket.DeploymentException; +import jakarta.websocket.Endpoint; +import jakarta.websocket.EndpointConfig; +import jakarta.websocket.HandshakeResponse; +import jakarta.websocket.MessageHandler; +import jakarta.websocket.Session; +import jakarta.websocket.server.HandshakeRequest; +import jakarta.websocket.server.ServerContainer; +import jakarta.websocket.server.ServerEndpointConfig; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Upgrades an accepted handshake with Jakarta WebSocket 2.1's + * {@code ServerContainer.upgradeHttpToWebSocket}, on whichever implementation the container + * installed (Tomcat's, Jetty's, Undertow's), and carries the socket's frames to and from the + * endpoint. Only this class touches the Jakarta WebSocket API, and the servlet loads it only for + * an upgrade request, so a container without it serves HTTP all the same. + * + * @author Mark Pollack + */ +final class JakartaWebSocketUpgrade { + + private static final Logger logger = LoggerFactory.getLogger(JakartaWebSocketUpgrade.class); + + private static final String SERVER_CONTAINER_ATTRIBUTE = "jakarta.websocket.server.ServerContainer"; + + private static final boolean API_PRESENT = isPresent("jakarta.websocket.server.ServerContainer"); + + private JakartaWebSocketUpgrade() { + } + + private static boolean isPresent(String className) { + try { + Class.forName(className, false, JakartaWebSocketUpgrade.class.getClassLoader()); + return true; + } + catch (ClassNotFoundException | LinkageError e) { + return false; + } + } + + /** + * Upgrades the request. + * @return false when the container has no Jakarta WebSocket implementation + */ + static boolean upgrade(HttpServletRequest request, HttpServletResponse response, AcpWsHandshake.Accepted accepted) + throws ServletException, IOException { + if (!API_PRESENT) { + return false; + } + return Upgrader.upgrade(request, response, accepted); + } + + /** Holds the Jakarta WebSocket types, so they load only once the API is known to be present. */ + private static final class Upgrader { + + static boolean upgrade(HttpServletRequest request, HttpServletResponse response, + AcpWsHandshake.Accepted accepted) throws ServletException, IOException { + Object attribute = request.getServletContext().getAttribute(SERVER_CONTAINER_ATTRIBUTE); + if (!(attribute instanceof ServerContainer container)) { + return false; + } + AcpEndpoint endpoint = new AcpEndpoint(accepted); + String path = request.getRequestURI(); + ServerEndpointConfig config = ServerEndpointConfig.Builder.create(AcpEndpoint.class, path) + .configurator(new ServerEndpointConfig.Configurator() { + + @Override + public T getEndpointInstance(Class endpointClass) { + return endpointClass.cast(endpoint); + } + + @Override + public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest handshakeRequest, + HandshakeResponse handshakeResponse) { + accepted.headers().forEach((name, value) -> handshakeResponse.getHeaders().put(name, List.of(value))); + } + + }) + .build(); + try { + container.upgradeHttpToWebSocket(request, response, config, Map.of()); + } + catch (DeploymentException e) { + throw new ServletException("ACP WebSocket upgrade failed", e); + } + return true; + } + + } + + /** + * One accepted socket. Text messages arrive in parts, assembled here up to the endpoint's + * limit, so the container's text buffer (8 KB on Tomcat, allocated per socket) need not be + * raised to the limit. + */ + public static final class AcpEndpoint extends Endpoint { + + private final AcpWsHandshake.Accepted accepted; + + private volatile @Nullable AcpWsHandler handler; + + private volatile @Nullable SessionOutbound outbound; + + AcpEndpoint(AcpWsHandshake.Accepted accepted) { + this.accepted = accepted; + } + + @Override + public void onOpen(Session session, EndpointConfig config) { + session.setMaxIdleTimeout(accepted.idleTimeout().toMillis()); + SessionOutbound sessionOutbound = new SessionOutbound(session); + this.outbound = sessionOutbound; + AcpWsHandler opened = accepted.open(sessionOutbound); + this.handler = opened; + long max = accepted.maxTextMessageBytes(); + StringBuilder message = new StringBuilder(); + session.addMessageHandler(String.class, (MessageHandler.Partial) (part, last) -> { + if (message.length() + part.length() > max) { + // Over the limit already in characters, so over it in bytes too; the endpoint + // checks the assembled message's bytes. + message.setLength(0); + close(session, CloseReason.CloseCodes.TOO_BIG, "message too big"); + return; + } + message.append(part); + if (last) { + String text = message.toString(); + message.setLength(0); + opened.onText(text); + } + }); + } + + @Override + public void onClose(Session session, CloseReason closeReason) { + SessionOutbound currentOutbound = outbound; + if (currentOutbound != null) { + currentOutbound.closed.complete(null); + } + AcpWsHandler current = handler; + if (current != null) { + current.onClose(closeReason.getCloseCode().getCode(), closeReason.getReasonPhrase()); + } + } + + @Override + public void onError(Session session, Throwable error) { + AcpWsHandler current = handler; + if (current != null) { + current.onError(error); + } + } + + } + + /** Closes the session; returns false when the close failed. */ + private static boolean close(Session session, CloseReason.CloseCode code, String reason) { + try { + session.close(new CloseReason(code, reason)); + return true; + } + catch (IOException | IllegalStateException e) { + logger.debug("Closing an ACP WebSocket failed: {}", e.toString()); + return false; + } + } + + /** Sends through the socket's async remote; the endpoint sends one frame at a time. */ + private static final class SessionOutbound implements AcpWsOutbound { + + private final Session session; + + /** + * Completes when the container reports the socket closed. Jetty sends the close frame + * asynchronously and reports the close once the client has answered it, or the + * connection has ended. + */ + final CompletableFuture closed = new CompletableFuture<>(); + + SessionOutbound(Session session) { + this.session = session; + } + + @Override + public CompletionStage sendText(String text) { + CompletableFuture sent = new CompletableFuture<>(); + try { + session.getAsyncRemote().sendText(text, result -> { + if (result.isOK()) { + sent.complete(null); + } + else { + sent.completeExceptionally(result.getException()); + } + }); + } + catch (RuntimeException e) { + sent.completeExceptionally(e); + } + return sent; + } + + @Override + public CompletionStage close(int code, String reason) { + if (!JakartaWebSocketUpgrade.close(session, CloseReason.CloseCodes.getCloseCode(code), reason)) { + closed.complete(null); + } + return closed; + } + + } + +} diff --git a/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletExchange.java b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletExchange.java new file mode 100644 index 00000000..672a643a --- /dev/null +++ b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletExchange.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.security.Principal; +import java.util.Locale; + +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * A servlet request as the endpoint sees it. The body is read on the request thread, blocking, + * as a servlet reads a JSON body, and never beyond the endpoint's limit. + * + * @author Mark Pollack + */ +final class ServletExchange implements AcpHttpExchange { + + private final HttpServletRequest request; + + ServletExchange(HttpServletRequest request) { + this.request = request; + } + + @Override + public String method() { + return request.getMethod().toUpperCase(Locale.ROOT); + } + + @Override + public @Nullable String header(String name) { + return request.getHeader(name); + } + + @Override + public Mono body(long maxBytes) { + return Mono.fromCallable( + () -> request.getInputStream().readNBytes((int) Math.min(Integer.MAX_VALUE - 8, maxBytes + 1))); + } + + @Override + public @Nullable Principal principal() { + return request.getUserPrincipal(); + } + +} diff --git a/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletSseWriter.java b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletSseWriter.java new file mode 100644 index 00000000..9e9e70df --- /dev/null +++ b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/ServletSseWriter.java @@ -0,0 +1,263 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.util.concurrent.locks.ReentrantLock; + +import com.agentclientprotocol.sdk.http.server.SseFrame; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; + +/** + * Writes one SSE stream's frames to a servlet async response with non-blocking output: it asks + * the endpoint for a frame only when the output is ready and the previous write has completed, + * writes and flushes each frame, and cancels when the client goes away or a write fails, so the + * endpoint keeps what was not written for the client's next GET. + * + *

    + * What "completed" means. A write is pending while {@code isReady()} is false after its + * flush; the container then calls {@code onWritePossible} on success or {@code onError} on + * failure. A write whose flush completes at once may still have failed: a container can accept + * a write to a stream the client has already reset and report it only on the next write + * (Jetty's HTTP/2 output does). So before asking for the next frame an empty write checks the + * stream; a request is the endpoint's confirmation that the frames before it were written. + *

    + * + *

    + * The writer's lock guards its own state and the output; it is never held while calling the + * endpoint, which hands frames over under a lock of its own. + *

    + * + * @author Mark Pollack + */ +final class ServletSseWriter implements CoreSubscriber, WriteListener, AsyncListener { + + private static final byte[] NO_BYTES = new byte[0]; + + private final ReentrantLock lock = new ReentrantLock(); + + private final AsyncContext asyncContext; + + private final ServletOutputStream output; + + /** Guarded by {@code lock}. */ + private @Nullable Subscription subscription; + + /** A frame was asked for and has not arrived. Guarded by {@code lock}. */ + private boolean requested; + + /** A frame was written and not yet flushed. Guarded by {@code lock}. */ + private boolean flushPending; + + /** A flush completed whose stream an empty write has not yet checked. Guarded by {@code lock}. */ + private boolean probeNeeded; + + /** Guarded by {@code lock}. */ + private boolean done; + + ServletSseWriter(AsyncContext asyncContext, ServletOutputStream output) { + this.asyncContext = asyncContext; + this.output = output; + } + + @Override + public void onSubscribe(Subscription s) { + lock.lock(); + try { + this.subscription = s; + } + finally { + lock.unlock(); + } + asyncContext.addListener(this); + // The container calls onWritePossible once the output can take the first frame. + output.setWriteListener(this); + } + + @Override + public void onNext(SseFrame frame) { + Step step; + lock.lock(); + try { + requested = false; + if (done) { + return; + } + // The frame was asked for while the output was ready, so it may be written now. + output.write(frame.encode()); + flushPending = true; + step = readyForNext() ? Step.REQUEST : Step.WAIT; + } + catch (IOException | IllegalStateException e) { + step = stopLocked(); + } + finally { + lock.unlock(); + } + take(step); + } + + @Override + public void onWritePossible() { + Step step; + lock.lock(); + try { + if (done) { + return; + } + step = readyForNext() ? Step.REQUEST : Step.WAIT; + } + catch (IOException | IllegalStateException e) { + step = stopLocked(); + } + finally { + lock.unlock(); + } + take(step); + } + + /** + * Advances as far as the output allows, each step only once {@code isReady()} said so (a + * non-blocking output refuses a write or flush while one is pending): flush the written frame, + * check the flushed stream with an empty write, then decide to ask for the next frame. When + * the output is not ready the container calls {@code onWritePossible} later. + * @return whether to ask for the next frame now + */ + private boolean readyForNext() throws IOException { + while (output.isReady()) { + if (flushPending) { + output.flush(); + flushPending = false; + probeNeeded = true; + } + else if (probeNeeded) { + output.write(NO_BYTES); + probeNeeded = false; + } + else if (requested) { + return false; + } + else { + requested = true; + return true; + } + } + return false; + } + + /** What to do once the lock is released: the endpoint is never called under it. */ + private enum Step { + + REQUEST, WAIT, CANCEL_AND_COMPLETE, NONE + + } + + /** Under the lock: marks the writer done; the caller then cancels and completes. */ + private Step stopLocked() { + if (done) { + return Step.NONE; + } + done = true; + return Step.CANCEL_AND_COMPLETE; + } + + private void take(Step step) { + Subscription current; + lock.lock(); + try { + current = subscription; + } + finally { + lock.unlock(); + } + switch (step) { + case REQUEST -> { + if (current != null) { + current.request(1); + } + } + case CANCEL_AND_COMPLETE -> { + // The client is gone: the endpoint keeps what was not written for its next GET. + if (current != null) { + current.cancel(); + } + StreamableHttpAcpServlet.complete(asyncContext); + } + default -> { + // Wait for onWritePossible, or nothing left to do. + } + } + } + + @Override + public void onError(Throwable error) { + // The endpoint's frames failed, or (WriteListener) the container failed a write. + Step step; + lock.lock(); + try { + step = stopLocked(); + } + finally { + lock.unlock(); + } + take(step); + } + + @Override + public void onComplete() { + lock.lock(); + try { + if (done) { + return; + } + done = true; + } + finally { + lock.unlock(); + } + StreamableHttpAcpServlet.complete(asyncContext); + } + + /** The container ended the response (client gone, completed, timed out). */ + private void stop() { + Step step; + lock.lock(); + try { + step = stopLocked(); + } + finally { + lock.unlock(); + } + take(step); + } + + @Override + public void onComplete(AsyncEvent event) { + stop(); + } + + @Override + public void onTimeout(AsyncEvent event) { + stop(); + } + + @Override + public void onError(AsyncEvent event) { + stop(); + } + + @Override + public void onStartAsync(AsyncEvent event) { + event.getAsyncContext().addListener(this); + } + +} diff --git a/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java new file mode 100644 index 00000000..8d4bf25f --- /dev/null +++ b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java @@ -0,0 +1,332 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.util.Assert; +import jakarta.servlet.AsyncContext; +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * The ACP endpoint as a servlet, for serving remote clients from a Servlet 6 container you + * already run, such as Spring Boot, Tomcat, Jetty or Undertow. It serves the Streamable HTTP + * profile and, where the container has Jakarta WebSocket 2.1, WebSocket on the same path: a + * request carrying {@code Upgrade: websocket} is upgraded by the servlet itself + * ({@code ServerContainer.upgradeHttpToWebSocket}), so there is no second server and the + * container's filters (security, observation, access logs) see the handshake too. It creates + * a fresh agent for each client connection with an {@link AcpAgentFactory}. + * + *

    Register it at the path of your choice, with async support on: + * + *

    {@code
    + * void register(jakarta.servlet.ServletContext servletContext, AcpAgentFactory agentFactory) {
    + *     var servlet = new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), agentFactory);
    + *     jakarta.servlet.ServletRegistration.Dynamic registration =
    + *         servletContext.addServlet("acp", servlet);
    + *     registration.addMapping("/acp");
    + *     registration.setAsyncSupported(true);
    + * }
    + * }
    + * + *

    The servlet is a host of {@link AcpHttpEndpoint}, which holds every protocol rule: the + * status table, routing, the SSE mailboxes and keep-alive, the WebSocket send queue and close + * codes, and the {@code Origin} check (a browser request from an origin other than a loopback + * one is refused with 403 unless listed in + * {@link StreamableHttpAcpAgentTransportOptions.Builder#allowedOrigins allowedOrigins}). The + * servlet only adapts I/O: requests through the container's async and non-blocking output + * APIs, SSE streams that never time out ({@code AsyncContext.setTimeout(0)}), and WebSocket + * frames through a Jakarta {@code Endpoint}. A container without a Jakarta WebSocket + * implementation answers an upgrade request 501. The servlet has no authentication of its own: + * protect its path as you would any other endpoint; the authenticated principal reaches the + * endpoint. + * + *

    The container's lifecycle drives the servlet: {@link #init()} starts the SSE keep-alive + * and {@link #destroy()} closes every connection. + * + *

    Close before a graceful container shutdown. Every SSE stream and WebSocket a client + * holds open is a request in flight, and a container that shuts down gracefully waits for + * those before it destroys servlets. Spring Boot shuts down gracefully by default, so with a + * client connected it waits its whole {@code spring.lifecycle.timeout-per-shutdown-phase} + * (30 seconds) before {@code destroy()} is even called. Call {@link #closeGracefully()} before + * the server stops instead, for instance from a {@code SmartLifecycle} in the default phase: + * SSE streams get a closing comment and complete, and WebSockets close with 1001. + * + * @author Kaiser Dandangi + * @author Mark Pollack + */ +public class StreamableHttpAcpServlet extends HttpServlet { + + private static final long serialVersionUID = 1L; + + private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpServlet.class); + + private final transient AcpHttpEndpoint endpoint; + + /** + * Creates a servlet with the default limits and timings and the default JSON mapper + * ({@link AcpJsonMapper#createDefault()}). + * @param agentFactory creates the agent for each connection + * @throws IllegalArgumentException if {@code agentFactory} is null + */ + public StreamableHttpAcpServlet(AcpAgentFactory agentFactory) { + this(AcpJsonMapper.createDefault(), agentFactory); + } + + /** + * Creates a servlet with the default limits and timings. + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @throws IllegalArgumentException if an argument is null + */ + public StreamableHttpAcpServlet(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { + this(jsonMapper, agentFactory, StreamableHttpAcpAgentTransportOptions.defaults()); + } + + /** + * Creates a servlet with the limits and timings of {@code options}. + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @param options the endpoint's limits and timings; {@code host}, + * {@code maxConcurrentStreamsPerConnection} and the thread settings are the container's + * to configure when this servlet is mounted in a container + * @throws IllegalArgumentException if an argument is null + */ + public StreamableHttpAcpServlet(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, + StreamableHttpAcpAgentTransportOptions options) { + this(AcpHttpEndpoint.create(jsonMapper, agentFactory, options)); + } + + /** + * Creates a servlet that serves an endpoint created elsewhere, such as one a framework wraps + * for observation. + * @param endpoint the endpoint + * @throws IllegalArgumentException if {@code endpoint} is null + */ + @UnstableAcpApi + public StreamableHttpAcpServlet(AcpHttpEndpoint endpoint) { + Assert.notNull(endpoint, "The endpoint can not be null"); + this.endpoint = endpoint; + } + + /** + * Returns the endpoint this servlet serves. + * @return the endpoint + */ + @UnstableAcpApi + public AcpHttpEndpoint endpoint() { + return endpoint; + } + + /** + * Starts the SSE keep-alive. The container calls it when it puts the servlet into service. + */ + @Override + public void init() throws ServletException { + super.init(); + endpoint.start(); + } + + /** + * Closes every connection, as {@link #closeGracefully()} does, within the shutdown timeout. + * The container calls it when it takes the servlet out of service. + */ + @Override + public void destroy() { + try { + // closeGracefully() is itself bounded by the shutdown timeout; the margin only + // guards against a close that does not even get to start. + endpoint.closeGracefully().block(endpoint.options().shutdownTimeout().plusSeconds(1)); + } + catch (RuntimeException e) { + logger.warn("Streamable HTTP servlet did not close within {}: {}", endpoint.options().shutdownTimeout(), + e.getMessage()); + } + super.destroy(); + } + + /** + * Closes every connection this servlet holds: new connections are refused, an + * {@code initialize} still in flight is answered 503, in-flight prompts are cancelled, SSE + * responses get a closing comment and complete, and WebSockets close with 1001. A connection + * whose agent has not closed within the + * {@linkplain StreamableHttpAcpAgentTransportOptions#shutdownTimeout() shutdown timeout} is + * closed at once. Nothing here waits for a client. Only the first call has an effect. + * @return a Mono that completes when every connection has closed, at the latest after the + * shutdown timeout + */ + public Mono closeGracefully() { + return endpoint.closeGracefully(); + } + + /** + * Sets the handler for the transport errors of every connection this servlet holds, + * including those opened before the call. The default logs them. + * @param handler receives the connections' transport errors + */ + public void setExceptionHandler(Consumer handler) { + endpoint.setExceptionHandler(handler); + } + + /** + * Returns the number of client connections this servlet holds, HTTP and WebSocket, not + * counting those whose {@code initialize} is still being answered. + * @return the number of open connections + */ + public int activeConnectionCount() { + return endpoint.activeConnectionCount(); + } + + /** + * Hands every request to the endpoint and writes its answer: a WebSocket upgrade request to + * the endpoint's handshake, any other to {@link AcpHttpEndpoint#handle}. A reply that is + * ready at once is written on the request thread; one that is not (an {@code initialize} + * waiting for the agent) and every SSE stream continue asynchronously. + */ + @Override + @SuppressWarnings("FutureReturnValueIgnored") // the callback writes and completes the response itself + protected void service(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + ServletExchange exchange = new ServletExchange(request); + if (exchange.isWebSocketUpgrade()) { + upgrade(request, response, exchange); + return; + } + CompletableFuture<@Nullable AcpHttpReply> reply = endpoint.handle(exchange).toFuture(); + if (reply.isDone() && !reply.isCompletedExceptionally()) { + AcpHttpReply answer = reply.join(); + if (answer != null) { + write(request, response, answer, null); + return; + } + } + AsyncContext asyncContext = request.startAsync(); + // The endpoint bounds the wait itself (initialize: 30 seconds). + asyncContext.setTimeout(0); + asyncContext.addListener(new CancelOnFailure(reply)); + reply.whenComplete((answer, error) -> { + try { + if (answer != null) { + write(request, response, answer, asyncContext); + return; + } + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + complete(asyncContext); + } + catch (IOException | RuntimeException e) { + logger.debug("Failed to write an ACP reply: {}", e.toString()); + complete(asyncContext); + } + }); + } + + private void upgrade(HttpServletRequest request, HttpServletResponse response, ServletExchange exchange) + throws ServletException, IOException { + AcpWsHandshake handshake = endpoint.webSocketHandshake(exchange); + if (handshake instanceof AcpWsHandshake.Refused refused) { + write(request, response, refused.reply(), null); + return; + } + if (!JakartaWebSocketUpgrade.upgrade(request, response, (AcpWsHandshake.Accepted) handshake)) { + writeText(response, HttpServletResponse.SC_NOT_IMPLEMENTED, + "WebSocket is not available in this servlet container"); + } + } + + private static void write(HttpServletRequest request, HttpServletResponse response, AcpHttpReply reply, + @Nullable AsyncContext asyncContext) throws IOException { + if (reply instanceof AcpHttpReply.EventStream stream) { + AsyncContext context = (asyncContext != null) ? asyncContext : request.startAsync(); + // An SSE stream lives as long as the connection; the endpoint's keep-alive comments, + // not a container timeout, keep it healthy. + context.setTimeout(0); + response.setStatus(HttpServletResponse.SC_OK); + response.setContentType(AcpHttpReply.EVENT_STREAM); + stream.headers().forEach(response::setHeader); + stream.frames().subscribe(new ServletSseWriter(context, response.getOutputStream())); + return; + } + try { + response.setStatus(reply.status()); + reply.headers().forEach(response::setHeader); + if (reply instanceof AcpHttpReply.Body body) { + response.setContentType(body.contentType()); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + byte[] bytes = body.bodyBytes(); + response.setContentLength(bytes.length); + response.getOutputStream().write(bytes); + } + } + finally { + if (asyncContext != null) { + complete(asyncContext); + } + } + } + + private static void writeText(HttpServletResponse response, int status, String body) throws IOException { + response.setStatus(status); + response.setContentType("text/plain"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); + } + + static void complete(AsyncContext asyncContext) { + try { + asyncContext.complete(); + } + catch (IllegalStateException ignored) { + // Already completed, or completed by the container after a client disconnect. + } + } + + /** Abandons a pending reply when the container fails the request (the client went away). */ + private static final class CancelOnFailure implements AsyncListener { + + private final CompletableFuture<@Nullable AcpHttpReply> reply; + + CancelOnFailure(CompletableFuture<@Nullable AcpHttpReply> reply) { + this.reply = reply; + } + + @Override + public void onComplete(AsyncEvent event) { + } + + @Override + public void onTimeout(AsyncEvent event) { + reply.cancel(false); + } + + @Override + public void onError(AsyncEvent event) { + reply.cancel(false); + } + + @Override + public void onStartAsync(AsyncEvent event) { + } + + } + +} diff --git a/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java new file mode 100644 index 00000000..170461ab --- /dev/null +++ b/acp-http-servlet/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The servlet host of the ACP endpoint (acp-http-servlet); this module's part of the package + * shared with acp-core, acp-http-core and acp-streamable-http-jetty. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.agent.transport; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgradeTest.java b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgradeTest.java new file mode 100644 index 00000000..b0999fc4 --- /dev/null +++ b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/JakartaWebSocketUpgradeTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.http.server.AcpWsHandler; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.http.server.AcpWsOutbound; +import jakarta.websocket.CloseReason; +import jakarta.websocket.EndpointConfig; +import jakarta.websocket.RemoteEndpoint; +import jakarta.websocket.SendHandler; +import jakarta.websocket.SendResult; +import jakarta.websocket.Session; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The Jakarta WebSocket endpoint against a mocked session, for the paths a real container takes + * only by timing: a close that fails at once, a close the container reports later, a failed + * send, and events that arrive before the socket opened. + */ +class JakartaWebSocketUpgradeTest { + + private final RecordingHandshake handshake = new RecordingHandshake(); + + private final JakartaWebSocketUpgrade.AcpEndpoint endpoint = new JakartaWebSocketUpgrade.AcpEndpoint(handshake); + + private final Session session = mock(Session.class); + + private final RemoteEndpoint.Async remote = mock(RemoteEndpoint.Async.class); + + @Test + void theContainerIdleTimeoutIsTheHandshakes() { + open(); + verify(session).setMaxIdleTimeout(Duration.ofSeconds(9).toMillis()); + } + + @Test + void aCloseThatFailsCompletesAtOnce() throws Exception { + doThrow(new IOException("broken pipe")).when(session).close(any(CloseReason.class)); + AcpWsOutbound outbound = open(); + + CompletableFuture closed = outbound.close(1001, "idle timeout").toCompletableFuture(); + + assertThat(closed).isCompleted(); + } + + @Test + void aCloseCompletesWhenTheContainerReportsTheSocketClosed() { + AcpWsOutbound outbound = open(); + + CompletableFuture closed = outbound.close(1001, "idle timeout").toCompletableFuture(); + assertThat(closed).isNotDone(); + + endpoint.onClose(session, new CloseReason(CloseReason.CloseCodes.GOING_AWAY, "idle timeout")); + assertThat(closed).isCompleted(); + assertThat(handshake.handler.closes).containsExactly(1001); + } + + @Test + void aFailedSendFailsItsStage() { + SendResult failed = new SendResult(new IOException("reset")); + doAnswer(invocation -> { + invocation.getArgument(1).onResult(failed); + return null; + }).when(remote).sendText(anyString(), any(SendHandler.class)); + AcpWsOutbound outbound = open(); + + assertThat(outbound.sendText("{}").toCompletableFuture()).isCompletedExceptionally(); + } + + @Test + void anErrorReachesTheHandler() { + open(); + IOException error = new IOException("corrupt frame"); + + endpoint.onError(session, error); + + assertThat(handshake.handler.errors).containsExactly(error); + } + + @Test + void eventsBeforeTheSocketOpenedAreIgnored() { + endpoint.onError(session, new IOException("early")); + endpoint.onClose(session, new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "early")); + + assertThat(handshake.handler.errors).isEmpty(); + assertThat(handshake.handler.closes).isEmpty(); + } + + private AcpWsOutbound open() { + when(session.getAsyncRemote()).thenReturn(remote); + endpoint.onOpen(session, mock(EndpointConfig.class)); + AcpWsOutbound outbound = handshake.outbound.get(); + assertThat(outbound).isNotNull(); + return outbound; + } + + private static final class RecordingHandshake implements AcpWsHandshake.Accepted { + + final AtomicReference<@Nullable AcpWsOutbound> outbound = new AtomicReference<>(); + + final RecordingHandler handler = new RecordingHandler(); + + @Override + public Map headers() { + return Map.of(); + } + + @Override + public long maxTextMessageBytes() { + return 1024; + } + + @Override + public Duration idleTimeout() { + return Duration.ofSeconds(9); + } + + @Override + public AcpWsHandler open(AcpWsOutbound opened) { + outbound.set(opened); + return handler; + } + + } + + private static final class RecordingHandler implements AcpWsHandler { + + final List closes = new CopyOnWriteArrayList<>(); + + final List errors = new CopyOnWriteArrayList<>(); + + @Override + public void onText(String text) { + } + + @Override + public void onClose(int code, @Nullable String reason) { + closes.add(code); + } + + @Override + public void onError(Throwable error) { + errors.add(error); + } + + } + +} diff --git a/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/ServletHostArchitectureTest.java b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/ServletHostArchitectureTest.java new file mode 100644 index 00000000..faeb3b8c --- /dev/null +++ b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/ServletHostArchitectureTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.util.Assert; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * The servlet is a host: it adapts I/O and depends only on the host contract + * ({@code com.agentclientprotocol.sdk.http.server}) and the servlet and Jakarta WebSocket APIs. + * It holds no protocol rule: no JSON-RPC message, routing, session or connection class. + */ +@AnalyzeClasses(locations = ServletHostArchitectureTest.ThisModule.class) +class ServletHostArchitectureTest { + + @ArchTest + static final ArchRule dependsOnlyOnTheContract = classes().should() + .onlyDependOnClassesThat(resideInAnyPackage("java..", "jakarta.servlet..", "jakarta.websocket..", "org.slf4j..", + "reactor..", "org.reactivestreams..", "org.jspecify..", "com.agentclientprotocol.sdk.http.server..", + "com.agentclientprotocol.sdk.agent.transport..") + .or(belongToAnyOf(AcpAgentFactory.class, AcpJsonMapper.class, Assert.class, UnstableAcpApi.class)) + .as("the host contract, the servlet and Jakarta WebSocket APIs, and the SDK's entry types")) + .because("all protocol semantics live in the endpoint; a host only adapts I/O"); + + @ArchTest + static final ArchRule holdsNoProtocolRules = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("com.agentclientprotocol.sdk.spec..") + .or(belongToAnyOf(RemoteAcpConnection.class))) + .because("JSON-RPC messages, routing and connections are the endpoint's"); + + @ArchTest + static final ArchRule isContainerNeutral = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("org.eclipse.jetty..", "org.apache.catalina..", "org.apache.tomcat..", + "io.undertow..")) + .because("the servlet must stay mountable in any Servlet 6 container"); + + /** Imports this module's main classes only. */ + public static final class ThisModule implements LocationProvider { + + @Override + public Set get(Class testClass) { + try { + Path root = Path.of(StreamableHttpAcpServlet.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletHostTest.java b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletHostTest.java new file mode 100644 index 00000000..c5115249 --- /dev/null +++ b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletHostTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; +import org.apache.catalina.connector.Connector; +import org.apache.catalina.startup.Tomcat; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the transport TCK does not reach on Tomcat: a reply the endpoint is not ready with at once + * (an {@code initialize} waiting for a slow agent) is written asynchronously, and a container + * without Jakarta WebSocket answers an upgrade 501. Tomcat here has no WebSocket initializer. + */ +class TomcatServletHostTest { + + private static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":"init","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"""; + + private final AcpAgentFactory slowAgents = AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.delay(Duration.ofMillis(200)).thenReturn(AcpSchema.InitializeResponse.ok())) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build()); + + private final List errors = new CopyOnWriteArrayList<>(); + + private Tomcat tomcat; + + private StreamableHttpAcpServlet servlet; + + private int port; + + @BeforeEach + void start() throws Exception { + Path base = Files.createTempDirectory("acp-tomcat"); + tomcat = new Tomcat(); + tomcat.setBaseDir(base.toString()); + Connector connector = new Connector(); + connector.setPort(0); + connector.setProperty("address", "127.0.0.1"); + tomcat.setConnector(connector); + Context context = tomcat.addContext("", base.toString()); + servlet = new StreamableHttpAcpServlet(slowAgents); + servlet.setExceptionHandler(errors::add); + Wrapper wrapper = Tomcat.addServlet(context, "acp", servlet); + wrapper.setAsyncSupported(true); + wrapper.setLoadOnStartup(1); + context.addServletMappingDecoded("/acp", "acp"); + tomcat.start(); + port = connector.getLocalPort(); + } + + @AfterEach + void stop() throws Exception { + servlet.closeGracefully().block(Duration.ofSeconds(10)); + tomcat.stop(); + tomcat.destroy(); + } + + @Test + void aReplyTheAgentIsSlowWithIsWrittenAsynchronously() throws Exception { + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/acp")) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(INITIALIZE)) + .build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as("%s %s %s", response.body(), response.headers().map(), errors).isEqualTo(200); + assertThat(response.body()).contains("\"result\""); + assertThat(response.headers().firstValue("Acp-Connection-Id")).isPresent(); + assertThat(servlet.activeConnectionCount()).isEqualTo(1); + assertThat(errors).isEmpty(); + } + + @Test + void anUpgradeWithoutJakartaWebSocketIsNotImplemented() throws Exception { + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(10_000); + OutputStream out = socket.getOutputStream(); + out.write(("GET /acp HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\nUpgrade: websocket\r\n" + + "Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" + + "Sec-WebSocket-Version: 13\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + String status = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)) + .readLine(); + assertThat(status).startsWith("HTTP/1.1 501"); + } + } + +} diff --git a/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletTckTest.java b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletTckTest.java new file mode 100644 index 00000000..d5722ef6 --- /dev/null +++ b/acp-http-servlet/src/test/java/com/agentclientprotocol/sdk/agent/transport/TomcatServletTckTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; +import org.apache.catalina.connector.Connector; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.websocket.server.WsSci; + +/** + * The servlet host on embedded Tomcat 11, with Tomcat's Jakarta WebSocket implementation: the + * container that allows one outstanding WebSocket write, and whose connector times async + * requests out. + */ +class TomcatServletTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) throws Exception { + Path base = Files.createTempDirectory("acp-tomcat"); + Tomcat tomcat = new Tomcat(); + tomcat.setBaseDir(base.toString()); + Connector connector = new Connector(); + connector.setPort(0); + connector.setProperty("address", "127.0.0.1"); + connector.setAsyncTimeout(config.containerAsyncTimeout().toMillis()); + tomcat.setConnector(connector); + Context context = tomcat.addContext("", base.toString()); + context.addServletContainerInitializer(new WsSci(), null); + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), config.agents(), + config.options()); + Wrapper wrapper = Tomcat.addServlet(context, "acp", servlet); + wrapper.setAsyncSupported(true); + wrapper.setLoadOnStartup(1); + context.addServletMappingDecoded("/acp", "acp"); + tomcat.start(); + URI endpoint = URI.create("http://127.0.0.1:" + connector.getLocalPort() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() throws Exception { + // As a framework does: drain the endpoint, then stop the container. + servlet.closeGracefully().block(java.time.Duration.ofSeconds(10)); + tomcat.stop(); + tomcat.destroy(); + } + + }; + } + +} diff --git a/acp-http-servlet/src/test/resources/archunit.properties b/acp-http-servlet/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-http-servlet/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-http-servlet/src/test/resources/logback-test.xml b/acp-http-servlet/src/test/resources/logback-test.xml new file mode 100644 index 00000000..56d820ae --- /dev/null +++ b/acp-http-servlet/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/acp-http-webflux/pom.xml b/acp-http-webflux/pom.xml new file mode 100644 index 00000000..279feede --- /dev/null +++ b/acp-http-webflux/pom.xml @@ -0,0 +1,112 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-http-webflux + jar + + ACP HTTP WebFlux + The ACP endpoint as a Spring WebFlux RouterFunction, Streamable HTTP and WebSocket on one path, for any WebFlux server: Reactor Netty, Tomcat, Jetty + + + + 7.0.8 + + 0.92 + 0.83 + + + + + + org.springframework + spring-framework-bom + ${spring-framework.version} + pom + import + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-http-core + + + + org.springframework + spring-webflux + provided + + + + org.springframework + spring-context + provided + + + io.projectreactor.netty + reactor-netty-http + provided + + + + + org.apache.tomcat.embed + tomcat-embed-core + test + + + org.apache.tomcat.embed + tomcat-embed-websocket + test + + + com.agentclientprotocol + acp-test + test + + + org.junit.jupiter + junit-jupiter + test + + + com.tngtech.archunit + archunit-junit5 + test + + + org.assertj + assertj-core + test + + + io.projectreactor + reactor-test + test + + + ch.qos.logback + logback-classic + test + + + + diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/AcpWebFluxHost.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/AcpWebFluxHost.java new file mode 100644 index 00000000..7bca325a --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/AcpWebFluxHost.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.util.Optional; + +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.util.Assert; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService; +import org.springframework.web.server.ServerWebExchange; + +/** + * The ACP endpoint in a Spring WebFlux application: Streamable HTTP and WebSocket on one path of + * the application's own server (Reactor Netty, Tomcat or Jetty), as a {@link RouterFunction}. + * Because the endpoint is a route like any other, the application's {@code WebFilter}s apply to + * it: Spring Security's {@code SecurityWebFilterChain} protects it, over HTTP and on the + * WebSocket handshake, and the authenticated principal reaches the endpoint; observations and + * access logs cover it too. + * + *
    {@code
    + * AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), agentFactory,
    + *         StreamableHttpAcpAgentTransportOptions.builder().build());
    + * endpoint.start();
    + * RouterFunction acp = new AcpWebFluxHost(endpoint).routerFunction("/acp");
    + * }
    + * + *

    The host is a host of {@link AcpHttpEndpoint}, which holds every protocol rule: the status + * table, routing, the SSE mailboxes and keep-alive, the WebSocket send queue and close codes, + * and the {@code Origin} check. The host only adapts I/O: it resolves the request's principal, + * hands the request to the endpoint, writes its reply (SSE frames flushed one by one), and + * carries WebSocket frames between WebFlux's {@code WebSocketSession} and the endpoint, sending + * a frame only when WebFlux asks for one, so the endpoint's bound on pending frames holds. On + * Reactor Netty it raises the WebSocket frame limit above the endpoint's message limit, so an + * oversized message is the endpoint's to refuse (close code 1009); on Tomcat and Jetty the + * container's own WebSocket message limit applies as well. + * + *

    The application drives the endpoint's lifecycle: {@link AcpHttpEndpoint#start()} when the + * server starts, and {@link AcpHttpEndpoint#closeGracefully()} before the server's graceful + * shutdown, since open SSE streams and WebSockets never finish by themselves. The Spring Boot + * autoconfiguration does both. + * + * @author Mark Pollack + */ +public final class AcpWebFluxHost { + + private final AcpHttpEndpoint endpoint; + + /** + * Creates a host of the endpoint. + * @param endpoint the endpoint to serve + * @throws IllegalArgumentException if the endpoint is null + */ + public AcpWebFluxHost(AcpHttpEndpoint endpoint) { + Assert.notNull(endpoint, "The endpoint must not be null"); + this.endpoint = endpoint; + } + + /** + * Returns the endpoint this host serves. + * @return the endpoint + */ + public AcpHttpEndpoint endpoint() { + return endpoint; + } + + /** + * Returns a route serving the endpoint on {@code path}, every method and the WebSocket + * upgrade alike: register it as a {@code RouterFunction} bean, or combine it with the + * application's own routes. + * @param path the path, such as {@code /acp} + * @return the route + * @throws IllegalArgumentException if the path is null or empty + */ + public RouterFunction routerFunction(String path) { + Assert.hasText(path, "The path must not be empty"); + String mounted = path.startsWith("/") ? path : "/" + path; + return RouterFunctions.route(RequestPredicates.path(mounted), + request -> ServerResponse.ok().build((exchange, context) -> handle(exchange))); + } + + /** + * Answers one request on the endpoint's path, as a {@code WebHandler} would: a WebSocket + * upgrade goes to the endpoint's handshake and, if accepted, to WebFlux's WebSocket support; + * any other request to the endpoint. The principal is resolved first, so the endpoint reads it + * on the calling thread. + * @param exchange the request and its response + * @return completes when the response has been written, or the WebSocket has closed + */ + public Mono handle(ServerWebExchange exchange) { + return exchange.getPrincipal() + .map(Optional::of) + .defaultIfEmpty(Optional.empty()) + .flatMap(principal -> handle(exchange, new WebFluxExchange(exchange, principal.orElse(null)))); + } + + private Mono handle(ServerWebExchange exchange, WebFluxExchange acp) { + if (acp.isWebSocketUpgrade()) { + AcpWsHandshake handshake = endpoint.webSocketHandshake(acp); + if (handshake instanceof AcpWsHandshake.Refused refused) { + return write(exchange.getResponse(), refused.reply()); + } + AcpWsHandshake.Accepted accepted = (AcpWsHandshake.Accepted) handshake; + accepted.headers().forEach(exchange.getResponse().getHeaders()::set); + HandshakeWebSocketService webSockets = WebSocketUpgrades.service(exchange, accepted); + return webSockets.handleRequest(exchange, new WebFluxWsHandler(accepted)); + } + return endpoint.handle(acp).flatMap(reply -> write(exchange.getResponse(), reply)); + } + + static Mono write(ServerHttpResponse response, AcpHttpReply reply) { + response.setStatusCode(HttpStatus.valueOf(reply.status())); + reply.headers().forEach(response.getHeaders()::set); + if (reply instanceof AcpHttpReply.EventStream stream) { + response.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM); + // One flush per frame: an SSE event reaches the client as soon as it is written. + return response.writeAndFlushWith( + stream.frames().map(frame -> Mono.just(response.bufferFactory().wrap(frame.encode())))); + } + if (reply instanceof AcpHttpReply.Body body) { + response.getHeaders().setContentType(MediaType.parseMediaType(body.contentType() + ";charset=utf-8")); + DataBuffer buffer = response.bufferFactory().wrap(body.bodyBytes()); + return response.writeWith(Mono.just(buffer)); + } + return response.setComplete(); + } + +} diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxExchange.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxExchange.java new file mode 100644 index 00000000..b45f48f8 --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxExchange.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.security.Principal; + +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * A WebFlux request as the endpoint sees it. The body is aggregated without blocking, and no + * further than one byte past the endpoint's limit: the rest of an oversized body is never read + * into memory. The principal is the one WebFlux resolved for the exchange (Spring Security's + * authentication), resolved before the endpoint is called. + * + * @author Mark Pollack + */ +final class WebFluxExchange implements AcpHttpExchange { + + private final ServerWebExchange exchange; + + private final @Nullable Principal principal; + + WebFluxExchange(ServerWebExchange exchange, @Nullable Principal principal) { + this.exchange = exchange; + this.principal = principal; + } + + @Override + public String method() { + return exchange.getRequest().getMethod().name(); + } + + @Override + public @Nullable String header(String name) { + return exchange.getRequest().getHeaders().getFirst(name); + } + + @Override + public Mono body(long maxBytes) { + long limit = Math.min(Integer.MAX_VALUE - 8L, maxBytes + 1); + return DataBufferUtils.join(DataBufferUtils.takeUntilByteCount(exchange.getRequest().getBody(), limit)) + .map(WebFluxExchange::bytes) + .defaultIfEmpty(new byte[0]); + } + + private static byte[] bytes(DataBuffer buffer) { + try { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + return bytes; + } + finally { + DataBufferUtils.release(buffer); + } + } + + @Override + public @Nullable Principal principal() { + return principal; + } + +} diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsHandler.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsHandler.java new file mode 100644 index 00000000..a1c42781 --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsHandler.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import com.agentclientprotocol.sdk.http.server.AcpWsHandler; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import reactor.core.publisher.Mono; + +import org.springframework.web.reactive.socket.CloseStatus; +import org.springframework.web.reactive.socket.WebSocketHandler; +import org.springframework.web.reactive.socket.WebSocketMessage; +import org.springframework.web.reactive.socket.WebSocketSession; + +/** + * One accepted WebSocket: opens the endpoint's connection on the session and carries its + * events. Text messages arrive whole (WebFlux aggregates fragments) and go to the endpoint, the + * endpoint's frames go out through {@link WebFluxWsOutbound}, and the close the session reports + * goes to the endpoint, 1006 when the connection dropped without one. A message over the socket's + * own limit (on Reactor Netty, the endpoint's limit plus one) closes the socket with 1009, as + * {@link AcpWsHandshake.Accepted#maxTextMessageBytes()} has it; any other socket failure goes + * to the endpoint. + * + * @author Mark Pollack + */ +final class WebFluxWsHandler implements WebSocketHandler { + + /** The close code for a connection that dropped without a close frame. */ + static final int ABNORMAL_CLOSURE = 1006; + + /** The close code for a message over the socket's limit. */ + static final int MESSAGE_TOO_BIG = 1009; + + private final AcpWsHandshake.Accepted accepted; + + WebFluxWsHandler(AcpWsHandshake.Accepted accepted) { + this.accepted = accepted; + } + + @Override + public Mono handle(WebSocketSession session) { + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + AcpWsHandler handler = accepted.open(outbound); + session.closeStatus() + .defaultIfEmpty(CloseStatus.create(ABNORMAL_CLOSURE, "connection dropped")) + .onErrorResume(error -> Mono.just(CloseStatus.create(ABNORMAL_CLOSURE, "connection dropped"))) + .subscribe(status -> handler.onClose(status.getCode(), status.getReason())); + Mono input = session.receive() + .doOnNext(message -> { + if (message.getType() == WebSocketMessage.Type.TEXT) { + handler.onText(message.getPayloadAsText()); + } + }) + .onErrorResume(error -> { + if (WebSocketUpgrades.isMessageTooBig(error)) { + // Over the socket's own limit, which is above the endpoint's: the contract's + // close for a message the socket refuses. + outbound.close(MESSAGE_TOO_BIG, "message too big"); + } + else { + handler.onError(error); + } + return Mono.empty(); + }) + .doFinally(signal -> outbound.closed()) + .then(); + Mono output = session.send(outbound.frames().map(session::textMessage)) + .onErrorResume(error -> { + handler.onError(error); + return Mono.empty(); + }); + return Mono.when(input, output); + } + +} diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutbound.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutbound.java new file mode 100644 index 00000000..d5a8a3fd --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutbound.java @@ -0,0 +1,202 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.locks.ReentrantLock; + +import com.agentclientprotocol.sdk.http.server.AcpWsOutbound; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; + +import org.springframework.web.reactive.socket.CloseStatus; +import org.springframework.web.reactive.socket.WebSocketSession; + +/** + * Sends the endpoint's frames through a WebFlux {@link WebSocketSession}. WebFlux sends from a + * publisher and reports no completion per frame, so the publisher's demand stands for it: a + * frame is handed over only when WebFlux has asked for one, and its send completes then. The + * endpoint sends the next frame only once the previous send completed, so while the client + * reads nothing, WebFlux stops asking, the frames wait in the endpoint's bounded queue, and the + * endpoint's rule for a full queue applies. Nothing is queued here beyond the one frame waiting + * for demand. + * + * @author Mark Pollack + */ +final class WebFluxWsOutbound implements AcpWsOutbound { + + private static final Logger logger = LoggerFactory.getLogger(WebFluxWsOutbound.class); + + private final WebSocketSession session; + + /** Guards the fields below. A lock, not a monitor: senders may be virtual threads. */ + private final ReentrantLock lock = new ReentrantLock(); + + private final Flux frames; + + private @Nullable FluxSink sink; + + /** Frames WebFlux asked for and has not been given. Guarded by {@code lock}. */ + private long demand; + + /** The frame waiting for demand, and its send. Guarded by {@code lock}. */ + private @Nullable String waiting; + + private @Nullable CompletableFuture waitingSent; + + /** Whether the socket is closing or closed. Guarded by {@code lock}. */ + private boolean closed; + + WebFluxWsOutbound(WebSocketSession session) { + this.session = session; + this.frames = Flux.create(this::attach, FluxSink.OverflowStrategy.ERROR); + } + + /** + * Returns the frames to send, for {@link WebSocketSession#send}: one subscriber. + * @return the frames + */ + Flux frames() { + return frames; + } + + private void attach(FluxSink attached) { + attached.onDispose(this::disposed); + lock.lock(); + try { + this.sink = attached; + } + finally { + lock.unlock(); + } + // Called at once with the demand so far, which sends a frame already waiting. + attached.onRequest(this::request); + } + + @Override + public CompletionStage sendText(String text) { + FluxSink target; + lock.lock(); + try { + if (closed) { + return CompletableFuture.failedFuture(new IllegalStateException("The WebSocket is closed")); + } + if (waiting != null) { + return CompletableFuture.failedFuture( + new IllegalStateException("A frame is already waiting to be sent; send one at a time")); + } + if (sink == null || demand == 0) { + CompletableFuture sent = new CompletableFuture<>(); + waiting = text; + waitingSent = sent; + return sent; + } + demand--; + target = sink; + } + finally { + lock.unlock(); + } + target.next(text); + return CompletableFuture.completedFuture(null); + } + + private void request(long n) { + String text; + CompletableFuture sent; + FluxSink target; + lock.lock(); + try { + demand = demand + n < 0 ? Long.MAX_VALUE : demand + n; + text = waiting; + sent = waitingSent; + target = sink; + if (text == null || sent == null || target == null || closed) { + return; + } + demand--; + waiting = null; + waitingSent = null; + } + finally { + lock.unlock(); + } + target.next(text); + // The endpoint's next send may run here, on WebFlux's request. + sent.complete(null); + } + + /** + * Closes the socket with the endpoint's code, and only then completes the frames: completed + * first, WebFlux would end the session without a close code (1005). + */ + @Override + public CompletionStage close(int code, String reason) { + if (!markClosed()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture sent = new CompletableFuture<>(); + session.close(CloseStatus.create(code, reason)) + .doFinally(signal -> { + complete(); + sent.complete(null); + }) + .subscribe(ignored -> { + }, error -> logger.debug("Closing an ACP WebSocket failed: {}", error.toString())); + return sent; + } + + /** The socket closed: no frame will be sent any more. */ + void closed() { + markClosed(); + complete(); + } + + /** Marks the socket closed and fails the waiting send. Returns whether this call closed it. */ + private boolean markClosed() { + CompletableFuture sent; + lock.lock(); + try { + if (closed) { + return false; + } + closed = true; + sent = waitingSent; + waiting = null; + waitingSent = null; + } + finally { + lock.unlock(); + } + if (sent != null) { + sent.completeExceptionally(new IllegalStateException("The WebSocket is closed")); + } + return true; + } + + private void complete() { + FluxSink target; + lock.lock(); + try { + target = sink; + } + finally { + lock.unlock(); + } + if (target != null) { + target.complete(); + } + } + + /** WebFlux stopped sending (the socket closed or the send failed). */ + private void disposed() { + markClosed(); + } + +} diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebSocketUpgrades.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebSocketUpgrades.java new file mode 100644 index 00000000..7a4b1470 --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/WebSocketUpgrades.java @@ -0,0 +1,105 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; + +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.web.reactive.socket.server.support.HandshakeWebSocketService; +import org.springframework.web.reactive.socket.server.upgrade.ReactorNettyRequestUpgradeStrategy; +import org.springframework.web.reactive.socket.server.upgrade.StandardWebSocketUpgradeStrategy; +import org.springframework.web.server.ServerWebExchange; + +/** + * WebFlux's WebSocket support for one accepted handshake, with the socket's message limit + * above the endpoint's where the server takes one per upgrade. On Reactor Netty the frame limit + * (64 KB by default) becomes the endpoint's limit plus one, so an oversized message reaches the + * endpoint, which closes with 1009, rather than failing Netty's decoder. Other servers keep + * their WebSocket container's own limit (Tomcat 8 KB, Jetty 64 KB), and a larger message closes + * with 1009 there. Reactor Netty's types load only when Reactor Netty is on the classpath. + * + * @author Mark Pollack + */ +final class WebSocketUpgrades { + + private static final boolean REACTOR_NETTY_PRESENT = isPresent("reactor.netty.http.server.HttpServerResponse"); + + private WebSocketUpgrades() { + } + + static HandshakeWebSocketService service(ServerWebExchange exchange, AcpWsHandshake.Accepted accepted) { + if (REACTOR_NETTY_PRESENT) { + if (Netty.serves(exchange)) { + return Netty.service(accepted); + } + // WebFlux picks its upgrade by the classpath, Reactor Netty's whenever Netty is there + // and Jetty is not; on another server (Tomcat) that one fails every upgrade. + HandshakeWebSocketService detected = new HandshakeWebSocketService(); + if (Netty.isNettys(detected)) { + return new HandshakeWebSocketService(new StandardWebSocketUpgradeStrategy()); + } + return detected; + } + return new HandshakeWebSocketService(); + } + + /** + * Returns whether a socket failed on a message over its limit: on Reactor Netty, an + * aggregated message or a single frame too long. + */ + static boolean isMessageTooBig(Throwable error) { + return REACTOR_NETTY_PRESENT && Netty.isMessageTooBig(error); + } + + /** The frame limit for an endpoint limit: one byte more, within an int. */ + static int frameLimit(long maxTextMessageBytes) { + return (int) Math.min(Integer.MAX_VALUE, maxTextMessageBytes + 1); + } + + private static boolean isPresent(String className) { + try { + Class.forName(className, false, WebSocketUpgrades.class.getClassLoader()); + return true; + } + catch (ClassNotFoundException | LinkageError e) { + return false; + } + } + + /** Holds the Reactor Netty types, so they load only once Reactor Netty is known present. */ + private static final class Netty { + + static boolean serves(ServerWebExchange exchange) { + try { + return ServerHttpResponseDecorator.getNativeResponse( + exchange.getResponse()) instanceof reactor.netty.http.server.HttpServerResponse; + } + catch (IllegalArgumentException e) { + // A response with no native one underneath, such as a test's mock. + return false; + } + } + + static boolean isNettys(HandshakeWebSocketService service) { + return service.getUpgradeStrategy() instanceof ReactorNettyRequestUpgradeStrategy; + } + + static boolean isMessageTooBig(Throwable error) { + if (error instanceof io.netty.handler.codec.TooLongFrameException) { + return true; + } + return error instanceof io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException corrupted + && corrupted.closeStatus().code() == WebFluxWsHandler.MESSAGE_TOO_BIG; + } + + static HandshakeWebSocketService service(AcpWsHandshake.Accepted accepted) { + int limit = frameLimit(accepted.maxTextMessageBytes()); + return new HandshakeWebSocketService(new ReactorNettyRequestUpgradeStrategy( + () -> reactor.netty.http.server.WebsocketServerSpec.builder().maxFramePayloadLength(limit))); + } + + } + +} diff --git a/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/package-info.java b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/package-info.java new file mode 100644 index 00000000..86126d5f --- /dev/null +++ b/acp-http-webflux/src/main/java/com/agentclientprotocol/sdk/http/webflux/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Spring WebFlux host of the ACP endpoint (acp-http-webflux): {@link AcpWebFluxHost} mounts + * an {@link com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint} as a {@code RouterFunction}, + * Streamable HTTP and WebSocket on one path. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.http.webflux; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/FakeWebSocketSession.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/FakeWebSocketSession.java new file mode 100644 index 00000000..b8c7352e --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/FakeWebSocketSession.java @@ -0,0 +1,174 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscription; +import reactor.core.publisher.BaseSubscriber; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.socket.CloseStatus; +import org.springframework.web.reactive.socket.HandshakeInfo; +import org.springframework.web.reactive.socket.WebSocketMessage; +import org.springframework.web.reactive.socket.WebSocketSession; + +/** + * A WebSocket session whose client the test plays: it pushes inbound text, decides how many + * outbound frames WebFlux asks for, and sees the close. + */ +final class FakeWebSocketSession implements WebSocketSession { + + private final DataBufferFactory buffers = DefaultDataBufferFactory.sharedInstance; + + private final Sinks.Many inbound = Sinks.many().unicast().onBackpressureBuffer(); + + private final Sinks.One closeStatus = Sinks.one(); + + final List sent = new CopyOnWriteArrayList<>(); + + final List closes = new CopyOnWriteArrayList<>(); + + private final long initialDemand; + + private volatile @Nullable Subscription sending; + + FakeWebSocketSession(long initialDemand) { + this.initialDemand = initialDemand; + } + + /** The client sends a text frame. */ + void receive(String text) { + inbound.tryEmitNext(textMessage(text)).orThrow(); + } + + /** The client closes the socket. */ + void clientCloses(int code) { + closeStatus.tryEmitValue(new CloseStatus(code)).orThrow(); + inbound.tryEmitComplete().orThrow(); + } + + /** WebFlux asks for more frames, as when the client's socket drains. */ + void request(long n) { + Subscription current = sending; + if (current == null) { + throw new IllegalStateException("nothing is sending"); + } + current.request(n); + } + + @Override + public String getId() { + return "fake"; + } + + @Override + public HandshakeInfo getHandshakeInfo() { + return new HandshakeInfo(URI.create("ws://127.0.0.1/acp"), new HttpHeaders(), Mono.empty(), null); + } + + @Override + public DataBufferFactory bufferFactory() { + return buffers; + } + + @Override + public Map getAttributes() { + return new ConcurrentHashMap<>(); + } + + @Override + public Flux receive() { + return inbound.asFlux(); + } + + @Override + public Mono send(Publisher messages) { + return Mono.create(done -> { + BaseSubscriber subscriber = new BaseSubscriber<>() { + + @Override + protected void hookOnSubscribe(Subscription subscription) { + sending = subscription; + if (initialDemand > 0) { + subscription.request(initialDemand); + } + } + + @Override + protected void hookOnNext(WebSocketMessage message) { + sent.add(message.getPayloadAsText()); + } + + @Override + protected void hookOnComplete() { + done.success(); + } + + @Override + protected void hookOnError(Throwable error) { + done.error(error); + } + + }; + done.onCancel(subscriber); + messages.subscribe(subscriber); + }); + } + + @Override + public boolean isOpen() { + return closes.isEmpty(); + } + + @Override + public Mono close(CloseStatus status) { + return Mono.fromRunnable(() -> { + closes.add(status); + closeStatus.tryEmitValue(status); + inbound.tryEmitComplete(); + }); + } + + @Override + public Mono closeStatus() { + return closeStatus.asMono(); + } + + @Override + public WebSocketMessage textMessage(String payload) { + return new WebSocketMessage(WebSocketMessage.Type.TEXT, buffers.wrap(payload.getBytes(StandardCharsets.UTF_8))); + } + + @Override + public WebSocketMessage binaryMessage(Function payloadFactory) { + return new WebSocketMessage(WebSocketMessage.Type.BINARY, payloadFactory.apply(buffers)); + } + + @Override + public WebSocketMessage pingMessage(Function payloadFactory) { + return new WebSocketMessage(WebSocketMessage.Type.PING, payloadFactory.apply(buffers)); + } + + @Override + public WebSocketMessage pongMessage(Function payloadFactory) { + return new WebSocketMessage(WebSocketMessage.Type.PONG, payloadFactory.apply(buffers)); + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyHostTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyHostTest.java new file mode 100644 index 00000000..13a2d322 --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyHostTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.security.Principal; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.server.WebFilter; +import org.springframework.web.reactive.handler.WebFluxResponseStatusExceptionHandler; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * The WebFlux host on Reactor Netty, beyond the TCK: the principal a {@code WebFilter} resolved + * (as Spring Security's does) reaches the endpoint over HTTP and on the handshake; a message far + * over the limit closes with 1009 too; the route serves its own path only. + */ +class ReactorNettyHostTest { + + private static final long LIMIT = 16 * 1024; + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private final List principals = new CopyOnWriteArrayList<>(); + + private final AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), + AcpHttpTransportTck.agentFactory(), StreamableHttpAcpAgentTransportOptions.builder().maxPostBodyBytes(LIMIT).build()); + + private @Nullable DisposableServer server; + + private URI uri = URI.create("http://127.0.0.1/acp"); + + @BeforeEach + void start() { + // The principal as Spring Security's WebFilter chain hands it over, for requests that + // carry the test's header. + WebFilter authentication = (exchange, chain) -> { + String user = exchange.getRequest().getHeaders().getFirst("X-User"); + return user == null ? chain.filter(exchange) + : chain.filter(exchange.mutate().principal(Mono.just(() -> user)).build()); + }; + var handler = WebHttpHandlerBuilder + .webHandler(RouterFunctions.toWebHandler(new AcpWebFluxHost(new Recording(endpoint)).routerFunction("acp"))) + .filter(authentication) + .exceptionHandler(new WebFluxResponseStatusExceptionHandler()) + .build(); + DisposableServer started = HttpServer.create() + .host("127.0.0.1") + .port(0) + .handle(new ReactorHttpHandlerAdapter(handler)) + .bindNow(); + this.server = started; + this.uri = URI.create("http://127.0.0.1:" + started.port() + "/acp"); + } + + @AfterEach + void stop() { + endpoint.closeGracefully().block(Duration.ofSeconds(10)); + DisposableServer current = server; + if (current != null) { + current.disposeNow(Duration.ofSeconds(10)); + } + } + + @Test + void thePrincipalAWebFilterResolvedReachesTheEndpoint() throws Exception { + assertThat(HttpProbes.initialize(uri, null, Map.of("X-User", "alice")).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(uri, null, Map.of("X-User", "alice"))) + .isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + assertThat(HttpProbes.initialize(uri, null).statusCode()).isEqualTo(200); + assertThat(principals).containsExactly("alice", "alice", ""); + } + + @Test + void aMessageFarOverTheLimitClosesWith1009() throws Exception { + Socket socket = Socket.open(uri); + socket.send(HttpProbes.INITIALIZE); + socket.awaitMessage(); + socket.send("x".repeat((int) LIMIT * 4)); + assertThat(socket.closeCode()).isEqualTo(1009); + } + + @Test + void aMessageAtTheLimitIsTheEndpointsToRead() throws Exception { + Socket socket = Socket.open(uri); + socket.send(HttpProbes.INITIALIZE); + socket.awaitMessage(); + // Not JSON-RPC, so answered with a parse error; the socket stays open. + socket.send("x".repeat((int) LIMIT)); + assertThat(socket.awaitMessage()).contains("-32700"); + } + + @Test + void theRouteServesItsOwnPathOnly() throws Exception { + HttpResponse other = HTTP.send(HttpRequest.newBuilder(uri.resolve("/other")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(other.statusCode()).isEqualTo(404); + assertThat(principals).isEmpty(); + } + + @Test + void aHostNeedsAnEndpointAndARouteAPath() { + assertThatIllegalArgumentException().isThrownBy(() -> new AcpWebFluxHost(null)); + AcpWebFluxHost host = new AcpWebFluxHost(endpoint); + assertThat(host.endpoint()).isSameAs(endpoint); + assertThatIllegalArgumentException().isThrownBy(() -> host.routerFunction("")); + } + + /** A raw WebSocket client that records the first message and the close code. */ + private static final class Socket implements WebSocket.Listener { + + private final java.util.concurrent.BlockingQueue messages = new java.util.concurrent.LinkedBlockingQueue<>(); + + private final CompletableFuture closeCode = new CompletableFuture<>(); + + private final StringBuilder partial = new StringBuilder(); + + private @Nullable WebSocket socket; + + static Socket open(URI uri) throws Exception { + Socket listener = new Socket(); + listener.socket = HTTP.newWebSocketBuilder() + .buildAsync(URI.create("ws" + uri.toString().substring("http".length())), listener) + .get(10, TimeUnit.SECONDS); + return listener; + } + + void send(String text) throws Exception { + WebSocket current = socket; + if (current != null) { + current.sendText(text, true).get(10, TimeUnit.SECONDS); + } + } + + String awaitMessage() throws InterruptedException { + String message = messages.poll(10, TimeUnit.SECONDS); + if (message == null) { + throw new AssertionError("no message"); + } + return message; + } + + int closeCode() throws Exception { + return closeCode.get(10, TimeUnit.SECONDS); + } + + @Override + public @Nullable CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + partial.append(data); + if (last) { + messages.add(partial.toString()); + partial.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public @Nullable CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + closeCode.complete(statusCode); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + closeCode.complete(1006); + } + + } + + /** Records the principal of every exchange the endpoint is handed. */ + private final class Recording implements AcpHttpEndpoint { + + private final AcpHttpEndpoint delegate; + + Recording(AcpHttpEndpoint delegate) { + this.delegate = delegate; + } + + private void record(AcpHttpExchange exchange) { + Principal principal = exchange.principal(); + principals.add(principal != null ? principal.getName() : ""); + } + + @Override + public StreamableHttpAcpAgentTransportOptions options() { + return delegate.options(); + } + + @Override + public Mono handle(AcpHttpExchange exchange) { + record(exchange); + return delegate.handle(exchange); + } + + @Override + public AcpWsHandshake webSocketHandshake(AcpHttpExchange handshake) { + record(handshake); + return delegate.webSocketHandshake(handshake); + } + + @Override + public void start() { + delegate.start(); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully(); + } + + @Override + public int activeConnectionCount() { + return delegate.activeConnectionCount(); + } + + @Override + public void setExceptionHandler(Consumer handler) { + delegate.setExceptionHandler(handler); + } + + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyTckTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyTckTest.java new file mode 100644 index 00000000..7bd5d5c2 --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/ReactorNettyTckTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import reactor.netty.DisposableServer; +import reactor.netty.http.server.HttpServer; + +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.web.reactive.function.server.RouterFunctions; + +/** + * The WebFlux host's route on Reactor Netty, WebFlux's default server, under the shared + * transport TCK. + */ +class ReactorNettyTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) { + AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), config.agents(), + config.options()); + endpoint.start(); + var handler = RouterFunctions.toHttpHandler(new AcpWebFluxHost(endpoint).routerFunction("/acp")); + DisposableServer server = HttpServer.create() + .host("127.0.0.1") + .port(0) + .handle(new ReactorHttpHandlerAdapter(handler)) + .bindNow(); + URI uri = URI.create("http://127.0.0.1:" + server.port() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return uri; + } + + @Override + public void stop() { + endpoint.closeGracefully().block(Duration.ofSeconds(10)); + server.disposeNow(Duration.ofSeconds(10)); + } + + }; + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/TomcatReactiveTckTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/TomcatReactiveTckTest.java new file mode 100644 index 00000000..99d58ea0 --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/TomcatReactiveTckTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import org.apache.catalina.Context; +import org.apache.catalina.Wrapper; +import org.apache.catalina.connector.Connector; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.websocket.server.WsSci; + +import org.springframework.http.server.reactive.TomcatHttpHandlerAdapter; +import org.springframework.web.reactive.function.server.RouterFunctions; + +/** + * The WebFlux host's route on Tomcat as a WebFlux server (Spring's Tomcat adapter, WebSocket + * through Tomcat's Jakarta WebSocket), under the shared transport TCK. + */ +class TomcatReactiveTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) throws Exception { + AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), config.agents(), + config.options()); + endpoint.start(); + Path base = Files.createTempDirectory("acp-tomcat-reactive"); + Tomcat tomcat = new Tomcat(); + tomcat.setBaseDir(base.toString()); + Connector connector = new Connector(); + connector.setPort(0); + connector.setProperty("address", "127.0.0.1"); + connector.setAsyncTimeout(config.containerAsyncTimeout().toMillis()); + tomcat.setConnector(connector); + Context context = tomcat.addContext("", base.toString()); + context.addServletContainerInitializer(new WsSci(), null); + var handler = RouterFunctions.toHttpHandler(new AcpWebFluxHost(endpoint).routerFunction("/acp")); + Wrapper wrapper = Tomcat.addServlet(context, "webflux", new TomcatHttpHandlerAdapter(handler)); + wrapper.setAsyncSupported(true); + wrapper.setLoadOnStartup(1); + context.addServletMappingDecoded("/", "webflux"); + tomcat.start(); + URI uri = URI.create("http://127.0.0.1:" + connector.getLocalPort() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return uri; + } + + @Override + public void stop() throws Exception { + endpoint.closeGracefully().block(Duration.ofSeconds(10)); + tomcat.stop(); + tomcat.destroy(); + } + + }; + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxHostArchitectureTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxHostArchitectureTest.java new file mode 100644 index 00000000..dcf57dda --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxHostArchitectureTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.annotation.UnstableAcpApi; +import com.agentclientprotocol.sdk.util.Assert; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +/** + * The WebFlux host is a host: it adapts I/O and depends only on the host contract + * ({@code com.agentclientprotocol.sdk.http.server}) and Spring WebFlux. It holds no protocol + * rule: no JSON-RPC message, routing, session or connection class. Reactor Netty is touched in + * one place, which loads only when Netty is the server. + */ +@AnalyzeClasses(locations = WebFluxHostArchitectureTest.ThisModule.class) +class WebFluxHostArchitectureTest { + + @ArchTest + static final ArchRule dependsOnlyOnTheContract = classes().should() + .onlyDependOnClassesThat(resideInAnyPackage("java..", "org.springframework.web..", "org.springframework.http..", + "org.springframework.core.io.buffer..", "reactor.core..", "reactor.netty..", "io.netty..", + "org.reactivestreams..", "org.slf4j..", "org.jspecify..", "com.agentclientprotocol.sdk.http.server..", + "com.agentclientprotocol.sdk.http.webflux..") + .or(belongToAnyOf(StreamableHttpAcpAgentTransportOptions.class, Assert.class, UnstableAcpApi.class)) + .as("the host contract, Spring WebFlux, and the SDK's entry types")) + .because("all protocol semantics live in the endpoint; a host only adapts I/O"); + + @ArchTest + static final ArchRule holdsNoProtocolRules = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("com.agentclientprotocol.sdk.spec..", "com.agentclientprotocol.sdk.json..") + .or(belongToAnyOf(RemoteAcpConnection.class))) + .because("JSON-RPC messages, routing and connections are the endpoint's"); + + @ArchTest + static final ArchRule touchesNettyInOnePlace = noClasses().that() + .doNotHaveFullyQualifiedName(WebSocketUpgrades.class.getName() + "$Netty") + .should() + .dependOnClassesThat(resideInAnyPackage("reactor.netty..", "io.netty..")) + .because("Reactor Netty is one of WebFlux's servers, loaded only when it is the one serving"); + + @ArchTest + static final ArchRule isServerNeutral = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("org.eclipse.jetty..", "org.apache.catalina..", "org.apache.tomcat..", + "io.undertow..", "jakarta.servlet..")) + .because("the route must stay mountable on any WebFlux server"); + + /** Imports this module's main classes only. */ + public static final class ThisModule implements LocationProvider { + + @Override + public Set get(Class testClass) { + try { + Path root = Path.of(AcpWebFluxHost.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutboundTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutboundTest.java new file mode 100644 index 00000000..125e89a2 --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebFluxWsOutboundTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.util.concurrent.CompletableFuture; + +import io.netty.handler.codec.TooLongFrameException; +import io.netty.handler.codec.http.websocketx.CorruptedWebSocketFrameException; +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The outbound side of one socket: a frame goes out only on WebFlux's demand, one at a time, + * and nothing goes out after the close. + */ +class WebFluxWsOutboundTest { + + @Test + void aSendWaitsForDemandAndCompletesWhenTheFrameGoesOut() { + FakeWebSocketSession session = new FakeWebSocketSession(0); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + // Before WebFlux subscribes, a frame waits too. + CompletableFuture first = outbound.sendText("one").toCompletableFuture(); + Disposable sending = session.send(outbound.frames().map(session::textMessage)).subscribe(); + assertThat(first).isNotDone(); + session.request(1); + assertThat(first).isCompleted(); + assertThat(session.sent).containsExactly("one"); + sending.dispose(); + } + + @Test + void theEndpointSendsOneFrameAtATime() { + FakeWebSocketSession session = new FakeWebSocketSession(0); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + Disposable sending = session.send(outbound.frames().map(session::textMessage)).subscribe(); + CompletableFuture first = outbound.sendText("one").toCompletableFuture(); + assertThat(outbound.sendText("two").toCompletableFuture()).isCompletedExceptionally(); + assertThat(first).isNotDone(); + sending.dispose(); + } + + @Test + void demandBeyondLongMaxStaysUnbounded() { + FakeWebSocketSession session = new FakeWebSocketSession(Long.MAX_VALUE); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + Disposable sending = session.send(outbound.frames().map(session::textMessage)).subscribe(); + session.request(Long.MAX_VALUE); + for (int i = 0; i < 5; i++) { + assertThat(outbound.sendText("m" + i).toCompletableFuture()).isCompleted(); + } + assertThat(session.sent).hasSize(5); + sending.dispose(); + } + + @Test + void theCloseFailsTheWaitingSendAndEveryLaterOne() { + FakeWebSocketSession session = new FakeWebSocketSession(0); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + Disposable sending = session.send(outbound.frames().map(session::textMessage)).subscribe(); + CompletableFuture waiting = outbound.sendText("one").toCompletableFuture(); + outbound.close(1001, "going away"); + outbound.close(1000, "again"); + assertThat(waiting).isCompletedExceptionally(); + assertThat(outbound.sendText("two").toCompletableFuture()).isCompletedExceptionally(); + assertThat(session.closes).extracting(status -> status.getCode()).containsExactly(1001); + session.request(1); + assertThat(session.sent).isEmpty(); + assertThat(sending.isDisposed()).as("the frames completed after the close").isTrue(); + } + + @Test + void aSocketThatClosedSendsNothing() { + FakeWebSocketSession session = new FakeWebSocketSession(1); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + outbound.closed(); + assertThat(outbound.sendText("one").toCompletableFuture()).isCompletedExceptionally(); + outbound.close(1000, "after"); + assertThat(session.closes).isEmpty(); + } + + @Test + void aSendThatWebFluxCancelledFailsTheWaitingFrame() { + FakeWebSocketSession session = new FakeWebSocketSession(0); + WebFluxWsOutbound outbound = new WebFluxWsOutbound(session); + Disposable sending = session.send(outbound.frames().map(session::textMessage)).subscribe(); + CompletableFuture waiting = outbound.sendText("one").toCompletableFuture(); + sending.dispose(); + assertThat(waiting).isCompletedExceptionally(); + } + + @Test + void nettysRefusalsOfAnOversizedMessageAreTooBig() { + assertThat(WebSocketUpgrades.isMessageTooBig(new TooLongFrameException("too long"))).isTrue(); + assertThat(WebSocketUpgrades.isMessageTooBig( + new CorruptedWebSocketFrameException(WebSocketCloseStatus.MESSAGE_TOO_BIG, "too big"))).isTrue(); + assertThat(WebSocketUpgrades.isMessageTooBig( + new CorruptedWebSocketFrameException(WebSocketCloseStatus.PROTOCOL_ERROR, "bad frame"))).isFalse(); + assertThat(WebSocketUpgrades.isMessageTooBig(new IllegalStateException("other"))).isFalse(); + } + + @Test + void theFrameLimitIsOneByteOverTheEndpointsWithinAnInt() { + assertThat(WebSocketUpgrades.frameLimit(65_536)).isEqualTo(65_537); + assertThat(WebSocketUpgrades.frameLimit(Long.MAX_VALUE - 1)).isEqualTo(Integer.MAX_VALUE); + } + +} diff --git a/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebSocketBackpressureTest.java b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebSocketBackpressureTest.java new file mode 100644 index 00000000..2b1a86ea --- /dev/null +++ b/acp-http-webflux/src/test/java/com/agentclientprotocol/sdk/http/webflux/WebSocketBackpressureTest.java @@ -0,0 +1,164 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.http.webflux; + +import java.security.Principal; +import java.time.Duration; +import java.util.Map; +import java.util.function.BooleanSupplier; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.Disposable; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WebFlux reports no completion per WebSocket frame, so the host sends a frame only when WebFlux + * asks for one. A client that reads nothing therefore leaves the agent's frames in the + * endpoint's bounded queue, and the endpoint's rule for a full queue applies: the socket closes + * with 1011, rather than the frames piling up in the host without bound. + */ +class WebSocketBackpressureTest { + + private static final int PENDING_FRAMES = 4; + + private static final String NEW_SESSION = """ + {"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/","mcpServers":[]}}"""; + + private final AcpHttpEndpoint endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), + AcpHttpTransportTck.agentFactory(), + StreamableHttpAcpAgentTransportOptions.builder().maxWebSocketPendingFrames(PENDING_FRAMES).build()); + + private @Nullable Disposable running; + + @AfterEach + void close() { + Disposable current = running; + if (current != null) { + current.dispose(); + } + endpoint.closeGracefully().block(Duration.ofSeconds(10)); + } + + private FakeWebSocketSession open(long initialDemand) { + AcpWsHandshake handshake = endpoint.webSocketHandshake(new Upgrade()); + assertThat(handshake).isInstanceOf(AcpWsHandshake.Accepted.class); + FakeWebSocketSession session = new FakeWebSocketSession(initialDemand); + running = new WebFluxWsHandler((AcpWsHandshake.Accepted) handshake).handle(session).subscribe(); + return session; + } + + private static String prompt(String text) { + return "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/prompt\",\"params\":{\"sessionId\":\"s-1\"," + + "\"prompt\":[{\"type\":\"text\",\"text\":\"" + text + "\"}]}}"; + } + + @Test + void aClientThatReadsNothingOverflowsTheEndpointsQueueAndTheSocketClosesWith1011() { + FakeWebSocketSession session = open(2); + session.receive(HttpProbes.INITIALIZE); + session.receive(NEW_SESSION); + waitUntil(() -> session.sent.size() == 2); + // Far more updates than the queue holds; WebFlux asks for none of them. + session.receive(prompt("updates:100")); + waitUntil(() -> !session.closes.isEmpty()); + assertThat(session.closes).hasSize(1); + assertThat(session.closes.get(0).getCode()).isEqualTo(1011); + assertThat(session.sent).as("nothing beyond what WebFlux asked for").hasSize(2); + } + + @Test + void aFrameWaitsForDemandAndGoesOutWhenWebFluxAsks() throws InterruptedException { + FakeWebSocketSession session = open(1); + session.receive(HttpProbes.INITIALIZE); + waitUntil(() -> session.sent.size() == 1); + session.receive(NEW_SESSION); + session.receive(prompt("updates:3")); + Thread.sleep(200); + assertThat(session.sent).hasSize(1); + session.request(Long.MAX_VALUE); + // session/new's answer, three updates and the prompt's answer. + waitUntil(() -> session.sent.size() == 6); + assertThat(session.sent.get(1)).contains("\"id\":2").contains("s-1"); + assertThat(session.sent.get(5)).contains("\"id\":3").contains("end_turn"); + assertThat(session.closes).isEmpty(); + } + + @Test + void theEndpointsCloseCodeGoesOutBeforeTheFramesComplete() { + FakeWebSocketSession session = open(Long.MAX_VALUE); + // The first message must be initialize: the endpoint closes with 1002. + session.receive(NEW_SESSION); + waitUntil(() -> !session.closes.isEmpty()); + assertThat(session.closes).extracting(status -> status.getCode()).containsExactly(1002); + } + + @Test + void aClientCloseClosesTheConnection() { + FakeWebSocketSession session = open(Long.MAX_VALUE); + session.receive(HttpProbes.INITIALIZE); + waitUntil(() -> session.sent.size() == 1); + assertThat(endpoint.activeConnectionCount()).isEqualTo(1); + session.clientCloses(1000); + waitUntil(() -> endpoint.activeConnectionCount() == 0); + assertThat(session.closes).as("the client closed; the host sends no close of its own").isEmpty(); + } + + private static void waitUntil(BooleanSupplier condition) { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new AssertionError("timed out"); + } + + try { + Thread.sleep(10); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + } + + /** A WebSocket upgrade request with no Origin, as a non-browser client sends it. */ + private static final class Upgrade implements AcpHttpExchange { + + private static final Map HEADERS = Map.of("upgrade", "websocket"); + + @Override + public String method() { + return "GET"; + } + + @Override + public @Nullable String header(String name) { + return HEADERS.get(name.toLowerCase(java.util.Locale.ROOT)); + } + + @Override + public Mono body(long maxBytes) { + return Mono.just(new byte[0]); + } + + @Override + public @Nullable Principal principal() { + return null; + } + + } + + +} diff --git a/acp-http-webflux/src/test/resources/archunit.properties b/acp-http-webflux/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-http-webflux/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-http-webflux/src/test/resources/logback-test.xml b/acp-http-webflux/src/test/resources/logback-test.xml new file mode 100644 index 00000000..56d820ae --- /dev/null +++ b/acp-http-webflux/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/acp-integration/README.md b/acp-integration/README.md new file mode 100644 index 00000000..4cdebc1a --- /dev/null +++ b/acp-integration/README.md @@ -0,0 +1,134 @@ +# acp-integration + +The framework-neutral half of the ACP Java SDK's framework integrations. The Spring Boot +autoconfiguration, the Micronaut integration and the Quarkus extension are each a thin layer over +this module: they bind their own configuration, find the `@AcpAgent` bean their own way, and call +the hosts here from their own lifecycle hooks. Everything that does not depend on a framework lives +here, once. + +Use it to integrate ACP with another container. An application on Spring Boot, Micronaut or +Quarkus does not use it directly, except for `AcpClientCustomizer`. + +```xml + + com.agentclientprotocol + acp-integration + + + + com.agentclientprotocol + acp-streamable-http-jetty + +``` + +The module depends on `acp-core` and `acp-agent-support`, with `acp-streamable-http-jetty` +optional, and on no framework; an ArchUnit test enforces both, and that the SDK below does not +depend on it. Package `com.agentclientprotocol.sdk.integration`, null-marked. + +## Settings + +`AcpAgentSettings` and `AcpClientSettings` are immutable records with builders. A value left unset +keeps the SDK's default, so no default is decided twice. A framework binds its configuration onto +the builders (Quarkus `@ConfigMapping`, Spring and Micronaut `@ConfigurationProperties`), or reads +plain key/value configuration with `from(SettingsSource, prefix)`: + +| Agent key | Default | +|---|---| +| `enabled` | `true` | +| `request-timeout`, `cancel-grace-period`, `max-prompt-duration` | SDK defaults | +| `shutdown-on-transport-end` | `true`: stop the application when stdio input ends | +| `transport.type` | `stdio`; `http` (or `websocket`, the same) for Streamable HTTP and WebSocket. The SDK's listener takes WebSocket upgrades on the endpoint's path; the servlet in a framework's Servlet container does not, so there `websocket` serves HTTP/SSE only, unless the framework routes the upgrades itself (Quarkus does) | +| `transport.http.path` | `/acp` | +| `transport.http.max-post-body-size`, `keep-alive-interval`, `mailbox-capacity`, `max-pending-sse-events`, `max-web-socket-pending-frames`, `max-provisional-sessions`, `shutdown-timeout`, `web-socket-idle-timeout`, `initialize-timeout` | SDK defaults | +| `transport.http.listener.port` | `8080`, the SDK listener only; `0` for an ephemeral port | +| `transport.http.listener.max-concurrent-streams-per-connection` | SDK default | + +| Client key | Default | +|---|---| +| `request-timeout` | SDK default | +| `prompt-timeout` | none | +| `transport.type` | inferred (below) | +| `transport.stdio.command`, `args`, `env` | the agent process to start | +| `transport.websocket.uri`, `connect-timeout` | `10s` | +| `transport.http.uri` | | +| `capabilities.read-text-file`, `write-text-file`, `terminal`, `elicitation-form`, `elicitation-url`, `boolean-config-options` | `false` | + +Advertise a capability only together with the handler for it, registered through an +`AcpClientCustomizer`. + +## Transports + +`AcpClientTransports.create(settings, prefix)` applies one rule in every framework: an explicit +`transport.type` wins and fails without its command or URI; otherwise exactly one of +`stdio.command`, `websocket.uri` and `http.uri` selects the transport, and more than one fails, +naming them; with none there is no client. Error messages name the framework's own keys. + +`AcpAgentTransports.stdio()` is the stdio agent transport. `AcpListeners` creates the endpoints +of the optional `acp-streamable-http-jetty` module: `listener(settings, factory)` for the SDK's own +listener (HTTP, cleartext HTTP/2 and WebSocket on one path) and `servlet(settings, factory)` for a +framework's Servlet container; `isListenerAvailable()` says whether the listener and its Jetty +server are on the classpath. The module's types appear in `AcpListeners`, `AcpListenerHost`, +`AcpServletHost` and `AcpAgentSettings.toOptions`. A framework without the module still loads +these classes, `AcpAgentSettings` included, because the JVM resolves those types only when code +that uses them runs: call them only when the agent is served over HTTP, and call +`isListenerAvailable()` before `listener` to report a missing module in the framework's own +words. + +## The agent + +```java +List> candidates = ...; // the container's @AcpAgent beans: user class, instance supplier +AcpAgentDiscovery.requireSingle(candidates, "my.acp.agent.enabled").ifPresent(agent -> { + AcpAgentSupport.Builder builder = AcpAgents.builder(agent, settings, interceptors, resolvers, handlers); + AcpHost host = settings.servesHttp() + ? new AcpListenerHost(AcpListeners.listener(settings, builder.buildFactory())) + : new AcpAgentHost(builder.transport(AcpAgentTransports.stdio()).build(), + settings.shutdownOnTransportEnd() ? container::close : () -> { }); + host.start(); // from the container's start hook + // host.stop(Duration.ofSeconds(30)) // from its stop hook, or a JVM shutdown hook +}); +``` + +- `AcpAgentDiscovery.requireSingle`: none is a client-only application, more than one an error + naming them. A candidate carries the user class from the container's metadata, never a proxy + class, and a supplier of the bean, which may be a proxy and keeps its advice. A build-time form + takes class names (a Jandex index) and loads nothing. +- `AcpAgents.builder`: handlers discovered on the user class, the settings' timeouts, then the + interceptors, argument resolvers and return value handlers in order. +- `AcpHost`: `start()`, `stopGracefully()`, `stop(Duration)` (safe from a shutdown hook, also + while `start()` runs), `termination()`, `port()`, and `holdJvmUntilTermination()` for an + application with nothing else keeping the JVM up. Nothing blocks except `stop`, so a framework + whose `main` returns can host an agent too. +- `AcpAgentHost`: one agent on one transport, on the SDK's own lifecycle (`AcpAgentSupport`'s + `start()` and `close()`, its agent's `awaitTermination()`). When the transport ends by itself, + the `onTransportEnd` action given at construction runs once, on a thread of the host's own, + never when the host stopped the agent itself. +- `AcpListenerHost`: the SDK listener, one agent per connection; it ends only when stopped. +- `AcpServletHost.closeBeforeShutdown`: closes the servlet's connections before the container's + graceful shutdown, which would otherwise wait for every open SSE response. + +## The client + +`AcpClients.async(transport, settings, customizers)` builds the one async client: capabilities, +request and prompt timeouts, a session-update handler that logs each update at DEBUG, its content +included, then the +`AcpClientCustomizer`s in order. Building the client connects the transport (for stdio, it starts +the agent process); a stdio command that cannot be started, or a transport already connected, +fails the build. The ACP handshake waits for the application's `initialize()`. +`AcpClients.sync(async)` is the sync facade over that same client +(one session on one connection). `AcpClientHost` closes it once, gracefully, then at once after +its timeout (`settings.closeTimeout()`); the framework must not close the client or its transport +again through an inferred destroy method. + +Logging: DEBUG and TRACE on `com.agentclientprotocol.sdk` log message payloads in full, session +updates included; do not enable them where logs are shipped. A stdio agent's standard error is +logged at INFO, unfiltered, on `com.agentclientprotocol.sdk.client.transport.agent-stderr`, and can +carry sensitive data: set that logger to `WARN` or `OFF`, or register a handler of your own with +`StdioAcpClientTransport.setStdErrorHandler`. + +## Not here + +The Mutiny `Uni` and `Multi` return types stay in `acp-quarkus`; the `CompletionStage` and +`Publisher` ones are in `acp-agent-support`. Planned after 0.80.0: a container-neutral WebSocket +connection API, so Vert.x, Netty and other servers route WebSocket upgrades to the SDK without +Jetty, and a Jetty-free `acp-streamable-http-servlet` module. diff --git a/acp-websocket-jetty/pom.xml b/acp-integration/pom.xml similarity index 52% rename from acp-websocket-jetty/pom.xml rename to acp-integration/pom.xml index 1dad1859..01537000 100644 --- a/acp-websocket-jetty/pom.xml +++ b/acp-integration/pom.xml @@ -7,39 +7,45 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 - acp-websocket-jetty - jar + acp-integration + ACP Framework Integration + Framework-neutral building blocks for ACP framework integrations: settings, transports, agent discovery and assembly, and host lifecycles - ACP WebSocket Jetty - WebSocket agent transport using Jetty - for agents that accept WebSocket connections + + + 0.97 + 0.92 + - + + org.jspecify + jspecify + com.agentclientprotocol acp-core - com.agentclientprotocol - acp-json-jackson2 + acp-agent-support ${project.version} - - + - org.eclipse.jetty.websocket - jetty-websocket-jetty-server + com.agentclientprotocol + acp-streamable-http-jetty + true - org.eclipse.jetty.websocket - jetty-websocket-jetty-api + org.slf4j + slf4j-api - org.junit.jupiter junit-jupiter @@ -51,8 +57,8 @@ test - org.mockito - mockito-core + com.tngtech.archunit + archunit-junit5 test @@ -61,11 +67,10 @@ test - io.projectreactor - reactor-test + com.agentclientprotocol + acp-test test - diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscovery.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscovery.java new file mode 100644 index 00000000..a6fb61fe --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscovery.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +/** + * Decides which {@code @AcpAgent} an application serves, by the rule every framework applies: + * none means a client-only application (serve no agent), one is served, and more than one is an + * error that names them all. The framework finds the candidates its own way, from bean metadata + * at run time ({@link AgentCandidate}) or from a build-time index by class name, and passes them + * to {@code requireSingle}; this class only decides. The chosen candidate goes to + * {@link AcpAgents#builder}. + * + *

    Pass the framework's property that turns the agent off, such as + * {@code spring.acp.agent.enabled}, so the error tells the user how to serve none. A framework + * that requires an agent (because it found one by annotation already) turns the empty result + * into its own error. + */ +public final class AcpAgentDiscovery { + + private AcpAgentDiscovery() { + } + + /** + * One {@code @AcpAgent} bean the container found: its name, the class the application wrote, + * and a way to get the bean. The framework builds one per bean from its own metadata and + * passes them to {@link AcpAgentDiscovery#requireSingle(Collection, String)}. + * + *

    The user class must be the class the application declared, taken from the container's + * metadata (Spring's {@code ClassUtils.getUserClass}, Micronaut's bean definition type), never + * the class of a proxy or generated subclass (CGLIB, ArC, Micronaut AOP): the handler + * annotations are on the user class, and a proxy's overriding methods carry none. The instance + * may be such a proxy, which keeps its advice (transactions, security) on every call. + * {@link AcpAgents#builder} calls the supplier once, while it assembles the agent. + * @param name the container's name for the bean, used in messages + * @param userClass the class the application wrote, whose methods carry the handler + * annotations + * @param instance supplies the bean that handles every request, possibly a proxy + * @param the agent type + */ + public record AgentCandidate(String name, Class userClass, Supplier instance) { + + /** + * Creates a candidate, checking each part. + * @throws NullPointerException if a part is null + */ + public AgentCandidate { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(userClass, "userClass"); + Objects.requireNonNull(instance, "instance"); + } + + } + + /** + * Returns the one candidate, as {@link #requireSingle(Collection, String)} does, with an + * error that names no property. + * @param candidates the {@code @AcpAgent} beans the container found + * @return the candidate, or empty when there is none + * @throws IllegalStateException if there is more than one; the message lists their user + * classes + */ + public static Optional> requireSingle(Collection> candidates) { + return requireSingle(candidates, null); + } + + /** + * Returns the one candidate the application serves, or empty for a client-only application. + * @param candidates the {@code @AcpAgent} beans the container found + * @param enabledProperty the framework's property that turns the agent off, such as + * {@code spring.acp.agent.enabled}, which the error names as the way to serve none; null to + * name none + * @return the candidate, or empty when there is none + * @throws IllegalStateException if there is more than one; the message lists their user + * classes, sorted, and says to remove {@code @AcpAgent} from all but one + */ + public static Optional> requireSingle(Collection> candidates, + @Nullable String enabledProperty) { + if (candidates.size() > 1) { + throw tooMany("beans", candidates.stream().map(candidate -> candidate.userClass().getName()).toList(), + enabledProperty); + } + return candidates.isEmpty() ? Optional.empty() : Optional.of(candidates.iterator().next()); + } + + /** + * Applies the same rule to class names, for discovery at build time (Quarkus reads a Jandex + * index), so no class is loaded. + * @param classNames the names of the {@code @AcpAgent} classes found + * @param enabledProperty the framework's property that turns the agent off, which the error + * names; null to name none + * @return the class name, or empty when there is none + * @throws IllegalStateException if there is more than one; the message lists them, sorted + */ + public static Optional requireSingle(List classNames, @Nullable String enabledProperty) { + if (classNames.size() > 1) { + throw tooMany("classes", classNames, enabledProperty); + } + return classNames.stream().findFirst(); + } + + private static IllegalStateException tooMany(String kind, List names, @Nullable String enabledProperty) { + String serveNone = (enabledProperty != null) ? ", or set " + enabledProperty + "=false to serve none" : ""; + return new IllegalStateException("Found " + names.size() + " @AcpAgent " + kind + " " + + names.stream().sorted().toList() + ", but an application serves one: remove @AcpAgent from all but one" + + serveNone); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentHost.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentHost.java new file mode 100644 index 00000000..e25c95f1 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentHost.java @@ -0,0 +1,151 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.OptionalInt; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * The {@link AcpHost} for one agent on one transport: stdio, or a transport bean the application + * provides (an in-memory transport in tests, for one). Build the agent with + * {@link AcpAgents#builder}, set its transport ({@link AcpAgentTransports#stdio()}), build it, + * and give it to this host with the action to run when the client ends the transport. For the + * SDK's HTTP and WebSocket listener, which serves many clients, use {@link AcpListenerHost}. + * + *

    The host drives the SDK's own lifecycle: {@link #start()} calls + * {@link AcpAgentSupport#start()}; a stop runs {@link AcpAgentSupport#close()} (graceful, at most + * about 10 seconds, then at once) on a daemon thread named {@code acp-agent-stop}, and + * {@link #stop(Duration)} closes the agent at once when that does not finish within its timeout. + * {@link #termination()} follows the agent's {@code awaitTermination()}, and completes normally + * also when the transport ended with an error. {@link #port()} is always empty. + * + *

    When the transport ends by itself (for stdio: the client closed the agent's input and + * every answer has been written), the agent has no one left to serve, and the host runs + * {@code onTransportEnd}, typically "close my container" or "exit the application". The host + * runs it at most once, on a new non-daemon thread named {@code acp-agent-transport-end}, never + * on a transport thread: closing the container stops this host, which closes the transport, and + * the JVM waits for the action to finish. It also runs when the transport ended before the host + * started or saw it, and it does not run when the host's own stop ended the transport. The + * host itself never reads {@link AcpAgentSettings#shutdownOnTransportEnd()}: when that setting + * is false, the framework passes an action that does nothing. + * + *

    The host is safe for use from several threads; {@link #start()} and the stop methods + * share one lock. + */ +public final class AcpAgentHost implements AcpHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpAgentHost.class); + + /** The name of the thread that runs the transport-end action. */ + static final String TRANSPORT_END_THREAD_NAME = "acp-agent-transport-end"; + + private final AcpAgentSupport agent; + + private final Runnable onTransportEnd; + + private final CompletableFuture<@Nullable Void> termination; + + private final Object lock = new Object(); + + private final AtomicBoolean stopping = new AtomicBoolean(); + + private final AtomicBoolean transportEndHandled = new AtomicBoolean(); + + private boolean started; + + private @Nullable CompletableFuture<@Nullable Void> stopped; + + /** + * Creates a host for an agent built on its transport, not yet started. The host watches the + * agent's termination from here on, so the action is fixed now and no end of the transport is + * missed, even one before {@link #start()}. + * @param agent the agent, built with its transport and not started + * @param onTransportEnd what to run once when the transport ends by itself, such as closing + * the container; an action that does nothing when the application should keep running + */ + public AcpAgentHost(AcpAgentSupport agent, Runnable onTransportEnd) { + this.agent = agent; + this.onTransportEnd = onTransportEnd; + this.termination = agent.getAgent().async().awaitTermination().onErrorResume(error -> { + logger.debug("ACP agent transport ended with an error: {}", error.toString()); + return Mono.empty(); + }).doOnSuccess(ignored -> transportEnded()).toFuture(); + } + + @Override + public void start() { + synchronized (lock) { + if (started || stopping.get()) { + return; + } + started = true; + agent.start(); + } + } + + @Override + public CompletionStage<@Nullable Void> stopGracefully() { + synchronized (lock) { + CompletableFuture<@Nullable Void> current = stopped; + if (current == null) { + stopping.set(true); + // Off the caller's thread: the SDK's close blocks, gracefully for a bounded time, + // then closes at once. + current = CompletableFuture.<@Nullable Void>supplyAsync(this::closeAgent, runnable -> { + Thread closer = new Thread(runnable, "acp-agent-stop"); + closer.setDaemon(true); + closer.start(); + }); + stopped = current; + } + return current; + } + } + + @Override + public void stop(Duration timeout) { + Hosts.await(stopGracefully(), timeout, "ACP agent", () -> agent.getAgent().async().close()); + } + + @Override + public CompletionStage<@Nullable Void> termination() { + return termination.minimalCompletionStage(); + } + + @Override + public OptionalInt port() { + return OptionalInt.empty(); + } + + private @Nullable Void closeAgent() { + agent.close(); + return null; + } + + private void transportEnded() { + if (stopping.get() || !transportEndHandled.compareAndSet(false, true)) { + return; + } + Thread action = new Thread(() -> { + if (!stopping.get()) { + logger.debug("ACP agent transport ended"); + onTransportEnd.run(); + } + }, TRANSPORT_END_THREAD_NAME); + // Not a daemon: the action (closing the container) must finish before the JVM exits. + action.setDaemon(false); + action.start(); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentSettings.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentSettings.java new file mode 100644 index 00000000..50005aae --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentSettings.java @@ -0,0 +1,547 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import org.jspecify.annotations.Nullable; + +/** + * How the application's {@code @AcpAgent} is served, in a form every framework shares: whether it + * is served, its timeouts, what happens when stdio ends, the transport, and the HTTP endpoint's + * path, port and limits. The framework binds its own configuration onto a {@link #builder()} + * (Spring Boot and Micronaut {@code @ConfigurationProperties}, Quarkus {@code @ConfigMapping}), or + * reads plain key/value configuration with {@link #from(SettingsSource, String)}, and hands the + * result to {@link AcpAgents#builder} and {@link AcpListeners}. + * + *

    A value left unset (null) keeps the SDK's default, so no default is decided twice: the + * framework passes on only what the user wrote. The keys, under the framework's prefix (such as + * {@code spring.acp.agent}), and their defaults: + * + *

    + * enabled                                               true
    + * request-timeout                                       SDK default, 60s (agent-to-client requests)
    + * cancel-grace-period                                   SDK default, 60s
    + * max-prompt-duration                                   SDK default (none)
    + * shutdown-on-transport-end                             true
    + * transport.type                                        stdio | http | websocket (same as http)
    + * transport.http.path                                   /acp
    + * transport.http.allowed-origins                        none (loopback origins only)
    + * transport.http.max-post-body-size, keep-alive-interval, mailbox-capacity,
    + *     max-pending-sse-events, max-web-socket-pending-frames, max-provisional-sessions,
    + *     shutdown-timeout, web-socket-idle-timeout, initialize-timeout   SDK defaults
    + * transport.http.listener.host                          loopback (127.0.0.1 and ::1)
    + * transport.http.listener.port                          8080 (0: ephemeral)
    + * transport.http.listener.max-concurrent-streams-per-connection   SDK default
    + * 
    + * + *

    The framework, not this record, acts on {@code enabled} (no agent when false) and + * {@code shutdownOnTransportEnd} (which {@code onTransportEnd} action it gives an + * {@link AcpAgentHost}). The record checks only that its parts are present; the SDK builders + * reject a negative grace period or prompt duration, and the HTTP options reject out-of-range + * limits, when the agent or endpoint is built. + * + * @param enabled whether the {@code @AcpAgent} is served + * @param requestTimeout how long a request the agent sends to the client waits for its answer; + * null for the SDK default + * @param cancelGracePeriod how long a prompt handler has after {@code session/cancel}; null for + * the SDK default + * @param maxPromptDuration how long a prompt turn may run; null for the SDK default (no limit) + * @param shutdownOnTransportEnd whether the application stops when a single transport (stdio) + * ends by itself + * @param transport the transport; {@link AcpTransportType#WEBSOCKET} means the same as + * {@link AcpTransportType#HTTP} + * @param http the Streamable HTTP endpoint, used only when {@link #servesHttp()} + */ +public record AcpAgentSettings(boolean enabled, @Nullable Duration requestTimeout, + @Nullable Duration cancelGracePeriod, @Nullable Duration maxPromptDuration, boolean shutdownOnTransportEnd, + AcpTransportType transport, Http http) { + + /** The default endpoint path: {@value}. */ + public static final String DEFAULT_PATH = "/acp"; + + /** The default port of the SDK's own listener: {@value}. */ + public static final int DEFAULT_LISTENER_PORT = 8080; + + /** + * Creates the settings, checking that the transport and endpoint are present. + * @throws NullPointerException if {@code transport} or {@code http} is null + */ + public AcpAgentSettings { + Objects.requireNonNull(transport, "transport"); + Objects.requireNonNull(http, "http"); + } + + /** + * The agent's Streamable HTTP endpoint, wherever it is served: as the SDK's servlet in the + * framework's own server, or by the SDK's listener. The path and limits apply to both; the + * listener part only to the SDK's listener. + * @param path the endpoint path, such as {@value AcpAgentSettings#DEFAULT_PATH} + * @param limits the message and stream bounds + * @param listener the SDK listener's own settings + * @param allowedOrigins the browser origins accepted besides the loopback ones, such as + * {@code https://app.example.com}, or {@code *} for any; empty by default. A request from any + * other origin is answered 403, over HTTP and on the WebSocket handshake; see + * {@link StreamableHttpAcpAgentTransportOptions#allowedOrigins()} + */ + public record Http(String path, Limits limits, Listener listener, List allowedOrigins) { + + /** + * Creates an endpoint, checking each part. + * @throws NullPointerException if a part is null + */ + public Http { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(limits, "limits"); + Objects.requireNonNull(listener, "listener"); + allowedOrigins = List.copyOf(allowedOrigins); + } + + } + + /** + * The message and stream bounds of the endpoint, for the servlet and the listener alike. Each + * null keeps the SDK default; {@link AcpAgentSettings#toOptions(boolean)} turns them into + * {@link StreamableHttpAcpAgentTransportOptions}, which documents each default and range. + * @param maxPostBodyBytes the largest inbound message in bytes (POST body or WebSocket text + * message) + * @param keepAliveInterval the interval between SSE keep-alive comments; zero disables them + * @param mailboxCapacity the events retained per outbound stream while none is attached + * @param maxPendingSseEvents the events queued for one SSE subscriber before it is closed + * @param maxWebSocketPendingFrames the frames queued for one WebSocket connection + * @param maxProvisionalSessions the session streams a connection may open before the session is + * known + * @param shutdownTimeout how long closing waits for the connections to close gracefully + * @param webSocketIdleTimeout how long a WebSocket connection may pass no frame before the + * endpoint closes it + * @param initializeTimeout how long a connection has to complete {@code initialize} + */ + public record Limits(@Nullable Long maxPostBodyBytes, @Nullable Duration keepAliveInterval, + @Nullable Integer mailboxCapacity, @Nullable Integer maxPendingSseEvents, + @Nullable Integer maxWebSocketPendingFrames, @Nullable Integer maxProvisionalSessions, + @Nullable Duration shutdownTimeout, @Nullable Duration webSocketIdleTimeout, + @Nullable Duration initializeTimeout) { + } + + /** + * The settings of the SDK's own listener; a framework's own server ignores them. + * @param host the address the listener binds, or null (the default) for the loopback + * interface only; {@code 0.0.0.0} exposes the agent on every interface, an explicit opt-in + * (the endpoint has no authentication of its own) + * @param port the port, {@value AcpAgentSettings#DEFAULT_LISTENER_PORT} by default; 0 for an + * ephemeral one, which {@link AcpHost#port()} reports once started + * @param maxConcurrentStreamsPerConnection the HTTP/2 streams one client connection may hold + * open; null for the SDK default + */ + public record Listener(@Nullable String host, int port, @Nullable Integer maxConcurrentStreamsPerConnection) { + } + + /** + * Returns whether the agent is served over Streamable HTTP (and WebSocket) rather than on one + * transport. A framework uses it to choose between an {@link AcpListenerHost} or servlet and + * an {@link AcpAgentHost}. + * @return true for {@code http} and {@code websocket}, false for {@code stdio} + */ + public boolean servesHttp() { + return transport != AcpTransportType.STDIO; + } + + /** + * Returns the SDK transport options for the endpoint's limits; an unset limit keeps the SDK + * default. {@link AcpListeners} calls it; a framework that creates the endpoint itself (as + * Quarkus does for its servlet and WebSocket route) calls it too. Needs + * {@code acp-streamable-http-jetty} on the classpath. + * @param listener true for the SDK's own listener, which also takes the listener's stream + * limit; false inside a framework's server + * @return the options + * @throws IllegalArgumentException if a limit is out of the range the options accept + */ + public StreamableHttpAcpAgentTransportOptions toOptions(boolean listener) { + return toOptionsBuilder(listener).build(); + } + + /** The builder {@link #toOptions(boolean)} builds, for {@link AcpListeners} to add threads to. */ + StreamableHttpAcpAgentTransportOptions.Builder toOptionsBuilder(boolean listener) { + Limits limits = http.limits(); + StreamableHttpAcpAgentTransportOptions.Builder options = StreamableHttpAcpAgentTransportOptions.builder(); + ifSet(limits.maxPostBodyBytes(), options::maxPostBodyBytes); + ifSet(limits.keepAliveInterval(), options::keepAliveInterval); + ifSet(limits.mailboxCapacity(), options::mailboxCapacity); + ifSet(limits.maxPendingSseEvents(), options::maxPendingSseEvents); + ifSet(limits.maxWebSocketPendingFrames(), options::maxWebSocketPendingFrames); + ifSet(limits.maxProvisionalSessions(), options::maxProvisionalSessions); + ifSet(limits.shutdownTimeout(), options::shutdownTimeout); + ifSet(limits.webSocketIdleTimeout(), options::webSocketIdleTimeout); + ifSet(limits.initializeTimeout(), options::initializeTimeout); + options.allowedOrigins(http.allowedOrigins()); + if (listener) { + ifSet(http.listener().maxConcurrentStreamsPerConnection(), options::maxConcurrentStreamsPerConnection); + options.host(http.listener().host()); + } + return options; + } + + private static void ifSet(@Nullable T value, Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** + * Returns a builder holding every default: served, stdio, path + * {@value #DEFAULT_PATH}, listener port {@value #DEFAULT_LISTENER_PORT}, and the SDK's + * defaults for the rest. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Reads the settings under a prefix of key/value configuration, for a framework without typed + * binding. Keys are in kebab case, such as {@code acp.agent.request-timeout} and + * {@code acp.agent.transport.http.listener.port} for prefix {@code acp.agent}; an empty prefix + * reads the bare keys. A blank value counts as unset. Durations are ISO-8601 + * ({@code PT30S}) or a whole amount with a unit {@code ms}, {@code s}, {@code m}, {@code h} or + * {@code d} ({@code 30s}, {@code 500ms}); a bare number is milliseconds. Sizes are bytes or an + * amount with {@code KB}, {@code MB} or {@code GB} (powers of 1024). Booleans are + * {@code true} or {@code false} in any case. + * @param source the configuration + * @param prefix the prefix of the agent's keys, such as {@code acp.agent} + * @return the settings + * @throws IllegalArgumentException if a value does not parse; the message names the full key + * and the value + */ + public static AcpAgentSettings from(SettingsSource source, String prefix) { + SettingsReader read = new SettingsReader(source, prefix); + Builder builder = builder().enabled(read.bool("enabled", true)) + .requestTimeout(read.duration("request-timeout")) + .cancelGracePeriod(read.duration("cancel-grace-period")) + .maxPromptDuration(read.duration("max-prompt-duration")) + .shutdownOnTransportEnd(read.bool("shutdown-on-transport-end", true)) + .maxPostBodyBytes(read.bytes("transport.http.max-post-body-size")) + .keepAliveInterval(read.duration("transport.http.keep-alive-interval")) + .mailboxCapacity(read.integer("transport.http.mailbox-capacity")) + .maxPendingSseEvents(read.integer("transport.http.max-pending-sse-events")) + .maxWebSocketPendingFrames(read.integer("transport.http.max-web-socket-pending-frames")) + .maxProvisionalSessions(read.integer("transport.http.max-provisional-sessions")) + .shutdownTimeout(read.duration("transport.http.shutdown-timeout")) + .webSocketIdleTimeout(read.duration("transport.http.web-socket-idle-timeout")) + .initializeTimeout(read.duration("transport.http.initialize-timeout")) + .maxConcurrentStreamsPerConnection( + read.integer("transport.http.listener.max-concurrent-streams-per-connection")); + AcpTransportType type = read.transportType("transport.type"); + if (type != null) { + builder.transport(type); + } + String path = read.string("transport.http.path"); + if (path != null) { + builder.path(path); + } + builder.listenerHost(read.string("transport.http.listener.host")); + builder.allowedOrigins(read.list("transport.http.allowed-origins")); + Integer port = read.integer("transport.http.listener.port"); + if (port != null) { + builder.listenerPort(port); + } + return builder.build(); + } + + /** + * Builds {@link AcpAgentSettings}, flat: the endpoint's path, limits and listener settings + * are set here directly, and {@link #build()} groups them. Every setting has a default, so a + * framework sets only what the user configured. Not safe for use from several threads. + */ + public static final class Builder { + + private boolean enabled = true; + + private @Nullable Duration requestTimeout; + + private @Nullable Duration cancelGracePeriod; + + private @Nullable Duration maxPromptDuration; + + private boolean shutdownOnTransportEnd = true; + + private AcpTransportType transport = AcpTransportType.STDIO; + + private String path = DEFAULT_PATH; + + private @Nullable Long maxPostBodyBytes; + + private @Nullable Duration keepAliveInterval; + + private @Nullable Integer mailboxCapacity; + + private @Nullable Integer maxPendingSseEvents; + + private @Nullable Integer maxWebSocketPendingFrames; + + private @Nullable Integer maxProvisionalSessions; + + private @Nullable Duration shutdownTimeout; + + private @Nullable Duration webSocketIdleTimeout; + + private @Nullable Duration initializeTimeout; + + private int listenerPort = DEFAULT_LISTENER_PORT; + + private @Nullable String listenerHost; + + private List allowedOrigins = List.of(); + + private @Nullable Integer maxConcurrentStreamsPerConnection; + + private Builder() { + } + + /** + * Sets whether the agent is served. Default true; false serves no agent even when the + * application has an {@code @AcpAgent} bean. + * @param enabled whether the agent is served + * @return this builder + */ + public Builder enabled(boolean enabled) { + this.enabled = enabled; + return this; + } + + /** + * Sets how long a request the agent sends to the client (a permission prompt, a file + * read) waits for its answer. + * @param requestTimeout the timeout, or null for the SDK default (60 seconds) + * @return this builder + */ + public Builder requestTimeout(@Nullable Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + /** + * Sets how long a {@code @Prompt} method has to return after {@code session/cancel} + * before the SDK answers the turn {@code cancelled} itself and interrupts the method. + * @param cancelGracePeriod the grace period, zero for none, or null for the SDK default + * (60 seconds) + * @return this builder + */ + public Builder cancelGracePeriod(@Nullable Duration cancelGracePeriod) { + this.cancelGracePeriod = cancelGracePeriod; + return this; + } + + /** + * Sets the longest a prompt turn may run before the SDK answers it with error + * {@code -32800} (request cancelled). + * @param maxPromptDuration the duration, zero for none, or null for the SDK default + * (none) + * @return this builder + */ + public Builder maxPromptDuration(@Nullable Duration maxPromptDuration) { + this.maxPromptDuration = maxPromptDuration; + return this; + } + + /** + * Sets whether the application stops when a single transport (stdio) ends by itself, + * that is, when the client closed the agent's input. Default true. The framework acts on + * it through the action it gives {@link AcpAgentHost}. + * @param shutdownOnTransportEnd whether to stop at transport end + * @return this builder + */ + public Builder shutdownOnTransportEnd(boolean shutdownOnTransportEnd) { + this.shutdownOnTransportEnd = shutdownOnTransportEnd; + return this; + } + + /** + * Sets the transport. Default {@link AcpTransportType#STDIO}; + * {@link AcpTransportType#WEBSOCKET} means the same as {@link AcpTransportType#HTTP}. + * @param transport the transport + * @return this builder + */ + public Builder transport(AcpTransportType transport) { + this.transport = Objects.requireNonNull(transport, "transport"); + return this; + } + + /** + * Sets the HTTP endpoint's path, for the servlet and the listener alike. Default + * {@value AcpAgentSettings#DEFAULT_PATH}. + * @param path the path + * @return this builder + */ + public Builder path(String path) { + this.path = Objects.requireNonNull(path, "path"); + return this; + } + + /** + * Sets the largest message the endpoint accepts from a client: a larger POST body is + * answered 413, and a larger WebSocket text message closes the connection. + * @param maxPostBodyBytes the size in bytes, or null for the SDK default (16 MB) + * @return this builder + */ + public Builder maxPostBodyBytes(@Nullable Long maxPostBodyBytes) { + this.maxPostBodyBytes = maxPostBodyBytes; + return this; + } + + /** + * Sets the interval between keep-alive comments on open SSE streams, which stop proxies + * from cutting idle streams. + * @param keepAliveInterval the interval, zero for none, or null for the SDK default (15 + * seconds) + * @return this builder + */ + public Builder keepAliveInterval(@Nullable Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + return this; + } + + /** + * Sets how many unsent events one SSE stream keeps while no client is reading it, to + * send when the client reconnects; one more closes the connection. + * @param mailboxCapacity the capacity, or null for the SDK default (1024) + * @return this builder + */ + public Builder mailboxCapacity(@Nullable Integer mailboxCapacity) { + this.mailboxCapacity = mailboxCapacity; + return this; + } + + /** + * Sets how many events may be queued for a client reading an SSE stream; one more + * detaches that client, keeping the events for its next GET. + * @param maxPendingSseEvents the limit, or null for the SDK default (1024) + * @return this builder + */ + public Builder maxPendingSseEvents(@Nullable Integer maxPendingSseEvents) { + this.maxPendingSseEvents = maxPendingSseEvents; + return this; + } + + /** + * Sets how many frames may be queued for one WebSocket connection; one more closes the + * connection. + * @param maxWebSocketPendingFrames the limit, or null for the SDK default (1024) + * @return this builder + */ + public Builder maxWebSocketPendingFrames(@Nullable Integer maxWebSocketPendingFrames) { + this.maxWebSocketPendingFrames = maxWebSocketPendingFrames; + return this; + } + + /** + * Sets how many session streams a connection may open before the agent knows the + * session, as a client does before {@code session/load}; a further one is refused. + * @param maxProvisionalSessions the limit, or null for the SDK default (64) + * @return this builder + */ + public Builder maxProvisionalSessions(@Nullable Integer maxProvisionalSessions) { + this.maxProvisionalSessions = maxProvisionalSessions; + return this; + } + + /** + * Sets how long closing the endpoint waits for its connections' agents to close before + * it closes the rest at once. Closing never waits for a client. + * @param shutdownTimeout the timeout, or null for the SDK default (5 seconds) + * @return this builder + */ + public Builder shutdownTimeout(@Nullable Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + return this; + } + + /** + * Sets how long a WebSocket connection may pass no frame in either direction before the + * endpoint closes it (1001), on every host. + * @param webSocketIdleTimeout the timeout, or null for the SDK default (30 minutes) + * @return this builder + */ + public Builder webSocketIdleTimeout(@Nullable Duration webSocketIdleTimeout) { + this.webSocketIdleTimeout = webSocketIdleTimeout; + return this; + } + + /** + * Sets how long a connection has to complete {@code initialize}: a WebSocket that has not + * sent it by then is closed (1008), and a POST {@code initialize} the agent has not + * answered by then is answered 500. + * @param initializeTimeout the timeout, or null for the SDK default (30 seconds) + * @return this builder + */ + public Builder initializeTimeout(@Nullable Duration initializeTimeout) { + this.initializeTimeout = initializeTimeout; + return this; + } + + /** + * Sets the SDK listener's port; a framework's own server ignores it. Default + * {@value AcpAgentSettings#DEFAULT_LISTENER_PORT}. + * @param listenerPort the port; 0 for an ephemeral one + * @return this builder + */ + public Builder listenerPort(int listenerPort) { + this.listenerPort = listenerPort; + return this; + } + + /** + * Sets the address the SDK listener binds; a framework's own server ignores it. Default + * (null) the loopback interface only; {@code 0.0.0.0} exposes the agent on every + * interface. + * @param listenerHost a host name or address, or null for loopback only + * @return this builder + */ + public Builder listenerHost(@Nullable String listenerHost) { + this.listenerHost = listenerHost; + return this; + } + + /** + * Sets the browser origins the endpoint accepts besides the loopback ones; default none. + * @param allowedOrigins origins such as {@code https://app.example.com}, or {@code *} + * @return this builder + */ + public Builder allowedOrigins(List allowedOrigins) { + this.allowedOrigins = List.copyOf(allowedOrigins); + return this; + } + + /** + * Sets how many HTTP/2 streams one client connection to the SDK listener may hold open; + * a framework's own server ignores it. + * @param maxConcurrentStreamsPerConnection the limit, or null for the SDK default (1024) + * @return this builder + */ + public Builder maxConcurrentStreamsPerConnection(@Nullable Integer maxConcurrentStreamsPerConnection) { + this.maxConcurrentStreamsPerConnection = maxConcurrentStreamsPerConnection; + return this; + } + + /** + * Builds the settings from the values set so far. The builder can be used again. + * @return the settings + */ + public AcpAgentSettings build() { + Limits limits = new Limits(maxPostBodyBytes, keepAliveInterval, mailboxCapacity, maxPendingSseEvents, + maxWebSocketPendingFrames, maxProvisionalSessions, shutdownTimeout, webSocketIdleTimeout, + initializeTimeout); + return new AcpAgentSettings(enabled, requestTimeout, cancelGracePeriod, maxPromptDuration, + shutdownOnTransportEnd, transport, + new Http(path, limits, new Listener(listenerHost, listenerPort, maxConcurrentStreamsPerConnection), + allowedOrigins)); + } + + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentTransports.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentTransports.java new file mode 100644 index 00000000..3d82d0c7 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgentTransports.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; + +/** + * Creates the agent transport a framework uses when it serves one agent on stdio, for an + * {@link AcpAgentHost}. The HTTP and WebSocket endpoints are in {@link AcpListeners}, apart, + * because they come from the optional {@code acp-streamable-http-jetty} module; a class the + * framework always loads must not name those types. + */ +public final class AcpAgentTransports { + + private AcpAgentTransports() { + } + + /** + * Returns a new stdio transport over {@code System.in} and {@code System.out}, for the + * builder from {@link AcpAgents#builder}. Standard output then carries ACP messages only, so + * the framework must send its logging and banner to standard error or turn them off; anything + * else written there corrupts the stream the client reads. The transport ends by itself when + * the client closes the agent's input and every answer has been written; an + * {@link AcpAgentHost} then runs its transport-end action. + * @return a new stdio transport, not started + */ + public static AcpAgentTransport stdio() { + return new StdioAcpAgentTransport(); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgents.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgents.java new file mode 100644 index 00000000..daf5d7b3 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpAgents.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import org.jspecify.annotations.Nullable; + +/** + * Assembles the application's {@code @AcpAgent} into an {@link AcpAgentSupport.Builder} the same + * way in every framework, from the candidate {@link AcpAgentDiscovery} chose, the + * {@link AcpAgentSettings}, and the framework's extension beans. The framework then finishes the + * builder for its transport: for one agent on one transport, it sets the transport, builds, and + * gives the agent to an {@link AcpAgentHost}; for HTTP and WebSocket, it calls + * {@link AcpAgentSupport.Builder#buildFactory() buildFactory()} and gives the factory to + * {@link AcpListeners}. + * + *

    What the shared code does: it finds the handler methods on the candidate's user class + * (superclasses included) and calls them on the candidate's instance, applies the settings' + * timeouts (an unset one keeps the SDK default), and adds the interceptors, argument resolvers + * and return value handlers in the order given. What the framework does: collect those beans in + * its own bean order, and choose the executor the handler methods run on. + */ +public final class AcpAgents { + + private AcpAgents() { + } + + /** + * Returns a builder for the agent whose handler methods run on the SDK's default executor, as + * {@link #builder(AgentCandidate, AcpAgentSettings, List, List, List, ExecutorService)} + * builds it with no executor. + * @param agent the {@code @AcpAgent} bean + * @param settings the agent settings + * @param interceptors the interceptors, in order + * @param resolvers the argument resolvers, in order + * @param returnValueHandlers the return value handlers, in order + * @param the agent type + * @return a builder holding everything but the transport + * @throws IllegalArgumentException if neither the user class nor a superclass is marked + * {@code @AcpAgent}, or its handler methods are malformed; see + * {@link AcpAgentSupport.Builder#agent(Object)} for the cases + */ + public static AcpAgentSupport.Builder builder(AgentCandidate agent, AcpAgentSettings settings, + List interceptors, List resolvers, + List returnValueHandlers) { + return builder(agent, settings, interceptors, resolvers, returnValueHandlers, null); + } + + /** + * Returns a builder for the agent: its handler methods found on the candidate's user class + * (superclasses included) and called on the instance the candidate supplies, which this + * method asks for once, now; the settings' request timeout, cancel grace period and maximum + * prompt duration, each only when set; then the interceptors, argument resolvers and return + * value handlers, each list in order. The settings' transport and endpoint values are not + * used here; {@link AcpListeners} applies those. + * + *

    The handler executor lets handler methods run on the framework's own threads: its + * virtual-thread executor (Micronaut on JDK 21 and later, Spring Boot with virtual threads + * on) or its managed worker pool (Quarkus), so they do not need a second pool beside the + * framework's. Handler methods block, so the executor must allow blocking. The SDK cancels a + * handler by cancelling the task it submitted, which interrupts the thread, and never shuts + * the executor down. Avoid an executor of non-daemon threads that the container does not + * stop when the agent's transport ends: those threads keep a stdio agent's JVM running after + * its client has gone. + * @param agent the {@code @AcpAgent} bean + * @param settings the agent settings; an unset timeout keeps the SDK default + * @param interceptors the interceptors, in order + * @param resolvers the argument resolvers, in order + * @param returnValueHandlers the return value handlers, in order + * @param handlerExecutor the executor the handler methods run on, or null for the SDK's + * default: a virtual thread per call on JDK 21 and later, before that a pool of daemon threads + * @param the agent type + * @return a builder holding everything but the transport + * @throws IllegalArgumentException if neither the user class nor a superclass is marked + * {@code @AcpAgent}, or its handler methods are malformed; see + * {@link AcpAgentSupport.Builder#agent(Object)} for the cases + */ + public static AcpAgentSupport.Builder builder(AgentCandidate agent, AcpAgentSettings settings, + List interceptors, List resolvers, + List returnValueHandlers, @Nullable ExecutorService handlerExecutor) { + AcpAgentSupport.Builder builder = AcpAgentSupport.builder().agent(agent.userClass(), agent.instance()::get); + Duration requestTimeout = settings.requestTimeout(); + if (requestTimeout != null) { + builder.requestTimeout(requestTimeout); + } + Duration cancelGracePeriod = settings.cancelGracePeriod(); + if (cancelGracePeriod != null) { + builder.cancelGracePeriod(cancelGracePeriod); + } + Duration maxPromptDuration = settings.maxPromptDuration(); + if (maxPromptDuration != null) { + builder.maxPromptDuration(maxPromptDuration); + } + interceptors.forEach(builder::interceptor); + resolvers.forEach(builder::argumentResolver); + returnValueHandlers.forEach(builder::returnValueHandler); + if (handlerExecutor != null) { + builder.handlerExecutor(handlerExecutor); + } + return builder; + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientCustomizer.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientCustomizer.java new file mode 100644 index 00000000..3ba6ae6d --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientCustomizer.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import com.agentclientprotocol.sdk.client.AcpClient; + +/** + * Lets the application add to the ACP client a framework builds: a session-update handler, a + * permission handler, file system, terminal or elicitation handlers, or anything else the SDK's + * {@link AcpClient.AsyncSpec} takes. The application declares customizers as beans; the + * framework passes every one, in its bean order, to {@link AcpClients#async}, which applies them + * to the one builder behind both the async and the sync client. This is the one type of this + * package an application on Spring Boot, Micronaut or Quarkus uses directly. + * + *

    A capability turned on in the settings ({@link AcpClientSettings.Capabilities}) needs its + * handler registered here, or building the client fails. A session-update handler added here + * replaces the default one that only logs. The builder already has a default session-update + * handler, so a customizer must not call {@code defaultSessionUpdateHandler}, which then + * fails. + */ +@FunctionalInterface +public interface AcpClientCustomizer { + + /** + * Adds to the client builder, before the client is built. Called once per client, on the + * thread that creates it, after the settings were applied and before later customizers. + * @param spec the builder of the client + */ + void customize(AcpClient.AsyncSpec spec); + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientHost.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientHost.java new file mode 100644 index 00000000..baeb86cd --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientHost.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import org.jspecify.annotations.Nullable; + +/** + * Closes the client a framework built with {@link AcpClients} when the container disposes of + * it: gracefully first, and at once if that takes too long. The framework creates one around its + * {@link AcpAsyncClient} and calls {@link #close(Duration) close}{@code (settings.closeTimeout())} + * from its dispose hook ({@link AcpClientSettings#closeTimeout()}). It is the client side's + * counterpart of {@link AcpHost#stop(Duration)}; there is nothing to start, since building the + * client connected it. + * + *

    The graceful close is {@link AcpAsyncClient#closeGracefully()}: requests still waiting for + * an answer fail, the agent's requests being handled are cancelled, session updates already + * received still reach the update handlers (bounded by the client's request timeout), and then the + * transport closes. For stdio the transport closes the agent's input first, so the agent process + * can exit by itself, and stops it if it does not. That one close covers the sync facade from + * {@link AcpClients#sync} and the transport too, so the framework must not close either of them + * again, for example through a destroy method the container infers from a {@code close()} + * method on the bean. Only the first close of a host has an effect; create one host per client. + * The host is safe for use from several threads. + */ +public final class AcpClientHost { + + private final AcpAsyncClient client; + + private final Object lock = new Object(); + + private @Nullable CompletableFuture<@Nullable Void> closed; + + /** + * Creates a host for the client. + * @param client the async client from {@link AcpClients#async} + */ + public AcpClientHost(AcpAsyncClient client) { + this.client = client; + } + + /** + * Begins a graceful close of the client without waiting for it. Only the first call starts + * the close; every call returns the same stage. + * @return a stage that completes when the client is closed + */ + public CompletionStage<@Nullable Void> closeGracefully() { + synchronized (lock) { + CompletableFuture<@Nullable Void> current = closed; + if (current == null) { + current = client.closeGracefully().toFuture(); + closed = current; + } + return current; + } + } + + /** + * Closes the client: begins {@link #closeGracefully()} and waits for it at most + * {@code timeout}. If the graceful close fails, takes longer, or the waiting thread is + * interrupted (the interrupt is kept), it logs a warning and closes the client at once. + * Returns at once when the client is already closed. + * @param timeout how long to wait for the graceful close, usually + * {@link AcpClientSettings#closeTimeout()} + */ + public void close(Duration timeout) { + Hosts.await(closeGracefully(), timeout, "ACP client", client::close); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientSettings.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientSettings.java new file mode 100644 index 00000000..9c89cb26 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientSettings.java @@ -0,0 +1,362 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; + +/** + * How the application's ACP client connects and what it advertises, in a form every framework + * shares: its timeouts, the transport (a stdio command, a WebSocket URI or a Streamable HTTP URI), + * and the client capabilities. The framework binds its own configuration onto a + * {@link #builder()}, or reads plain key/value configuration with + * {@link #from(SettingsSource, String)}, then asks {@link #hasTransport()} whether to create a + * client at all, and hands the settings to {@link AcpClientTransports}, {@link AcpClients} and + * {@link AcpClientHost} (through {@link #closeTimeout()}). + * + *

    A value left unset keeps the SDK's default. The keys, under the framework's prefix (such + * as {@code spring.acp.client}), and their defaults: + * + *

    + * request-timeout                         SDK default, 60s
    + * prompt-timeout                          none
    + * transport.type                          stdio | websocket | http (else inferred)
    + * transport.stdio.command, args, env      the agent process to start
    + * transport.websocket.uri                 ws://host:port/acp
    + * transport.websocket.connect-timeout     10s
    + * transport.http.uri                      http://host:port/acp
    + * capabilities.read-text-file, write-text-file, terminal, elicitation-form, elicitation-url,
    + *     boolean-config-options              false
    + * 
    + * + * @param requestTimeout how long the client waits for the agent to answer a request; null for + * the SDK default + * @param promptTimeout how long a prompt may wait for its answer, apart from the request timeout; + * null for none + * @param transport the transport, or null to infer it from the one transport configured + * @param stdio the agent process a stdio client starts + * @param websocket the WebSocket endpoint + * @param http the Streamable HTTP endpoint + * @param capabilities the capabilities the client advertises + */ +public record AcpClientSettings(@Nullable Duration requestTimeout, @Nullable Duration promptTimeout, + @Nullable AcpTransportType transport, Stdio stdio, WebSocket websocket, Http http, Capabilities capabilities) { + + /** The default WebSocket connect timeout: 10 seconds. */ + public static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + + /** + * Creates the settings, checking that every part other than the timeouts and the type is + * present. + * @throws NullPointerException if a part other than a timeout or the type is null + */ + public AcpClientSettings { + Objects.requireNonNull(stdio, "stdio"); + Objects.requireNonNull(websocket, "websocket"); + Objects.requireNonNull(http, "http"); + Objects.requireNonNull(capabilities, "capabilities"); + } + + /** + * The agent process a stdio client starts: the command, its arguments, and environment + * variables added to the environment the process inherits from the application. The + * arguments and environment are copied; no element, key or value may be null. + * @param command the command, or null when unset + * @param args the command's arguments, in order + * @param env variables added to the agent's environment, by name + */ + public record Stdio(@Nullable String command, List args, Map env) { + + /** + * Creates a process description, copying the arguments and environment, which must not + * be null or hold a null. + */ + public Stdio { + args = List.copyOf(args); + env = Map.copyOf(env); + } + + } + + /** + * The WebSocket endpoint a WebSocket client connects to. + * @param uri the endpoint, such as {@code ws://host:port/acp}, or null when unset + * @param connectTimeout how long connecting may take + */ + public record WebSocket(@Nullable URI uri, Duration connectTimeout) { + + /** + * Creates an endpoint, checking the connect timeout. + * @throws NullPointerException if {@code connectTimeout} is null + */ + public WebSocket { + Objects.requireNonNull(connectTimeout, "connectTimeout"); + } + + } + + /** + * The Streamable HTTP endpoint a Streamable HTTP client posts to. + * @param uri the endpoint, such as {@code http://host:port/acp}, or null when unset + */ + public record Http(@Nullable URI uri) { + } + + /** + * The capabilities the client advertises in {@code initialize}, all off by default. Advertise + * only what the application handles: turn a capability on together with the handler the + * application registers through an {@link AcpClientCustomizer}. Building the client with + * {@link AcpClients#async} fails when a capability other than boolean config options has no + * handler, naming the setting. + * @param readTextFile {@code fs/read_text_file} + * @param writeTextFile {@code fs/write_text_file} + * @param terminal the {@code terminal/*} methods + * @param elicitationForm form-mode {@code elicitation/create} + * @param elicitationUrl URL-mode {@code elicitation/create} + * @param booleanConfigOptions boolean session config options + */ + public record Capabilities(boolean readTextFile, boolean writeTextFile, boolean terminal, boolean elicitationForm, + boolean elicitationUrl, boolean booleanConfigOptions) { + + /** No capability advertised: the default. */ + public static final Capabilities NONE = new Capabilities(false, false, false, false, false, false); + + /** + * Returns the capabilities as the protocol writes them in {@code initialize}: the file + * system and terminal flags always, an elicitation object only when a mode is on, and + * the session capability for boolean config options only when that is on. + * @return the client capabilities + */ + public AcpSchema.ClientCapabilities toClientCapabilities() { + AcpSchema.ElicitationCapabilities elicitation = null; + if (elicitationForm || elicitationUrl) { + elicitation = new AcpSchema.ElicitationCapabilities( + elicitationForm ? new AcpSchema.ElicitationFormCapabilities() : null, + elicitationUrl ? new AcpSchema.ElicitationUrlCapabilities() : null, null); + } + return new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(readTextFile, writeTextFile), + terminal, booleanConfigOptions ? AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions() : null, + null, elicitation, null); + } + + } + + /** + * Returns whether any transport setting is present: a type, a stdio command, or a URI. A + * framework creates no client when none is (an agent-only application). True does not mean + * the settings are complete; {@link AcpClientTransports#create} checks that. + * @return true when a transport is configured + */ + public boolean hasTransport() { + return transport != null || stdio.command() != null || websocket.uri() != null || http.uri() != null; + } + + /** + * Returns how long closing the client may wait, for {@link AcpClientHost#close(Duration)}: + * the request timeout (the SDK default, 60 seconds, when unset), which bounds the delivery of + * pending session updates, plus a margin of 10 seconds. + * @return the close timeout + */ + public Duration closeTimeout() { + return (requestTimeout != null ? requestTimeout : Duration.ofSeconds(60)).plusSeconds(10); + } + + /** + * Returns a builder holding every default: no transport, no capability, a 10-second + * WebSocket connect timeout, and the SDK's defaults for the rest. + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Reads the settings under a prefix of key/value configuration, for a framework without typed + * binding. Keys are in kebab case, such as {@code acp.client.transport.stdio.command} for + * prefix {@code acp.client}; the arguments and environment are read with + * {@link SettingsSource#list} and {@link SettingsSource#map}. A blank value counts as unset. + * Durations and booleans are read as {@link AcpAgentSettings#from} reads them; URIs must + * parse as {@link URI}s. + * @param source the configuration + * @param prefix the prefix of the client's keys, such as {@code acp.client} + * @return the settings + * @throws IllegalArgumentException if a value does not parse; the message names the full key + * and the value + */ + public static AcpClientSettings from(SettingsSource source, String prefix) { + SettingsReader read = new SettingsReader(source, prefix); + Builder builder = builder().requestTimeout(read.duration("request-timeout")) + .promptTimeout(read.duration("prompt-timeout")) + .transport(read.transportType("transport.type")) + .stdioCommand(read.string("transport.stdio.command")) + .stdioArgs(read.list("transport.stdio.args")) + .stdioEnv(read.map("transport.stdio.env")) + .websocketUri(read.uri("transport.websocket.uri")) + .httpUri(read.uri("transport.http.uri")) + .capabilities(new Capabilities(read.bool("capabilities.read-text-file", false), + read.bool("capabilities.write-text-file", false), read.bool("capabilities.terminal", false), + read.bool("capabilities.elicitation-form", false), read.bool("capabilities.elicitation-url", false), + read.bool("capabilities.boolean-config-options", false))); + Duration connectTimeout = read.duration("transport.websocket.connect-timeout"); + if (connectTimeout != null) { + builder.websocketConnectTimeout(connectTimeout); + } + return builder.build(); + } + + /** + * Builds {@link AcpClientSettings}, flat: the transport's parts are set here directly, and + * {@link #build()} groups them. Every setting has a default, so a framework sets only what + * the user configured. Not safe for use from several threads. + */ + public static final class Builder { + + private @Nullable Duration requestTimeout; + + private @Nullable Duration promptTimeout; + + private @Nullable AcpTransportType transport; + + private @Nullable String stdioCommand; + + private List stdioArgs = List.of(); + + private Map stdioEnv = Map.of(); + + private @Nullable URI websocketUri; + + private Duration websocketConnectTimeout = DEFAULT_CONNECT_TIMEOUT; + + private @Nullable URI httpUri; + + private Capabilities capabilities = Capabilities.NONE; + + private Builder() { + } + + /** + * Sets how long the client waits for the agent to answer a request. + * @param requestTimeout the timeout, or null for the SDK default (60 seconds) + * @return this builder + */ + public Builder requestTimeout(@Nullable Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + /** + * Sets how long a prompt may wait for its answer; a prompt is not bound by the request + * timeout. + * @param promptTimeout the timeout, or null for none + * @return this builder + */ + public Builder promptTimeout(@Nullable Duration promptTimeout) { + this.promptTimeout = promptTimeout; + return this; + } + + /** + * Sets the transport explicitly; it then needs its command or URI. + * @param transport the transport, or null to infer it from the one configured + * @return this builder + */ + public Builder transport(@Nullable AcpTransportType transport) { + this.transport = transport; + return this; + } + + /** + * Sets the command that starts the agent process; with no explicit transport, setting it + * selects stdio. + * @param command the command, or null for none + * @return this builder + */ + public Builder stdioCommand(@Nullable String command) { + this.stdioCommand = command; + return this; + } + + /** + * Sets the agent process's arguments, replacing any set before. + * @param args the arguments, in order; no element may be null + * @return this builder + */ + public Builder stdioArgs(List args) { + this.stdioArgs = List.copyOf(args); + return this; + } + + /** + * Sets the variables added to the environment the agent process inherits, replacing any + * set before. + * @param env the variables, by name; no key or value may be null + * @return this builder + */ + public Builder stdioEnv(Map env) { + this.stdioEnv = new LinkedHashMap<>(env); + return this; + } + + /** + * Sets the WebSocket endpoint; with no explicit transport, setting it selects WebSocket. + * @param uri the endpoint, or null for none + * @return this builder + */ + public Builder websocketUri(@Nullable URI uri) { + this.websocketUri = uri; + return this; + } + + /** + * Sets how long connecting over WebSocket may take. Default 10 seconds. + * @param connectTimeout the timeout, not null + * @return this builder + */ + public Builder websocketConnectTimeout(Duration connectTimeout) { + this.websocketConnectTimeout = Objects.requireNonNull(connectTimeout, "connectTimeout"); + return this; + } + + /** + * Sets the Streamable HTTP endpoint; with no explicit transport, setting it selects + * Streamable HTTP. + * @param uri the endpoint, or null for none + * @return this builder + */ + public Builder httpUri(@Nullable URI uri) { + this.httpUri = uri; + return this; + } + + /** + * Sets the capabilities the client advertises. Default {@link Capabilities#NONE}. + * @param capabilities the capabilities, not null + * @return this builder + */ + public Builder capabilities(Capabilities capabilities) { + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + return this; + } + + /** + * Builds the settings from the values set so far. The builder can be used again. + * @return the settings + */ + public AcpClientSettings build() { + return new AcpClientSettings(requestTimeout, promptTimeout, transport, + new Stdio(stdioCommand, stdioArgs, stdioEnv), new WebSocket(websocketUri, websocketConnectTimeout), + new Http(httpUri), capabilities); + } + + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientTransports.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientTransports.java new file mode 100644 index 00000000..b7e82375 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClientTransports.java @@ -0,0 +1,168 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.client.transport.AgentParameters; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransportOptions; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.util.PlatformThreads; +import org.jspecify.annotations.Nullable; + +/** + * Creates the client transport the {@link AcpClientSettings} describe, by one rule that every + * framework applies, so the same configuration selects the same transport everywhere: + *
      + *
    • an explicit {@code transport.type} wins, and fails when its command or URI is missing;
    • + *
    • otherwise exactly one of {@code transport.stdio.command}, {@code transport.websocket.uri} + * and {@code transport.http.uri} selects the transport, and setting more than one fails, naming + * the transports found and the {@code transport.type} key that chooses one;
    • + *
    • with none, there is no transport, and the framework creates no client: an agent-only + * application, or one that configures no client.
    • + *
    + * + *

    Pass the framework's property prefix, such as {@code quarkus.acp.client}, so the error + * messages name the keys the user wrote. The stdio transport starts the agent process with the + * settings' arguments and added environment; the WebSocket transport uses the settings' connect + * timeout; each transport uses the default JSON mapper. A transport is not connected here: + * building the client on it does that, and one transport serves one client. + */ +public final class AcpClientTransports { + + /** The prefix error messages name when the caller gives none. */ + static final String DEFAULT_PREFIX = "acp.client"; + + private AcpClientTransports() { + } + + /** + * Returns the transport the settings describe, as {@link #create(AcpClientSettings, String)} + * does, with errors that name {@code acp.client.*} keys. + * @param settings the client settings + * @return the transport, or empty when no transport is configured + * @throws IllegalStateException if several transports are configured with no type, or the + * type lacks its command or URI + */ + public static Optional create(AcpClientSettings settings) { + return create(settings, DEFAULT_PREFIX); + } + + /** + * Returns the transport the settings describe, by the rule above, on the SDK's own threads + * ({@link AcpTransportThreads#sdkDefault()}). Empty exactly when + * {@link AcpClientSettings#hasTransport()} is false. + * @param settings the client settings + * @param prefix the framework's prefix of the client keys, such as {@code spring.acp.client}, + * which error messages name + * @return the transport, not connected, or empty when no transport is configured + * @throws IllegalStateException if several transports are configured with no type (the + * message names {@code .transport.type}), or the type lacks its command or URI (the + * message names the missing key) + */ + public static Optional create(AcpClientSettings settings, String prefix) { + return create(settings, prefix, AcpTransportThreads.sdkDefault()); + } + + /** + * Returns the transport the settings describe, by the rule above, with the WebSocket and + * Streamable HTTP transports on the given threads: the framework's executor, the SDK's own + * threads (virtual on JDK 21 and later), or platform threads on every JDK. A transport never + * shuts down an executor it is given. The stdio transport keeps its own reader and writer + * threads either way. + * @param settings the client settings + * @param prefix the framework's prefix of the client keys, which error messages name + * @param threads the threads the network transports run on + * @return the transport, not connected, or empty when no transport is configured + * @throws IllegalStateException as {@link #create(AcpClientSettings, String)} does + */ + public static Optional create(AcpClientSettings settings, String prefix, + AcpTransportThreads threads) { + AcpTransportType type = type(settings, prefix); + if (type == null) { + return Optional.empty(); + } + return Optional.of(switch (type) { + case STDIO -> stdio(settings.stdio(), prefix); + case WEBSOCKET -> webSocket(required(settings.websocket().uri(), type, prefix, "websocket.uri"), threads) + .connectTimeout(settings.websocket().connectTimeout()); + case HTTP -> http(required(settings.http().uri(), type, prefix, "http.uri"), threads); + }); + } + + private static WebSocketAcpClientTransport webSocket(URI uri, AcpTransportThreads threads) { + Executor executor = threads.executor(); + if (executor == null && !threads.virtualThreads()) { + // The WebSocket transport's own pool before JDK 21; its idle threads end after a + // minute, so the transport not shutting it down leaves nothing running. + executor = PlatformThreads.newCachedPool("acp-ws-client"); + } + return (executor != null) ? new WebSocketAcpClientTransport(uri, AcpJsonMapper.createDefault(), executor) + : new WebSocketAcpClientTransport(uri); + } + + private static StreamableHttpAcpClientTransport http(URI uri, AcpTransportThreads threads) { + StreamableHttpAcpClientTransportOptions.Builder options = StreamableHttpAcpClientTransportOptions.builder() + .virtualThreads(threads.virtualThreads()); + Executor executor = threads.executor(); + if (executor != null) { + options.executor(executor); + } + return new StreamableHttpAcpClientTransport(uri, AcpJsonMapper.createDefault(), options.build()); + } + + /** + * The transport type: the explicit one, else the only one configured. + * @return the type, or null when none is configured + */ + static @Nullable AcpTransportType type(AcpClientSettings settings, String prefix) { + AcpTransportType type = settings.transport(); + if (type != null) { + return type; + } + List configured = new ArrayList<>(); + if (settings.stdio().command() != null) { + configured.add(AcpTransportType.STDIO); + } + if (settings.websocket().uri() != null) { + configured.add(AcpTransportType.WEBSOCKET); + } + if (settings.http().uri() != null) { + configured.add(AcpTransportType.HTTP); + } + if (configured.size() > 1) { + throw new IllegalStateException("Several ACP client transports are configured " + configured + + "; choose one with " + prefix + ".transport.type"); + } + return configured.isEmpty() ? null : configured.get(0); + } + + private static AcpClientTransport stdio(AcpClientSettings.Stdio stdio, String prefix) { + AgentParameters.Builder agent = AgentParameters + .builder(required(stdio.command(), AcpTransportType.STDIO, prefix, "stdio.command")) + .args(stdio.args()); + if (!stdio.env().isEmpty()) { + agent.env(stdio.env()); + } + return new StdioAcpClientTransport(agent.build()); + } + + private static T required(@Nullable T value, AcpTransportType type, String prefix, String property) { + if (value == null) { + throw new IllegalStateException(prefix + ".transport.type=" + type.value() + " requires " + prefix + + ".transport." + property); + } + return value; + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClients.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClients.java new file mode 100644 index 00000000..db95c398 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpClients.java @@ -0,0 +1,149 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Builds the application's ACP client the same way in every framework: one + * {@link AcpAsyncClient} on the transport from {@link AcpClientTransports}, configured from the + * {@link AcpClientSettings} and the application's {@link AcpClientCustomizer}s, and one + * {@link AcpSyncClient} facade over that same client. A framework exposes both as beans, and + * closes the async one through an {@link AcpClientHost} when the container shuts down. + * + *

    What the shared code does: it advertises the settings' capabilities, applies the request + * and prompt timeouts, sets a default session-update handler that logs each update at DEBUG + * (so the SDK does not warn about updates nobody handles), applies the customizers in order, + * and turns a missing-handler error into one that names the framework's capability settings. + * What the framework does: create the transport, collect the customizer beans in its bean + * order, and pass its own property prefix so errors name its keys. + */ +public final class AcpClients { + + private static final Logger logger = LoggerFactory.getLogger(AcpClients.class); + + private AcpClients() { + } + + /** + * Returns the async client as + * {@link #async(AcpClientTransport, AcpClientSettings, List, String)} builds it, with errors + * that name {@code acp.client.*} keys. + * @param transport the client transport + * @param settings the client settings + * @param customizers the customizers, in order + * @return the client, not yet initialized + * @throws IllegalStateException if a capability setting is true but no customizer registers + * the handlers that serve it, or the transport refuses to connect + */ + public static AcpAsyncClient async(AcpClientTransport transport, AcpClientSettings settings, + List customizers) { + return async(transport, settings, customizers, AcpClientTransports.DEFAULT_PREFIX); + } + + /** + * Returns the async client on the transport: the settings' capabilities, their request + * timeout (unset keeps the SDK default, 60 seconds) and prompt timeout (unset: none), a + * default session-update handler, then the customizers in order. The default update handler logs + * each update at DEBUG; the first update handler a customizer adds with + * {@code sessionUpdateHandler} replaces it, rather than running beside it. Building the + * client connects the transport (for stdio, starts the agent process); the ACP handshake + * waits for the application's {@code initialize()}. + * @param transport the client transport, not yet used by another client + * @param settings the client settings + * @param customizers the customizers, in order + * @param prefix the framework's prefix of the client keys, such as {@code spring.acp.client}, + * which errors name + * @return the client, not yet initialized + * @throws IllegalStateException if a capability setting is true but no customizer registers + * the handlers that serve it; the message names the SDK's missing setters and the settings, + * such as {@code spring.acp.client.capabilities.terminal=true}. A customizer that calls + * {@code defaultSessionUpdateHandler} also fails, since the default is already set, and so + * does a transport that refuses to connect: one already connected, or a stdio command that + * cannot be started + */ + public static AcpAsyncClient async(AcpClientTransport transport, AcpClientSettings settings, + List customizers, String prefix) { + AcpClient.AsyncSpec spec = AcpClient.async(transport) + .clientCapabilities(settings.capabilities().toClientCapabilities()) + // Session updates always have an update handler, so the SDK does not warn about an unhandled + // one. This one only logs; an update handler a customizer adds replaces it. + .defaultSessionUpdateHandler(AcpClients::logSessionUpdate); + Duration requestTimeout = settings.requestTimeout(); + if (requestTimeout != null) { + spec.requestTimeout(requestTimeout); + } + Duration promptTimeout = settings.promptTimeout(); + if (promptTimeout != null) { + spec.promptTimeout(promptTimeout); + } + customizers.forEach(customizer -> customizer.customize(spec)); + try { + return spec.build(); + } + catch (IllegalStateException ex) { + throw namingTheSettings(ex, prefix, settings.capabilities()); + } + } + + /** + * Adds to the SDK's error about an advertised capability without its handler which of the + * capability settings it came from: register the handler in an {@link AcpClientCustomizer}, or + * set the setting to false. Any other error is returned as it is. + */ + static IllegalStateException namingTheSettings(IllegalStateException error, String prefix, + AcpClientSettings.Capabilities capabilities) { + String message = String.valueOf(error.getMessage()); + // The SDK's message says " needs " for each capability short of handlers. + Map settings = new LinkedHashMap<>(); + settings.put("read-text-file", capabilities.readTextFile() && message.contains("fs.readTextFile needs")); + settings.put("write-text-file", capabilities.writeTextFile() && message.contains("fs.writeTextFile needs")); + settings.put("terminal", capabilities.terminal() && message.contains("terminal needs")); + settings.put("elicitation-form", capabilities.elicitationForm() && message.contains("elicitation needs")); + settings.put("elicitation-url", capabilities.elicitationUrl() && message.contains("elicitation needs")); + List named = new ArrayList<>(); + settings.forEach((setting, missing) -> { + if (missing) { + named.add(prefix + ".capabilities." + setting + "=true"); + } + }); + if (named.isEmpty()) { + return error; + } + return new IllegalStateException(message + ". The capabilities come from " + String.join(", ", named) + + ": register the handlers in an AcpClientCustomizer, or set the settings to false", error); + } + + /** + * Returns a sync facade over the async client, so the application can inject either and both + * use one connection. Build the sync client this way, never a second client on the same + * transport: that would connect the transport a second time, which the SDK refuses. Closing + * the async client (through {@link AcpClientHost}) also ends the facade. + * @param async the async client from {@link #async} + * @return the sync client + */ + public static AcpSyncClient sync(AcpAsyncClient async) { + return new AcpSyncClient(async); + } + + static Mono logSessionUpdate(AcpSchema.SessionNotification notification) { + logger.debug("Session update for {}: {}", notification.sessionId(), notification.update()); + return Mono.empty(); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpHost.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpHost.java new file mode 100644 index 00000000..d75f8e6c --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpHost.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.OptionalInt; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; + +import org.jspecify.annotations.Nullable; + +/** + * Runs the application's {@code @AcpAgent} for a framework: the framework calls {@link #start()} + * from its start hook and {@link #stop(Duration)} from its stop hook (or a JVM shutdown hook), and + * the host does the rest. Use {@link AcpAgentHost} for one agent on one transport (stdio, or a + * transport bean of the application's) and {@link AcpListenerHost} for the SDK's own HTTP and + * WebSocket listener. An agent served by the framework's own Servlet container needs no host; see + * {@link AcpServletHost}. + * + *

    Only {@link #stop(Duration)} blocks on purpose: {@code start()} returns once the transport + * or listener runs, and the SDK serves the agent on threads of its own. So a framework whose + * {@code main} returns once its context is up (Micronaut does) can host an agent too. The SDK's + * threads are daemons, so such a framework calls {@link #holdJvmUntilTermination()} after + * {@code start()} when nothing else keeps the JVM running (for stdio, without an embedded + * server). + * + *

    The lifecycle is one-way: {@code start()} once, then stop. A second {@code start()} does + * nothing, and so does a {@code start()} after a stop began; the stop methods share one graceful + * stop, however often they are called. {@link #termination()} completes when the transport or + * listener has ended, whether a stop ended it or (for stdio) the client did. + * + *

    Implementations must be safe to call from several threads at once, since a shutdown hook + * may stop the host while the framework's start hook is still starting it. + * {@link #holdJvmUntilTermination()} has a default built on {@link #termination()}. + * + * @see AcpAgentHost + * @see AcpListenerHost + */ +public interface AcpHost { + + /** The name of the thread {@link #holdJvmUntilTermination()} starts: {@value}. */ + String HOLD_THREAD_NAME = "acp-agent-await"; + + /** + * Starts serving the agent and returns once the transport or listener runs. Does nothing if + * the host was started before, or once a stop has begun. + * @throws RuntimeException if the transport or listener cannot start; each implementation + * names the cases + */ + void start(); + + /** + * Begins a graceful stop without waiting for it: requests in flight are answered or + * cancelled within the SDK's bounds, then the transport or listener closes. Only the first + * call starts the stop; every call returns the same stage. Calling it before {@link #start()} + * stops the host for good. A framework with an asynchronous graceful-shutdown hook returns + * this stage from it. + * @return a stage that completes when the host has stopped + */ + CompletionStage<@Nullable Void> stopGracefully(); + + /** + * Stops the host: begins {@link #stopGracefully()} and waits for it at most {@code timeout}. + * If the graceful stop fails, takes longer, or the waiting thread is interrupted (the + * interrupt is kept), the host stops waiting and closes what it can at once: each + * implementation says what that is. Call it from the framework's stop hook or a JVM shutdown + * hook; it is safe while {@link #start()} is still running on another thread. Returns at once + * when the host has already stopped. + * @param timeout how long to wait for the graceful stop + */ + void stop(Duration timeout); + + /** + * Returns a stage that completes when the transport or listener has ended: by a stop, or, for + * a single transport such as stdio, by the client closing it. The stage is read-only. + * @return the termination stage + */ + CompletionStage<@Nullable Void> termination(); + + /** + * Returns the port the SDK's listener is bound to, which is how a framework learns the port + * the operating system chose for port 0. + * @return the bound port once started; empty before the start, and always empty for a host + * of a single transport such as stdio + */ + OptionalInt port(); + + /** + * Starts one non-daemon thread, named {@value #HOLD_THREAD_NAME}, that ends when + * {@link #termination()} completes, normally or with an error. The SDK's own threads are + * daemons, so a framework calls this after {@link #start()} when nothing else keeps the JVM + * running: for example a stdio agent in an application without an embedded server. Call it + * once; each call starts another thread. + * + * @implSpec The default starts the thread and returns at once; the thread joins + * {@code termination()} and ignores how it completed. + */ + default void holdJvmUntilTermination() { + Thread hold = new Thread(() -> { + try { + termination().toCompletableFuture().join(); + } + catch (CompletionException ex) { + // The transport ended with an error: it has ended all the same. + } + }, HOLD_THREAD_NAME); + hold.setDaemon(false); + hold.start(); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListenerHost.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListenerHost.java new file mode 100644 index 00000000..e8028845 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListenerHost.java @@ -0,0 +1,119 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.OptionalInt; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AcpHost} for the SDK's own Streamable HTTP listener from + * {@code acp-streamable-http-jetty}: a Jetty server on its own port that serves HTTP/1.1, + * cleartext HTTP/2 and WebSocket upgrades on one path, with one agent per connection. Use it when + * the agent is served over {@code http} or {@code websocket} and the framework has no Servlet + * container to mount the SDK's servlet in (then see {@link AcpServletHost}). Create the listener + * with {@link AcpListeners#listener}, after checking {@link AcpListeners#isListenerAvailable()}. + * For one agent on stdio, use {@link AcpAgentHost}. + * + *

    How it differs from {@link AcpAgentHost}: there is no transport-end action, because the + * listener ends only when stopped; a listener serves many clients, so no client leaving ends it. + * Nor is {@link #holdJvmUntilTermination()} needed: Jetty's threads are not daemons and keep the + * JVM running until the stop. {@link #start()} binds the port and waits for it at most 30 + * seconds, holding the host's lock meanwhile, so a stop that arrives during the start waits for + * the start to return. {@link #port()} then gives the bound port, the way to learn the port + * chosen for port 0. A stop closes every connection as + * {@code StreamableHttpAcpAgentTransport.closeGracefully()} does, bounded by the endpoint's + * shutdown timeout (5 seconds unless {@link AcpAgentSettings.Limits#shutdownTimeout()} sets one). + * {@link #stop(Duration)} has nothing faster to fall back on: when its timeout passes, it returns + * while that close goes on. {@link #termination()} completes when the listener has stopped. + * + *

    The host is safe for use from several threads. + */ +public final class AcpListenerHost implements AcpHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpListenerHost.class); + + /** How long starting the listener may take. */ + static final Duration START_TIMEOUT = Duration.ofSeconds(30); + + private final StreamableHttpAcpAgentTransport listener; + + private final CompletableFuture<@Nullable Void> termination; + + private final Object lock = new Object(); + + private boolean started; + + private @Nullable CompletableFuture<@Nullable Void> stopped; + + /** + * Creates a host for the listener, not yet started. + * @param listener the listener from {@link AcpListeners#listener}, not started + */ + public AcpListenerHost(StreamableHttpAcpAgentTransport listener) { + this.listener = listener; + this.termination = listener.awaitTermination().toFuture(); + } + + /** + * {@inheritDoc} + * + *

    Binds the listener's port, waiting at most 30 seconds, and logs the port at INFO. A + * listener whose start failed cannot be started again. + * @throws IllegalStateException if the listener does not start within 30 seconds + * @throws RuntimeException if the server cannot start, for example because the port is in + * use; a checked cause such as a {@code BindException} arrives wrapped + */ + @Override + public void start() { + synchronized (lock) { + if (started || stopped != null) { + return; + } + listener.start().block(START_TIMEOUT); + started = true; + logger.info("ACP agent listening on port {}", listener.getPort()); + } + } + + @Override + public CompletionStage<@Nullable Void> stopGracefully() { + synchronized (lock) { + CompletableFuture<@Nullable Void> current = stopped; + if (current == null) { + // The listener's close is bounded by its shutdown timeout. + current = listener.closeGracefully().toFuture(); + stopped = current; + } + return current; + } + } + + @Override + public void stop(Duration timeout) { + Hosts.await(stopGracefully(), timeout, "ACP agent listener", () -> { + // Nothing closes faster than the listener's own bounded close. + }); + } + + @Override + public CompletionStage<@Nullable Void> termination() { + return termination.minimalCompletionStage(); + } + + @Override + public OptionalInt port() { + synchronized (lock) { + return started ? OptionalInt.of(listener.getPort()) : OptionalInt.empty(); + } + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListeners.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListeners.java new file mode 100644 index 00000000..a3af092e --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpListeners.java @@ -0,0 +1,141 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.util.VirtualThreads; + +/** + * Creates the SDK's Streamable HTTP endpoints, which come from the optional + * {@code acp-streamable-http-jetty} module: the SDK's own {@link #listener listener}, for a + * framework without a Servlet container, or its {@link #servlet servlet}, for one with. Both + * serve one agent per connection from an {@code AcpAgentFactory} that + * {@link AcpAgents#builder} builds with {@code buildFactory()}, and both apply the endpoint + * limits of {@link AcpAgentSettings}. + * + *

    The optional module's types appear only here, in {@link AcpListenerHost} and + * {@link AcpServletHost}, and in {@link AcpAgentSettings#toOptions(boolean)}, so a framework + * without the module can load the rest of the package. Use them only when the agent is served + * over HTTP, and call {@link #isListenerAvailable()} before {@link #listener} to report a + * missing module in the framework's own words. + */ +public final class AcpListeners { + + /** The coordinates named when the listener is missing. */ + static final String MODULE = "com.agentclientprotocol:acp-streamable-http-jetty"; + + private static final String LISTENER_CLASS = "com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport"; + + private static final String JETTY_SERVER_CLASS = "org.eclipse.jetty.server.Server"; + + private AcpListeners() { + } + + /** + * Returns whether the SDK's own listener can run: {@code acp-streamable-http-jetty} and its + * Jetty server are both on the classpath. Checked without initialising either class. A + * framework that uses only the servlet may exclude the Jetty server, so this can be false + * while {@link #servlet} still works. + * @return true when {@link #listener} can be called + */ + public static boolean isListenerAvailable() { + return isPresent(LISTENER_CLASS) && isPresent(JETTY_SERVER_CLASS); + } + + static boolean isPresent(String className) { + try { + Class.forName(className, false, AcpListeners.class.getClassLoader()); + return true; + } + catch (ClassNotFoundException | LinkageError ex) { + return false; + } + } + + /** + * Returns the SDK's listener on the settings' listener host (loopback only unless set), port + * and path: HTTP/1.1, cleartext + * HTTP/2 and WebSocket upgrades on that one path, with the settings' limits and the + * listener's stream limit, and the default JSON mapper, on the SDK's own threads (virtual + * threads on JDK 21 and later). It is not started; give it to an {@link AcpListenerHost}. + * @param settings the agent settings + * @param factory the factory creating one agent per connection + * @return the listener, not started + * @throws IllegalStateException if {@code acp-streamable-http-jetty} or its Jetty server is + * not on the classpath; the message names the module + */ + public static StreamableHttpAcpAgentTransport listener(AcpAgentSettings settings, AcpAgentFactory factory) { + return listener(settings, factory, AcpTransportThreads.sdkDefault()); + } + + /** + * Returns the SDK's listener as {@link #listener(AcpAgentSettings, AcpAgentFactory)} does, + * on the given threads. On JDK 21 and later the listener serves on Jetty's + * {@code VirtualThreadPool}: its tasks on the framework's executor when one is given, else on + * virtual threads of its own; with {@link AcpTransportThreads#platform()}, and on every JDK + * before 21, on Jetty's pool of platform threads. Give an executor that starts a thread per + * task (virtual threads), not a bounded pool: Jetty's selectors and acceptors hold their + * tasks for as long as the listener runs. The listener never shuts it down. + * @param settings the agent settings + * @param factory the factory creating one agent per connection + * @param threads the threads the listener serves on + * @return the listener, not started + * @throws IllegalStateException if {@code acp-streamable-http-jetty} or its Jetty server is + * not on the classpath; the message names the module + */ + public static StreamableHttpAcpAgentTransport listener(AcpAgentSettings settings, AcpAgentFactory factory, + AcpTransportThreads threads) { + if (!isListenerAvailable()) { + throw new IllegalStateException("An ACP agent served over " + settings.transport().value() + " needs " + + MODULE + " (and its Jetty server) on the classpath"); + } + AcpAgentSettings.Http http = settings.http(); + StreamableHttpAcpAgentTransportOptions.Builder options = settings.toOptionsBuilder(true) + .virtualThreads(threads.virtualThreads()); + Executor executor = threads.executor(); + if (executor != null && VirtualThreads.isSupported()) { + options.executor(executor); + } + return new StreamableHttpAcpAgentTransport(http.listener().port(), http.path(), AcpJsonMapper.createDefault(), + factory, options.build()); + } + + /** + * Returns the SDK's servlet, for Streamable HTTP inside the framework's own Servlet container, + * with the settings' limits (not the listener's port or stream limit) and the default JSON + * mapper. The framework registers it at {@link AcpAgentSettings.Http#path()} with async + * support on, and calls {@link AcpServletHost#closeBeforeShutdown} before the container shuts + * down. The servlet upgrades WebSocket requests itself where the container has Jakarta + * WebSocket 2.1 (Tomcat, Jetty, Undertow); a container without it answers them 501. + * @param settings the agent settings + * @param factory the factory creating one agent per connection + * @return the servlet + */ + public static StreamableHttpAcpServlet servlet(AcpAgentSettings settings, AcpAgentFactory factory) { + return new StreamableHttpAcpServlet(endpoint(settings, factory)); + } + + /** + * Returns the framework-neutral ACP endpoint with the settings' limits and allowed origins (not + * the listener's port, host or stream limit) and the default JSON mapper, for a framework that + * mounts it itself: in a {@link StreamableHttpAcpServlet} of its own, or on a router of its own + * through the host contract. The framework calls {@link AcpHttpEndpoint#closeGracefully()} before + * its server's graceful shutdown. + * @param settings the agent settings + * @param factory the factory creating one agent per connection + * @return the endpoint, not started + */ + public static AcpHttpEndpoint endpoint(AcpAgentSettings settings, AcpAgentFactory factory) { + return AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), factory, settings.toOptions(false)); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpServletHost.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpServletHost.java new file mode 100644 index 00000000..6fbd7104 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpServletHost.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Closes the SDK's servlet before a framework's Servlet container shuts down, for an agent served + * over HTTP inside the framework's own server (Spring Boot's Tomcat or Jetty, Quarkus' Undertow). + * There the container starts the servlet and routes requests to it, so no {@link AcpHost} is + * needed; the framework's one job is to call {@link #closeBeforeShutdown} from a stop hook that + * runs before the container's graceful shutdown. Create the servlet with + * {@link AcpListeners#servlet}. When the framework has no Servlet container, use the SDK's own + * listener and {@link AcpListenerHost} instead. + */ +public final class AcpServletHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpServletHost.class); + + private AcpServletHost() { + } + + /** + * Closes every ACP connection the servlet holds and waits for that at most {@code timeout}. + * Call it before the container's graceful shutdown begins: each connection holds an open SSE + * response, which the container counts as an active request and would wait for until its own + * shutdown timeout (in Spring Boot, a {@code SmartLifecycle} in {@code DEFAULT_PHASE} stops + * before the graceful shutdown does). As {@link StreamableHttpAcpServlet#closeGracefully()} + * does, in-flight prompts are cancelled, their SSE responses complete, an {@code initialize} + * still in flight is answered 503, and new connections are refused; a connection whose agent + * has not closed within the endpoint's shutdown timeout is closed at once. A close that fails + * or does not finish within {@code timeout} is logged as a warning, not thrown, and goes on + * after this method returns. + * @param servlet the servlet from {@link AcpListeners#servlet} + * @param timeout how long to wait for the connections to close; best a little longer than the + * endpoint's shutdown timeout, which bounds the close itself + */ + public static void closeBeforeShutdown(StreamableHttpAcpServlet servlet, Duration timeout) { + try { + servlet.closeGracefully().block(timeout); + } + catch (RuntimeException ex) { + logger.warn("ACP HTTP connections did not close within {}: {}", timeout, ex.getMessage()); + } + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportThreads.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportThreads.java new file mode 100644 index 00000000..055e007c --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportThreads.java @@ -0,0 +1,93 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.util.Assert; +import org.jspecify.annotations.Nullable; + +/** + * The threads a framework's ACP network transports run on: the WebSocket and Streamable HTTP + * client transports ({@link AcpClientTransports}) and the SDK's listener ({@link AcpListeners}). + * A framework picks one by its own convention: + *

      + *
    • {@link #executor(Executor)}: the framework's executor for blocking work, such as its + * virtual-thread executor. The transports run on it and never shut it down. The listener takes + * it on JDK 21 and later only, and needs one that starts a thread per task.
    • + *
    • {@link #sdkDefault()}: the SDK's own threads, which are virtual threads on JDK 21 and + * later, and pools of platform threads before.
    • + *
    • {@link #platform()}: pools of platform threads on every JDK, for an application that has + * not opted into virtual threads (Spring Boot without {@code spring.threads.virtual.enabled}).
    • + *
    + * The stdio transports are not affected: their reader and writer threads block on the process's + * pipes for as long as it runs, which a virtual thread would not make cheaper. + */ +public final class AcpTransportThreads { + + private static final AcpTransportThreads SDK_DEFAULT = new AcpTransportThreads(null, true); + + private static final AcpTransportThreads PLATFORM = new AcpTransportThreads(null, false); + + private final @Nullable Executor executor; + + private final boolean virtualThreads; + + private AcpTransportThreads(@Nullable Executor executor, boolean virtualThreads) { + this.executor = executor; + this.virtualThreads = virtualThreads; + } + + /** + * The SDK's own threads: virtual threads on JDK 21 and later, pools of platform threads + * before. + * @return the SDK's default + */ + public static AcpTransportThreads sdkDefault() { + return SDK_DEFAULT; + } + + /** + * Pools of platform threads of the SDK's own, on every JDK. + * @return platform threads + */ + public static AcpTransportThreads platform() { + return PLATFORM; + } + + /** + * The framework's executor, which must allow blocking and not cap its threads: a + * virtual-thread executor or a framework's worker pool. + * @param executor the executor; the framework owns it + * @return the framework's executor + * @throws IllegalArgumentException if {@code executor} is null + */ + public static AcpTransportThreads executor(Executor executor) { + Assert.notNull(executor, "The executor can not be null"); + return new AcpTransportThreads(executor, true); + } + + /** + * Returns the framework's executor, if one was chosen. + * @return the executor, or null for the SDK's own threads + */ + public @Nullable Executor executor() { + return executor; + } + + /** + * Returns whether the SDK's own threads may be virtual where the JDK has them. + * @return false for platform threads on every JDK + */ + public boolean virtualThreads() { + return virtualThreads; + } + + @Override + public String toString() { + return (executor != null) ? "executor " + executor : (virtualThreads ? "SDK default" : "platform threads"); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportType.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportType.java new file mode 100644 index 00000000..7a354c53 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/AcpTransportType.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.Locale; + +/** + * The value of the {@code transport.type} setting: the transport an agent is served over + * ({@link AcpAgentSettings#transport()}) or a client connects with + * ({@link AcpClientSettings#transport()}). Configuration writes it in any case, as {@code stdio}, + * {@code websocket} or {@code http}; {@link #parse(String)} reads it and {@link #value()} writes + * it back. + * + *

    For an agent, {@link #WEBSOCKET} means the same as {@link #HTTP}: the SDK's listener serves + * Streamable HTTP and accepts WebSocket upgrades on the same path + * ({@link AcpAgentSettings#servesHttp()} is true for both). The SDK's servlet serves HTTP only, + * so inside a Servlet container WebSocket needs the framework's own routing. For a client the + * two differ: each selects its own client transport and URI ({@link AcpClientTransports}). + */ +public enum AcpTransportType { + + /** + * Newline-delimited JSON-RPC over standard input and output: the agent is a subprocess the + * client starts. The agent's default. + */ + STDIO, + + /** + * ACP over a WebSocket connection, one ACP message per text frame. For an agent, the + * same as {@link #HTTP}. + */ + WEBSOCKET, + + /** + * ACP Streamable HTTP: the client posts its messages to one endpoint and reads the agent's + * from Server-Sent Event (SSE) streams. + */ + HTTP; + + /** + * Returns the type a configuration value names, ignoring case and surrounding white space. + * @param value {@code stdio}, {@code websocket} or {@code http} + * @return the type + * @throws IllegalArgumentException if the value names no type; the message lists the three + * values + */ + public static AcpTransportType parse(String value) { + String name = value.trim().toUpperCase(Locale.ROOT); + for (AcpTransportType type : values()) { + if (type.name().equals(name)) { + return type; + } + } + throw new IllegalArgumentException("Unknown ACP transport type '" + value + "': use stdio, websocket or http"); + } + + /** + * Returns the configuration value for this type, as messages and settings write it. + * @return the lower-case name: {@code stdio}, {@code websocket} or {@code http} + */ + public String value() { + return name().toLowerCase(Locale.ROOT); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/Hosts.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/Hosts.java new file mode 100644 index 00000000..c63b2267 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/Hosts.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** What the hosts share: waiting for a graceful close, bounded, then closing at once. */ +final class Hosts { + + private static final Logger logger = LoggerFactory.getLogger(Hosts.class); + + private Hosts() { + } + + /** + * Waits for {@code graceful} at most {@code timeout}; when it does not complete in time, or + * fails, runs {@code now}. + */ + static void await(CompletionStage<@Nullable Void> graceful, Duration timeout, String what, Runnable now) { + try { + graceful.toCompletableFuture().get(timeout.toNanos(), TimeUnit.NANOSECONDS); + return; + } + catch (TimeoutException ex) { + logger.warn("{} did not close within {}; closing it now", what, timeout); + } + catch (ExecutionException ex) { + logger.warn("{} failed to close gracefully; closing it now: {}", what, String.valueOf(ex.getCause())); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while closing {}; closing it now", what); + } + now.run(); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsReader.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsReader.java new file mode 100644 index 00000000..1ff05aa6 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsReader.java @@ -0,0 +1,174 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jspecify.annotations.Nullable; + +/** + * Reads typed values from a {@link SettingsSource} under a prefix. A value that does not parse + * fails with the full key in the message. + */ +final class SettingsReader { + + private static final Pattern AMOUNT = Pattern.compile("(\\d+)\\s*([a-zA-Z]*)"); + + private static final Map DURATION_UNITS = Map.of("", ChronoUnit.MILLIS, "ms", + ChronoUnit.MILLIS, "s", ChronoUnit.SECONDS, "m", ChronoUnit.MINUTES, "h", ChronoUnit.HOURS, "d", + ChronoUnit.DAYS); + + private static final Map SIZE_UNITS = Map.of("", 1L, "B", 1L, "K", 1024L, "KB", 1024L, "M", + 1024L * 1024, "MB", 1024L * 1024, "G", 1024L * 1024 * 1024, "GB", 1024L * 1024 * 1024); + + private final SettingsSource source; + + private final String prefix; + + SettingsReader(SettingsSource source, String prefix) { + this.source = source; + this.prefix = prefix.isEmpty() || prefix.endsWith(".") ? prefix : prefix + "."; + } + + String key(String name) { + return prefix + name; + } + + @Nullable String string(String name) { + String value = source.get(key(name)); + return (value == null || value.isBlank()) ? null : value.trim(); + } + + List list(String name) { + return source.list(key(name)); + } + + Map map(String name) { + return source.map(key(name)); + } + + boolean bool(String name, boolean defaultValue) { + String value = string(name); + if (value == null) { + return defaultValue; + } + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + throw invalid(name, value, "true or false"); + } + + @Nullable Integer integer(String name) { + String value = string(name); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value); + } + catch (NumberFormatException ex) { + throw invalid(name, value, "an integer"); + } + } + + @Nullable URI uri(String name) { + String value = string(name); + if (value == null) { + return null; + } + try { + return new URI(value); + } + catch (URISyntaxException ex) { + throw invalid(name, value, "a URI"); + } + } + + @Nullable AcpTransportType transportType(String name) { + String value = string(name); + if (value == null) { + return null; + } + try { + return AcpTransportType.parse(value); + } + catch (IllegalArgumentException ex) { + throw invalid(name, value, "stdio, websocket or http"); + } + } + + /** + * A duration: ISO-8601 ({@code PT30S}), or an amount with a unit {@code ms}, {@code s}, + * {@code m}, {@code h} or {@code d} ({@code 30s}); a bare number is milliseconds. + */ + @Nullable Duration duration(String name) { + String value = string(name); + if (value == null) { + return null; + } + try { + if (value.regionMatches(true, 0, "P", 0, 1)) { + return Duration.parse(value); + } + Matcher matcher = matching(value); + ChronoUnit unit = DURATION_UNITS.get(matcher.group(2).toLowerCase(Locale.ROOT)); + if (unit != null) { + return Duration.of(Long.parseLong(matcher.group(1)), unit); + } + } + catch (DateTimeParseException | IllegalArgumentException ex) { + // reported below + } + throw invalid(name, value, "a duration such as 30s or PT30S"); + } + + /** + * A size in bytes: a bare number, or an amount with a unit {@code B}, {@code KB}, {@code MB} + * or {@code GB} (powers of 1024). + */ + @Nullable Long bytes(String name) { + String value = string(name); + if (value == null) { + return null; + } + try { + Matcher matcher = matching(value); + Long multiplier = SIZE_UNITS.get(matcher.group(2).toUpperCase(Locale.ROOT)); + if (multiplier != null) { + return Math.multiplyExact(Long.parseLong(matcher.group(1)), multiplier); + } + } + catch (IllegalArgumentException | ArithmeticException ex) { + // reported below + } + throw invalid(name, value, "a size such as 16MB"); + } + + /** An amount and its unit; IllegalArgumentException when the value is neither. */ + private static Matcher matching(String value) { + Matcher matcher = AMOUNT.matcher(value); + if (!matcher.matches()) { + throw new IllegalArgumentException(value); + } + return matcher; + } + + private IllegalArgumentException invalid(String name, String value, String expected) { + return new IllegalArgumentException(key(name) + "=" + value + " is not " + expected); + } + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsSource.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsSource.java new file mode 100644 index 00000000..c61bebad --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/SettingsSource.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +/** + * Plain key/value configuration, for a framework that has no binding of its own onto the settings + * builders: {@link AcpAgentSettings#from} and {@link AcpClientSettings#from} read their keys + * through it. A framework that binds typed configuration objects (Spring Boot and Micronaut + * {@code @ConfigurationProperties}, Quarkus {@code @ConfigMapping}) does not need it; it calls + * the builders instead. + * + *

    Implementations adapt the framework's configuration: keys are full property names in kebab + * case, such as {@code acp.client.transport.stdio.args}, and turning the framework's own key + * forms (camel case, environment variables) into that form is the adapter's job. One string + * lookup cannot express a list or a map, so each has its own method. Values are read once, when + * {@code from} runs, on the caller's thread. + */ +public interface SettingsSource { + + /** + * Returns the value of a key as written. The reader trims it and treats a blank value as + * not set. + * @param key the full property name + * @return the value, or null when the key is not set + */ + @Nullable String get(String key); + + /** + * Returns the list under a key, such as the stdio agent's arguments, in order. + * @param key the full property name + * @return the values, empty when the key is not set; no element may be null + */ + List list(String key); + + /** + * Returns the map under a key, such as the stdio agent's environment. Map keys keep their + * case, since environment variable names are case-sensitive. + * @param key the full property name + * @return the entries, empty when the key is not set; no key or value may be null + */ + Map map(String key); + +} diff --git a/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/package-info.java b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/package-info.java new file mode 100644 index 00000000..a1ef2347 --- /dev/null +++ b/acp-integration/src/main/java/com/agentclientprotocol/sdk/integration/package-info.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Framework-neutral building blocks for ACP framework integrations (Spring Boot, Micronaut, + * Quarkus and any other container). A framework binds its own configuration onto + * {@link com.agentclientprotocol.sdk.integration.AcpAgentSettings} and + * {@link com.agentclientprotocol.sdk.integration.AcpClientSettings}, finds the application's + * {@code @AcpAgent} with {@link com.agentclientprotocol.sdk.integration.AcpAgentDiscovery}, + * assembles it with {@link com.agentclientprotocol.sdk.integration.AcpAgents} or the client with + * {@link com.agentclientprotocol.sdk.integration.AcpClients}, and drives an + * {@link com.agentclientprotocol.sdk.integration.AcpHost} from its own lifecycle hooks. Nothing + * here depends on a framework. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.integration; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscoveryTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscoveryTest.java new file mode 100644 index 00000000..6188c2f3 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentDiscoveryTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.List; + +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpAgentDiscoveryTest { + + private static final AgentCandidate FIRST = new AgentCandidate<>("first", String.class, () -> "a"); + + private static final AgentCandidate SECOND = new AgentCandidate<>("second", Integer.class, () -> 1); + + @Test + void noneIsAClientOnlyApplication() { + assertThat(AcpAgentDiscovery.requireSingle(List.of())).isEmpty(); + assertThat(AcpAgentDiscovery.requireSingle(List.of(), null)).isEmpty(); + } + + @Test + void oneIsServed() { + assertThat(AcpAgentDiscovery.requireSingle(List.of(FIRST))).contains(FIRST); + assertThat(AcpAgentDiscovery.requireSingle(List.of("com.example.Agent"), "acp.agent.enabled")) + .contains("com.example.Agent"); + } + + @Test + void moreThanOneIsAnErrorNamingThem() { + assertThatThrownBy(() -> AcpAgentDiscovery.requireSingle(List.of(SECOND, FIRST))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Found 2 @AcpAgent beans [java.lang.Integer, java.lang.String], but an application serves " + + "one: remove @AcpAgent from all but one"); + assertThatThrownBy(() -> AcpAgentDiscovery.requireSingle(List.of(FIRST, SECOND), "acp.agent.enabled")) + .hasMessageEndingWith(", or set acp.agent.enabled=false to serve none"); + assertThatThrownBy(() -> AcpAgentDiscovery.requireSingle(List.of("b.Second", "a.First"), + "quarkus.acp.agent.enabled")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Found 2 @AcpAgent classes [a.First, b.Second], but an application serves one: remove " + + "@AcpAgent from all but one, or set quarkus.acp.agent.enabled=false to serve none"); + } + + @Test + void aCandidateNeedsEveryPart() { + assertThatThrownBy(() -> new AgentCandidate<>(null, String.class, () -> "")).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new AgentCandidate<>("n", null, () -> "")).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new AgentCandidate<>("n", String.class, null)).isInstanceOf(NullPointerException.class); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentHostTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentHostTest.java new file mode 100644 index 00000000..947888f6 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentHostTest.java @@ -0,0 +1,183 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpAgentHostTest { + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + @Test + void servesUntilStoppedAndStopsOnce() throws Exception { + AtomicInteger transportEnds = new AtomicInteger(); + AcpAgentHost host = new AcpAgentHost(agent(pair.agentTransport()), transportEnds::incrementAndGet); + assertThat(host.port()).isEmpty(); + host.start(); + host.start(); // once + assertThat(TestAgents.roundTrip(pair.clientTransport())).containsExactly("echo: hello!"); + + CompletionStage stopped = host.stopGracefully(); + assertThat(host.stopGracefully()).isSameAs(stopped); + stopped.toCompletableFuture().get(10, TimeUnit.SECONDS); + host.termination().toCompletableFuture().get(10, TimeUnit.SECONDS); + host.stop(Duration.ofSeconds(1)); // already stopped: returns at once + host.start(); // nor does a start after the stop + Thread.sleep(200); + // The host stopped the agent itself: that is no transport end. + assertThat(transportEnds).hasValue(0); + } + + @Test + void theTransportEndingRunsTheActionOnceOnTheHostsThread() throws Exception { + CountDownLatch ran = new CountDownLatch(1); + List threads = new java.util.concurrent.CopyOnWriteArrayList<>(); + AcpAgentHost host = new AcpAgentHost(agent(pair.agentTransport()), () -> { + threads.add(Thread.currentThread().getName()); + ran.countDown(); + }); + host.start(); + pair.agentTransport().closeGracefully().block(Duration.ofSeconds(5)); + assertThat(ran.await(10, TimeUnit.SECONDS)).isTrue(); + host.termination().toCompletableFuture().get(5, TimeUnit.SECONDS); + host.stop(Duration.ofSeconds(5)); + Thread.sleep(100); + assertThat(threads).containsExactly(AcpAgentHost.TRANSPORT_END_THREAD_NAME); + } + + @Test + void aTransportThatEndedBeforeTheHostSawItStillCounts() throws Exception { + pair.agentTransport().closeGracefully().block(Duration.ofSeconds(5)); + CountDownLatch ran = new CountDownLatch(1); + AcpAgentHost host = new AcpAgentHost(agent(pair.agentTransport()), ran::countDown); + assertThat(ran.await(10, TimeUnit.SECONDS)).isTrue(); + host.stop(Duration.ofSeconds(5)); + } + + @Test + void aTransportFailingCountsAsItsEnd() throws Exception { + CountDownLatch ran = new CountDownLatch(1); + AcpAgentTransport failing = new TestAgents.StuckAgentTransport(pair.agentTransport()) { + + @Override + public Mono awaitTermination() { + return Mono.error(new IllegalStateException("broken pipe")); + } + + }; + AcpAgentHost host = new AcpAgentHost(agent(failing), ran::countDown); + assertThat(ran.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(host.termination().toCompletableFuture().get(5, TimeUnit.SECONDS)).isNull(); + } + + @Test + void aStopThatDoesNotFinishInTimeClosesAtOnce() { + TestAgents.StuckAgentTransport stuck = new TestAgents.StuckAgentTransport(pair.agentTransport()); + AcpAgentHost host = new AcpAgentHost(agent(stuck, Duration.ofSeconds(2)), () -> { + }); + host.start(); + host.stop(Duration.ofMillis(50)); + assertThat(stuck.closes).hasPositiveValue(); + } + + @Test + void anInterruptedStopClosesAtOnceAndKeepsTheInterrupt() { + TestAgents.StuckAgentTransport stuck = new TestAgents.StuckAgentTransport(pair.agentTransport()); + AcpAgentHost host = new AcpAgentHost(agent(stuck, Duration.ofSeconds(2)), () -> { + }); + host.start(); + Thread.currentThread().interrupt(); + host.stop(Duration.ofSeconds(5)); + assertThat(Thread.interrupted()).isTrue(); + assertThat(stuck.closes).hasPositiveValue(); + } + + @Test + void holdsTheJvmUntilTermination() throws Exception { + AcpAgentHost host = new AcpAgentHost(agent(pair.agentTransport()), () -> { + }); + host.start(); + host.holdJvmUntilTermination(); + assertThat(holdThread()).isTrue(); + host.stop(Duration.ofSeconds(5)); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (holdThread() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + assertThat(holdThread()).isFalse(); + } + + @Test + void theHoldThreadEndsAlsoWhenTerminationFails() throws Exception { + CompletableFuture failed = new CompletableFuture<>(); + AcpHost host = new FailedTermination(failed); + host.holdJvmUntilTermination(); + failed.completeExceptionally(new IllegalStateException("gone")); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (holdThread() && System.nanoTime() < deadline) { + Thread.sleep(20); + } + assertThat(holdThread()).isFalse(); + } + + static boolean holdThread() { + return Thread.getAllStackTraces() + .keySet() + .stream() + .anyMatch(t -> t.isAlive() && t.getName().equals(AcpHost.HOLD_THREAD_NAME)); + } + + private static AcpAgentSupport agent(AcpAgentTransport transport) { + return agent(transport, null); + } + + private static AcpAgentSupport agent(AcpAgentTransport transport, Duration requestTimeout) { + return AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, TestAgents.EchoAgent::new), + AcpAgentSettings.builder().requestTimeout(requestTimeout).build(), List.of(), + List.of(new TestAgents.SuffixResolver()), List.of(new TestAgents.ReplyHandler())) + .transport(transport) + .build(); + } + + /** Only a termination, for the default method. */ + private record FailedTermination(CompletableFuture termination) implements AcpHost { + + @Override + public void start() { + } + + @Override + public CompletionStage stopGracefully() { + return termination; + } + + @Override + public void stop(Duration timeout) { + } + + @Override + public java.util.OptionalInt port() { + return java.util.OptionalInt.empty(); + } + + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentSettingsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentSettingsTest.java new file mode 100644 index 00000000..fb75a2ae --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentSettingsTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpAgentSettingsTest { + + @Test + void defaultsLeaveEveryLimitToTheSdk() { + AcpAgentSettings settings = AcpAgentSettings.builder().build(); + assertThat(settings.enabled()).isTrue(); + assertThat(settings.requestTimeout()).isNull(); + assertThat(settings.cancelGracePeriod()).isNull(); + assertThat(settings.maxPromptDuration()).isNull(); + assertThat(settings.shutdownOnTransportEnd()).isTrue(); + assertThat(settings.transport()).isEqualTo(AcpTransportType.STDIO); + assertThat(settings.servesHttp()).isFalse(); + assertThat(settings.http().path()).isEqualTo("/acp"); + assertThat(settings.http().listener().port()).isEqualTo(8080); + assertThat(settings.toOptions(true)).isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults()); + assertThat(settings.toOptions(false)).isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults()); + assertThat(AcpAgentSettings.from(MapSettingsSource.of(), "acp.agent")).isEqualTo(settings); + } + + @Test + void everyKeyBinds() { + AcpAgentSettings settings = AcpAgentSettings.from(MapSettingsSource.of("acp.agent.enabled", "FALSE", + "acp.agent.request-timeout", "7s", "acp.agent.cancel-grace-period", "PT3S", + "acp.agent.max-prompt-duration", "2m", "acp.agent.shutdown-on-transport-end", "false", + "acp.agent.transport.type", "WebSocket", "acp.agent.transport.http.path", "/agents/acp", + "acp.agent.transport.http.max-post-body-size", "2MB", "acp.agent.transport.http.keep-alive-interval", + "20000", "acp.agent.transport.http.mailbox-capacity", "11", + "acp.agent.transport.http.max-pending-sse-events", "12", + "acp.agent.transport.http.max-web-socket-pending-frames", "13", + "acp.agent.transport.http.max-provisional-sessions", "14", "acp.agent.transport.http.shutdown-timeout", + "1h", "acp.agent.transport.http.web-socket-idle-timeout", "45m", + "acp.agent.transport.http.initialize-timeout", "9s", "acp.agent.transport.http.listener.port", "0", + "acp.agent.transport.http.listener.max-concurrent-streams-per-connection", "15", + "acp.agent.transport.http.listener.host", "0.0.0.0", "acp.agent.transport.http.allowed-origins", + "https://a.example, https://b.example"), "acp.agent."); + assertThat(settings.enabled()).isFalse(); + assertThat(settings.requestTimeout()).isEqualTo(Duration.ofSeconds(7)); + assertThat(settings.cancelGracePeriod()).isEqualTo(Duration.ofSeconds(3)); + assertThat(settings.maxPromptDuration()).isEqualTo(Duration.ofMinutes(2)); + assertThat(settings.shutdownOnTransportEnd()).isFalse(); + assertThat(settings.transport()).isEqualTo(AcpTransportType.WEBSOCKET); + assertThat(settings.servesHttp()).isTrue(); + assertThat(settings.http().path()).isEqualTo("/agents/acp"); + assertThat(settings.http().listener().port()).isZero(); + + StreamableHttpAcpAgentTransportOptions listener = settings.toOptions(true); + assertThat(listener.maxPostBodyBytes()).isEqualTo(2L * 1024 * 1024); + assertThat(listener.keepAliveInterval()).isEqualTo(Duration.ofSeconds(20)); + assertThat(listener.mailboxCapacity()).isEqualTo(11); + assertThat(listener.maxPendingSseEvents()).isEqualTo(12); + assertThat(listener.maxWebSocketPendingFrames()).isEqualTo(13); + assertThat(listener.maxProvisionalSessions()).isEqualTo(14); + assertThat(listener.shutdownTimeout()).isEqualTo(Duration.ofHours(1)); + assertThat(listener.webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(45)); + assertThat(listener.initializeTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(settings.toOptions(false).webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(45)); + assertThat(settings.toOptions(false).initializeTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(listener.maxConcurrentStreamsPerConnection()).isEqualTo(15); + assertThat(listener.host()).isEqualTo("0.0.0.0"); + assertThat(listener.allowedOrigins()).containsExactlyInAnyOrder("https://a.example", "https://b.example"); + // A framework's own server keeps its own bind address, and applies the same origins. + assertThat(settings.toOptions(false).host()).isNull(); + assertThat(settings.toOptions(false).allowedOrigins()).hasSize(2); + // Inside a framework's server the listener's own stream limit means nothing. + assertThat(settings.toOptions(false).maxConcurrentStreamsPerConnection()) + .isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults().maxConcurrentStreamsPerConnection()); + } + + @Test + void unitsOfDurationsAndSizes() { + assertThat(agent("acp.request-timeout", "250ms").requestTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(agent("acp.request-timeout", "1d").requestTimeout()).isEqualTo(Duration.ofDays(1)); + assertThat(agent("acp.request-timeout", " ").requestTimeout()).isNull(); + assertThat(bytes("512")).isEqualTo(512); + assertThat(bytes("512B")).isEqualTo(512); + assertThat(bytes("4k")).isEqualTo(4096); + assertThat(bytes("4KB")).isEqualTo(4096); + assertThat(bytes("3M")).isEqualTo(3L * 1024 * 1024); + assertThat(bytes("1GB")).isEqualTo(1024L * 1024 * 1024); + assertThat(bytes("1g")).isEqualTo(1024L * 1024 * 1024); + } + + @Test + void aValueThatDoesNotParseNamesItsKey() { + assertThatThrownBy(() -> agent("acp.enabled", "yes")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("acp.enabled=yes is not true or false"); + assertThatThrownBy(() -> agent("acp.request-timeout", "soon")) + .hasMessage("acp.request-timeout=soon is not a duration such as 30s or PT30S"); + assertThatThrownBy(() -> agent("acp.request-timeout", "5 fortnights")) + .hasMessage("acp.request-timeout=5 fortnights is not a duration such as 30s or PT30S"); + assertThatThrownBy(() -> agent("acp.request-timeout", "PT5X")) + .hasMessage("acp.request-timeout=PT5X is not a duration such as 30s or PT30S"); + assertThatThrownBy(() -> agent("acp.transport.http.mailbox-capacity", "many")) + .hasMessage("acp.transport.http.mailbox-capacity=many is not an integer"); + assertThatThrownBy(() -> agent("acp.transport.type", "pigeon")) + .hasMessage("acp.transport.type=pigeon is not stdio, websocket or http"); + assertThatThrownBy(() -> bytes("lots")).hasMessageContaining("is not a size such as 16MB"); + assertThatThrownBy(() -> bytes("2TB")).hasMessageContaining("is not a size such as 16MB"); + } + + @Test + void anEmptyPrefixReadsBareKeys() { + assertThat(AcpAgentSettings.from(MapSettingsSource.of("transport.type", "http"), "").transport()) + .isEqualTo(AcpTransportType.HTTP); + } + + @Test + void transportTypesParseInAnyCase() { + assertThat(AcpTransportType.parse(" Http ")).isEqualTo(AcpTransportType.HTTP); + assertThat(AcpTransportType.STDIO.value()).isEqualTo("stdio"); + assertThatThrownBy(() -> AcpTransportType.parse("udp")).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("use stdio, websocket or http"); + } + + @Test + void partsAreRequired() { + AcpAgentSettings.Builder builder = AcpAgentSettings.builder(); + assertThatThrownBy(() -> builder.transport(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> builder.path(null)).isInstanceOf(NullPointerException.class); + } + + private static AcpAgentSettings agent(String key, String value) { + return AcpAgentSettings.from(MapSettingsSource.of(key, value), "acp"); + } + + private static long bytes(String value) { + return agent("acp.transport.http.max-post-body-size", value).http().limits().maxPostBodyBytes(); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentsTest.java new file mode 100644 index 00000000..5a865910 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpAgentsTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpAgentsTest { + + @Test + void assemblesTheUserClassOnTheInstanceWithTheExtensionBeans() { + TestAgents.ProxiedEchoAgent proxy = new TestAgents.ProxiedEchoAgent(); + TestAgents.RecordingInterceptor interceptor = new TestAgents.RecordingInterceptor(); + AcpAgentSettings settings = AcpAgentSettings.builder() + .requestTimeout(Duration.ofSeconds(5)) + .cancelGracePeriod(Duration.ofSeconds(1)) + .maxPromptDuration(Duration.ofMinutes(1)) + .build(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, () -> proxy), settings, + List.of(interceptor), List.of(new TestAgents.SuffixResolver()), List.of(new TestAgents.ReplyHandler())) + .transport(pair.agentTransport()) + .build(); + agent.start(); + try { + assertThat(TestAgents.roundTrip(pair.clientTransport())).containsExactly("echo: hello!"); + // Discovered on the user class, invoked on the instance: the "proxy" ran. + assertThat(proxy.calls).hasValue(1); + assertThat(interceptor.methods).contains("session/prompt"); + } + finally { + agent.close(); + } + } + + @Test + void unsetTimeoutsKeepTheSdkDefaults() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, TestAgents.EchoAgent::new), + AcpAgentSettings.builder().build(), List.of(), List.of(new TestAgents.SuffixResolver()), + List.of(new TestAgents.ReplyHandler())) + .transport(pair.agentTransport()) + .build(); + agent.start(); + try { + assertThat(TestAgents.roundTrip(pair.clientTransport())).containsExactly("echo: hello!"); + } + finally { + agent.close(); + } + } + + @Test + void handlersRunOnTheGivenExecutor() { + ExecutorService executor = Executors.newCachedThreadPool(runnable -> new Thread(runnable, "framework-worker")); + TestAgents.EchoAgent echo = new TestAgents.EchoAgent(); + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentSupport agent = AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, () -> echo), + AcpAgentSettings.builder().build(), List.of(), List.of(new TestAgents.SuffixResolver()), + List.of(new TestAgents.ReplyHandler()), executor) + .transport(pair.agentTransport()) + .build(); + agent.start(); + try { + assertThat(TestAgents.roundTrip(pair.clientTransport())).containsExactly("echo: hello!"); + assertThat(echo.threads).containsExactly("framework-worker"); + } + finally { + agent.close(); + executor.shutdownNow(); + } + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientSettingsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientSettingsTest.java new file mode 100644 index 00000000..381f2ac1 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientSettingsTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpClientSettingsTest { + + @Test + void defaults() { + AcpClientSettings settings = AcpClientSettings.builder().build(); + assertThat(settings.requestTimeout()).isNull(); + assertThat(settings.promptTimeout()).isNull(); + assertThat(settings.transport()).isNull(); + assertThat(settings.stdio().command()).isNull(); + assertThat(settings.stdio().args()).isEmpty(); + assertThat(settings.stdio().env()).isEmpty(); + assertThat(settings.websocket().uri()).isNull(); + assertThat(settings.websocket().connectTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(settings.http().uri()).isNull(); + assertThat(settings.capabilities()).isEqualTo(AcpClientSettings.Capabilities.NONE); + assertThat(settings.hasTransport()).isFalse(); + assertThat(settings.closeTimeout()).isEqualTo(Duration.ofSeconds(70)); + assertThat(AcpClientSettings.from(MapSettingsSource.of(), "acp.client")).isEqualTo(settings); + } + + @Test + void everyKeyBinds() { + AcpClientSettings settings = AcpClientSettings.from(MapSettingsSource.of("acp.client.request-timeout", "45s", + "acp.client.prompt-timeout", "10m", "acp.client.transport.type", "stdio", + "acp.client.transport.stdio.command", "my-agent", "acp.client.transport.stdio.args", "--acp, --verbose", + "acp.client.transport.stdio.env.AGENT_MODE", "test", "acp.client.transport.websocket.uri", + "ws://localhost:9/acp", "acp.client.transport.websocket.connect-timeout", "3s", + "acp.client.transport.http.uri", "http://localhost:9/acp", "acp.client.capabilities.read-text-file", + "true", "acp.client.capabilities.write-text-file", "true", "acp.client.capabilities.terminal", "true", + "acp.client.capabilities.elicitation-form", "true", "acp.client.capabilities.elicitation-url", "true", + "acp.client.capabilities.boolean-config-options", "true"), "acp.client"); + assertThat(settings.requestTimeout()).isEqualTo(Duration.ofSeconds(45)); + assertThat(settings.promptTimeout()).isEqualTo(Duration.ofMinutes(10)); + assertThat(settings.transport()).isEqualTo(AcpTransportType.STDIO); + assertThat(settings.stdio().command()).isEqualTo("my-agent"); + assertThat(settings.stdio().args()).containsExactly("--acp", "--verbose"); + assertThat(settings.stdio().env()).isEqualTo(Map.of("AGENT_MODE", "test")); + assertThat(settings.websocket().uri()).isEqualTo(URI.create("ws://localhost:9/acp")); + assertThat(settings.websocket().connectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(settings.http().uri()).isEqualTo(URI.create("http://localhost:9/acp")); + assertThat(settings.capabilities()) + .isEqualTo(new AcpClientSettings.Capabilities(true, true, true, true, true, true)); + assertThat(settings.hasTransport()).isTrue(); + assertThat(settings.closeTimeout()).isEqualTo(Duration.ofSeconds(55)); + } + + @Test + void anyTransportSettingCounts() { + assertThat(AcpClientSettings.builder().transport(AcpTransportType.HTTP).build().hasTransport()).isTrue(); + assertThat(AcpClientSettings.builder().stdioCommand("agent").build().hasTransport()).isTrue(); + assertThat(AcpClientSettings.builder().websocketUri(URI.create("ws://h/acp")).build().hasTransport()).isTrue(); + assertThat(AcpClientSettings.builder().httpUri(URI.create("http://h/acp")).build().hasTransport()).isTrue(); + } + + @Test + void aUriThatDoesNotParseNamesItsKey() { + assertThatThrownBy(() -> AcpClientSettings.from(MapSettingsSource.of("c.transport.http.uri", "http://a b"), "c")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("c.transport.http.uri=http://a b is not a URI"); + } + + @Test + void capabilitiesAsTheProtocolWritesThem() { + AcpSchema.ClientCapabilities none = AcpClientSettings.Capabilities.NONE.toClientCapabilities(); + assertThat(none.fs().readTextFile()).isFalse(); + assertThat(none.fs().writeTextFile()).isFalse(); + assertThat(none.terminal()).isFalse(); + assertThat(none.elicitation()).isNull(); + assertThat(none.session()).isNull(); + + AcpSchema.ClientCapabilities all = new AcpClientSettings.Capabilities(true, true, true, true, false, true) + .toClientCapabilities(); + assertThat(all.fs().readTextFile()).isTrue(); + assertThat(all.fs().writeTextFile()).isTrue(); + assertThat(all.terminal()).isTrue(); + assertThat(all.elicitation().form()).isNotNull(); + assertThat(all.elicitation().url()).isNull(); + assertThat(all.session()).isEqualTo(AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions()); + + AcpSchema.ClientCapabilities url = new AcpClientSettings.Capabilities(false, false, false, false, true, false) + .toClientCapabilities(); + assertThat(url.elicitation().form()).isNull(); + assertThat(url.elicitation().url()).isNotNull(); + } + + @Test + void stdioListsAreCopied() { + List args = new java.util.ArrayList<>(List.of("a")); + AcpClientSettings settings = AcpClientSettings.builder().stdioArgs(args).build(); + args.add("b"); + assertThat(settings.stdio().args()).containsExactly("a"); + } + + @Test + void partsAreRequired() { + AcpClientSettings.Builder builder = AcpClientSettings.builder(); + assertThatThrownBy(() -> builder.websocketConnectTimeout(null)).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> builder.capabilities(null)).isInstanceOf(NullPointerException.class); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientTransportsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientTransportsTest.java new file mode 100644 index 00000000..546e8e48 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientTransportsTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** The client transport rule, the same in every framework. */ +class AcpClientTransportsTest { + + private static final URI WS = URI.create("ws://localhost:9/acp"); + + private static final URI HTTP = URI.create("http://localhost:9/acp"); + + @Test + void noneConfiguredIsNoTransport() { + assertThat(AcpClientTransports.create(AcpClientSettings.builder().build())).isEmpty(); + } + + @Test + void theOneConfiguredIsInferred() { + assertThat(AcpClientTransports.create(settings(null, "agent", null, null))) + .containsInstanceOf(StdioAcpClientTransport.class); + assertThat(AcpClientTransports.create(settings(null, null, WS, null))) + .containsInstanceOf(WebSocketAcpClientTransport.class); + assertThat(AcpClientTransports.create(settings(null, null, null, HTTP))) + .containsInstanceOf(StreamableHttpAcpClientTransport.class); + } + + @Test + void anExplicitTypeWins() { + assertThat(AcpClientTransports.create(settings(AcpTransportType.HTTP, "agent", WS, HTTP))) + .containsInstanceOf(StreamableHttpAcpClientTransport.class); + assertThat(AcpClientTransports.create(settings(AcpTransportType.STDIO, "agent", WS, HTTP))) + .containsInstanceOf(StdioAcpClientTransport.class); + assertThat(AcpClientTransports.create(settings(AcpTransportType.WEBSOCKET, "agent", WS, HTTP))) + .containsInstanceOf(WebSocketAcpClientTransport.class); + } + + @Test + void severalWithoutATypeFailNamingThem() { + assertThatThrownBy(() -> AcpClientTransports.create(settings(null, "agent", WS, HTTP), "spring.acp.client")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Several ACP client transports are configured [STDIO, WEBSOCKET, HTTP]; " + + "choose one with spring.acp.client.transport.type"); + assertThatThrownBy(() -> AcpClientTransports.create(settings(null, null, WS, HTTP))) + .hasMessageContaining("[WEBSOCKET, HTTP]") + .hasMessageContaining("acp.client.transport.type"); + } + + @Test + void anExplicitTypeWithoutItsSettingNamesIt() { + assertThatThrownBy(() -> AcpClientTransports.create(settings(AcpTransportType.WEBSOCKET, null, null, HTTP), "q")) + .isInstanceOf(IllegalStateException.class) + .hasMessage("q.transport.type=websocket requires q.transport.websocket.uri"); + assertThatThrownBy(() -> AcpClientTransports.create(settings(AcpTransportType.HTTP, null, WS, null), "q")) + .hasMessage("q.transport.type=http requires q.transport.http.uri"); + assertThatThrownBy(() -> AcpClientTransports.create(settings(AcpTransportType.STDIO, null, WS, null), "q")) + .hasMessage("q.transport.type=stdio requires q.transport.stdio.command"); + } + + @Test + void stdioWithoutEnvironment() { + AcpClientSettings settings = AcpClientSettings.builder().stdioCommand("agent").build(); + assertThat(AcpClientTransports.create(settings)).containsInstanceOf(StdioAcpClientTransport.class); + } + + private static AcpClientSettings settings(AcpTransportType type, String command, URI ws, URI http) { + return AcpClientSettings.builder() + .transport(type) + .stdioCommand(command) + .stdioArgs(List.of("--acp")) + .stdioEnv(Map.of("MODE", "test")) + .websocketUri(ws) + .httpUri(http) + .build(); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientsTest.java new file mode 100644 index 00000000..a9a00cc9 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpClientsTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AcpClientsTest { + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + @Test + void buildsOneClientWithTheSettingsAndCustomizersInOrder() throws Exception { + AcpAgentSupport agent = agent(); + agent.start(); + List customized = new ArrayList<>(); + List chunks = new CopyOnWriteArrayList<>(); + AcpClientSettings settings = AcpClientSettings.builder() + .requestTimeout(Duration.ofSeconds(10)) + .capabilities(new AcpClientSettings.Capabilities(false, false, false, false, false, true)) + .build(); + AcpAsyncClient async = AcpClients.async(pair.clientTransport(), settings, + List.of(spec -> customized.add("first"), spec -> { + customized.add("second"); + spec.sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks.add(text.text()); + } + return Mono.empty(); + }); + })); + assertThat(customized).containsExactly("first", "second"); + AcpSyncClient sync = AcpClients.sync(async); + sync.initialize(); + String session = sync.newSession(new AcpSchema.NewSessionRequest("/", List.of())).sessionId(); + sync.prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("hello")))); + assertThat(chunks).containsExactly("echo: hello!"); + + AcpClientHost host = new AcpClientHost(async); + assertThat(host.closeGracefully()).isSameAs(host.closeGracefully()); + host.closeGracefully().toCompletableFuture().get(10, TimeUnit.SECONDS); + host.close(Duration.ofSeconds(1)); + agent.close(); + } + + @Test + void unsetRequestTimeoutKeepsTheSdkDefault() { + AcpAsyncClient async = AcpClients.async(pair.clientTransport(), AcpClientSettings.builder().build(), List.of()); + new AcpClientHost(async).close(Duration.ofSeconds(5)); + } + + @Test + void theDefaultConsumerLogsAndCompletes() { + AcpSchema.SessionNotification notification = new AcpSchema.SessionNotification("s", + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("x"))); + assertThat(AcpClients.logSessionUpdate(notification).block()).isNull(); + } + + @Test + void aCloseThatDoesNotFinishInTimeClosesAtOnce() { + AtomicInteger closes = new AtomicInteger(); + AcpClientTransport stuck = new StuckClientTransport(pair.clientTransport(), closes); + AcpAsyncClient async = AcpClients.async(stuck, AcpClientSettings.builder().build(), List.of()); + new AcpClientHost(async).close(Duration.ofMillis(50)); + assertThat(closes).hasPositiveValue(); + } + + @Test + void aCapabilityWithoutItsHandlerFailsNamingTheSetting() { + AcpClientSettings settings = AcpClientSettings.builder() + .capabilities(new AcpClientSettings.Capabilities(true, false, false, true, false, false)) + .build(); + assertThatThrownBy(() -> AcpClients.async(pair.clientTransport(), settings, List.of(), "my.acp.client")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("readTextFileHandler") + .hasMessageContaining("my.acp.client.capabilities.read-text-file=true, " + + "my.acp.client.capabilities.elicitation-form=true") + .hasMessageNotContaining("write-text-file"); + } + + @Test + void theCapabilityErrorNamesOnlyTheSettingsThatAdvertisedAMissingHandler() { + IllegalStateException sdk = new IllegalStateException("The client advertises capabilities it has no handler " + + "for: fs.readTextFile needs readTextFileHandler; fs.writeTextFile needs writeTextFileHandler; " + + "terminal needs killTerminalHandler; elicitation needs createElicitationHandler"); + AcpClientSettings.Capabilities all = new AcpClientSettings.Capabilities(true, true, true, true, true, true); + assertThat(AcpClients.namingTheSettings(sdk, "q", all)) + .hasMessageContaining("q.capabilities.read-text-file=true, q.capabilities.write-text-file=true, " + + "q.capabilities.terminal=true, q.capabilities.elicitation-form=true, " + + "q.capabilities.elicitation-url=true") + .hasCause(sdk); + assertThat(AcpClients.namingTheSettings(sdk, "q", AcpClientSettings.Capabilities.NONE)).isSameAs(sdk); + IllegalStateException other = new IllegalStateException("Already connected"); + assertThat(AcpClients.namingTheSettings(other, "q", all)).isSameAs(other); + } + + @Test + void aCustomizersConsumerReplacesTheDefault() { + AcpAgentSupport agent = agent(); + agent.start(); + List chunks = new CopyOnWriteArrayList<>(); + AcpAsyncClient async = AcpClients.async(pair.clientTransport(), AcpClientSettings.builder().build(), + List.of(spec -> spec.sessionUpdateHandler(notification -> { + chunks.add(notification.sessionId()); + return Mono.empty(); + }))); + AcpSyncClient sync = AcpClients.sync(async); + sync.initialize(); + String session = sync.newSession(new AcpSchema.NewSessionRequest("/", List.of())).sessionId(); + sync.prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("hello")))); + assertThat(chunks).containsExactly(session); + new AcpClientHost(async).close(Duration.ofSeconds(5)); + agent.close(); + } + + private AcpAgentSupport agent() { + return AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, TestAgents.EchoAgent::new), + AcpAgentSettings.builder().build(), List.of(), List.of(new TestAgents.SuffixResolver()), + List.of(new TestAgents.ReplyHandler())) + .transport(pair.agentTransport()) + .build(); + } + + /** A client transport whose graceful close never finishes; counts its immediate closes. */ + private record StuckClientTransport(AcpClientTransport delegate, AtomicInteger closes) + implements AcpClientTransport { + + @Override + public Mono connect( + java.util.function.Function, Mono> handler) { + return delegate.connect(handler); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public Mono closeGracefully() { + return Mono.never(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + + @Override + public T unmarshalFrom(Object data, com.agentclientprotocol.sdk.json.TypeRef typeRef) { + return delegate.unmarshalFrom(data, typeRef); + } + + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpListenerHostTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpListenerHostTest.java new file mode 100644 index 00000000..22bec75d --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpListenerHostTest.java @@ -0,0 +1,143 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpListenerHostTest { + + private static final AcpAgentSettings SETTINGS = AcpAgentSettings.builder() + .transport(AcpTransportType.HTTP) + .listenerPort(0) + .path("/agents/echo") + .maxConcurrentStreamsPerConnection(64) + .shutdownTimeout(Duration.ofSeconds(2)) + .build(); + + @Test + void servesHttpAndWebSocketOnOnePathUntilStopped() throws Exception { + assertThat(AcpListeners.isListenerAvailable()).isTrue(); + AcpListenerHost host = new AcpListenerHost(AcpListeners.listener(SETTINGS, factory())); + assertThat(host.port()).isEmpty(); + host.start(); + host.start(); // once + int port = host.port().orElseThrow(); + assertThat(port).isPositive(); + + URI http = URI.create("http://localhost:" + port + "/agents/echo"); + assertThat(TestAgents.roundTrip(new StreamableHttpAcpClientTransport(http, AcpJsonMapper.createDefault()))) + .containsExactly("echo: hello!"); + URI ws = URI.create("ws://localhost:" + port + "/agents/echo"); + assertThat(TestAgents.roundTrip(new WebSocketAcpClientTransport(ws, AcpJsonMapper.createDefault()))) + .containsExactly("echo: hello!"); + + assertThat(host.termination().toCompletableFuture().isDone()).isFalse(); + host.stop(Duration.ofSeconds(10)); + assertThat(host.stopGracefully()).isSameAs(host.stopGracefully()); + host.termination().toCompletableFuture().get(5, TimeUnit.SECONDS); + host.start(); // not after the stop + } + + /** + * The framework's executor reaches the listener (on JDK 21 and later) and both network client + * transports: their work runs on it, and none of them shuts it down. + */ + @Test + void theListenerAndTheClientTransportsRunOnTheFrameworksExecutor() throws Exception { + AtomicInteger tasks = new AtomicInteger(); + ExecutorService pool = Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "framework-worker"); + thread.setDaemon(true); + return thread; + }); + Executor framework = task -> { + tasks.incrementAndGet(); + pool.execute(task); + }; + AcpListenerHost host = new AcpListenerHost(AcpListeners.listener(SETTINGS, factory(), AcpTransportThreads.executor(framework))); + try { + host.start(); + int listenerTasks = tasks.get(); + assertThat(listenerTasks > 0).isEqualTo(VirtualThreads.isSupported()); + int port = host.port().orElseThrow(); + for (AcpClientSettings settings : List.of( + AcpClientSettings.builder().httpUri(URI.create("http://localhost:" + port + "/agents/echo")).build(), + AcpClientSettings.builder().websocketUri(URI.create("ws://localhost:" + port + "/agents/echo")).build())) { + int before = tasks.get(); + AcpClientTransport transport = AcpClientTransports.create(settings, "acp.client", AcpTransportThreads.executor(framework)) + .orElseThrow(); + assertThat(TestAgents.roundTrip(transport)).containsExactly("echo: hello!"); + assertThat(tasks.get()).as(settings.toString()).isGreaterThan(before); + } + } + finally { + host.stop(Duration.ofSeconds(10)); + } + assertThat(pool.isShutdown()).isFalse(); + pool.shutdownNow(); + } + + @Test + void aStopThatTimesOutReturns() { + AcpListenerHost host = new AcpListenerHost(AcpListeners.listener(SETTINGS, factory())); + host.start(); + host.stop(Duration.ofNanos(1)); + host.stop(Duration.ofSeconds(10)); + } + + @Test + void theServletTakesTheLimitsAndClosesBeforeShutdown() { + StreamableHttpAcpServlet servlet = AcpListeners.servlet(SETTINGS, factory()); + AcpServletHost.closeBeforeShutdown(servlet, Duration.ofSeconds(5)); + } + + @Test + void aServletThatDoesNotCloseInTimeIsLeft() { + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), factory()) { + + private static final long serialVersionUID = 1L; + + @Override + public Mono closeGracefully() { + return Mono.never(); + } + + }; + AcpServletHost.closeBeforeShutdown(servlet, Duration.ofMillis(10)); + } + + @Test + void anAbsentClassIsReportedAbsent() { + assertThat(AcpListeners.isPresent("com.example.NoSuchListener")).isFalse(); + } + + private static AcpAgentFactory factory() { + return AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, TestAgents.EchoAgent::new), SETTINGS, + List.of(), List.of(new TestAgents.SuffixResolver()), List.of(new TestAgents.ReplyHandler())) + .buildFactory(); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpPlatformThreadsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpPlatformThreadsTest.java new file mode 100644 index 00000000..942e1921 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/AcpPlatformThreadsTest.java @@ -0,0 +1,97 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link AcpTransportThreads#platform()}: an application that has not opted into virtual threads + * (Spring Boot without {@code spring.threads.virtual.enabled}) gets platform threads on every JDK. + * On JDK 17 that is all there is; on JDK 21 it is the choice that keeps the network client + * transports and the listener off the virtual threads they would otherwise default to. + */ +class AcpPlatformThreadsTest { + + private static final AcpAgentSettings SETTINGS = AcpAgentSettings.builder() + .transport(AcpTransportType.HTTP) + .listenerPort(0) + .path("/agents/echo") + .shutdownTimeout(Duration.ofSeconds(2)) + .build(); + + @Test + void theClientTransportsRunOnPlatformThreadsOnEveryJdk() { + AcpListenerHost host = new AcpListenerHost( + AcpListeners.listener(SETTINGS, factory(), AcpTransportThreads.platform())); + try { + host.start(); + int port = host.port().orElseThrow(); + URI http = URI.create("http://localhost:" + port + "/agents/echo"); + URI ws = URI.create("ws://localhost:" + port + "/agents/echo"); + + Set httpThreads = platformThreadsDuringAPrompt(AcpClientSettings.builder().httpUri(http).build(), + AcpTransportThreads.platform()); + assertThat(httpThreads).contains("acp-streamable-http-client"); + + Set wsThreads = platformThreadsDuringAPrompt(AcpClientSettings.builder().websocketUri(ws).build(), + AcpTransportThreads.platform()); + assertThat(wsThreads).contains("acp-ws-client"); + } + finally { + host.stop(Duration.ofSeconds(10)); + } + } + + /** + * Prompts the echo agent over the transport the settings describe, on the given threads, and + * returns the names of the live platform threads while the agent's reply arrives (the JVM + * lists no virtual threads there). + */ + private static Set platformThreadsDuringAPrompt(AcpClientSettings settings, + AcpTransportThreads threads) { + AcpClientTransport transport = AcpClientTransports.create(settings, "acp.client", threads).orElseThrow(); + Set names = new CopyOnWriteArraySet<>(); + AcpSyncClient client = AcpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(10)) + .sessionUpdateHandler(notification -> names.addAll(Thread.getAllStackTraces() + .keySet() + .stream() + .map(Thread::getName) + .collect(Collectors.toSet()))) + .build(); + try { + client.initialize(); + String session = client.newSession(new AcpSchema.NewSessionRequest("/", List.of())).sessionId(); + client.prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("hello")))); + } + finally { + client.closeGracefully(); + } + assertThat(names).isNotEmpty(); + return names; + } + + private static com.agentclientprotocol.sdk.agent.AcpAgentFactory factory() { + return AcpAgents + .builder(new AgentCandidate<>("echo", TestAgents.EchoAgent.class, TestAgents.EchoAgent::new), SETTINGS, + List.of(), List.of(new TestAgents.SuffixResolver()), List.of(new TestAgents.ReplyHandler())) + .buildFactory(); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/HostsTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/HostsTest.java new file mode 100644 index 00000000..31609e14 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/HostsTest.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HostsTest { + + @Test + void aFailedGracefulCloseClosesAtOnce() { + AtomicInteger now = new AtomicInteger(); + Hosts.await(CompletableFuture.failedFuture(new IllegalStateException("broken")), Duration.ofSeconds(1), "test", + now::incrementAndGet); + assertThat(now).hasValue(1); + } + + @Test + void aCompletedGracefulCloseIsEnough() { + AtomicInteger now = new AtomicInteger(); + Hosts.await(CompletableFuture.completedFuture(null), Duration.ofSeconds(1), "test", now::incrementAndGet); + assertThat(now).hasValue(0); + } + + @Test + void theStdioAgentTransport() { + assertThat(AcpAgentTransports.stdio()).isInstanceOf(StdioAcpAgentTransport.class); + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/IntegrationArchitectureTest.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/IntegrationArchitectureTest.java new file mode 100644 index 00000000..bee5c0de --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/IntegrationArchitectureTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-integration is framework-neutral, and the SDK below it does not know it exists. + * + *
    + *   spring-boot-autoconfigure   micronaut   quarkus
    + *                  \               |         /
    + *                       acp-integration
    + *                      /               \
    + *               acp-agent-support    acp-core
    + * 
    + */ +class IntegrationArchitectureTest { + + private static final String INTEGRATION = "com.agentclientprotocol.sdk.integration.."; + + /** This module's main classes. */ + private static final JavaClasses MODULE = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importLocations(Set.of(location(AcpHost.class))); + + /** The SDK modules below this one: annotations, core, annotation support. */ + private static final JavaClasses SDK = new ClassFileImporter().importLocations( + Stream.of(AcpAgent.class, AcpSchema.class, AcpAgentSupport.class) + .map(IntegrationArchitectureTest::location) + .collect(Collectors.toSet())); + + @Test + void importsThisModuleOnly() { + assertThat(MODULE.contain(AcpHost.class)).isTrue(); + assertThat(MODULE).allMatch(type -> type.getPackageName().equals("com.agentclientprotocol.sdk.integration"), + "in this module's package"); + assertThat(SDK.contain(AcpAgentSupport.class)).isTrue(); + } + + @Test + void dependsOnNoFramework() { + noClasses().should() + .dependOnClassesThat() + .resideInAnyPackage("org.springframework..", "io.quarkus..", "io.micronaut..", "io.vertx..", + "jakarta.enterprise..", "io.smallrye..") + .because("acp-integration is the framework-neutral half of every framework integration") + .check(MODULE); + } + + @Test + void theSdkBelowDoesNotDependOnIt() { + noClasses().should() + .dependOnClassesThat() + .resideInAPackage(INTEGRATION) + .because("acp-core and acp-agent-support are below the framework integrations") + .check(SDK); + } + + /** The directory or jar a class was loaded from. */ + private static Location location(Class type) { + Path root; + try { + root = Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + try { + return Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile())); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/MapSettingsSource.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/MapSettingsSource.java new file mode 100644 index 00000000..3f434831 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/MapSettingsSource.java @@ -0,0 +1,45 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Flat properties as a settings source: lists comma-separated, maps as {@code key.name=value}. */ +record MapSettingsSource(Map properties) implements SettingsSource { + + static MapSettingsSource of(String... keyValues) { + Map properties = new LinkedHashMap<>(); + for (int i = 0; i < keyValues.length; i += 2) { + properties.put(keyValues[i], keyValues[i + 1]); + } + return new MapSettingsSource(properties); + } + + @Override + public String get(String key) { + return properties.get(key); + } + + @Override + public List list(String key) { + String value = properties.get(key); + return (value == null) ? List.of() : Arrays.stream(value.split(",")).map(String::trim).toList(); + } + + @Override + public Map map(String key) { + Map entries = new LinkedHashMap<>(); + properties.forEach((name, value) -> { + if (name.startsWith(key + ".")) { + entries.put(name.substring(key.length() + 1), value); + } + }); + return entries; + } + +} diff --git a/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/TestAgents.java b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/TestAgents.java new file mode 100644 index 00000000..8e298c34 --- /dev/null +++ b/acp-integration/src/test/java/com/agentclientprotocol/sdk/integration/TestAgents.java @@ -0,0 +1,187 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.integration; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** An annotated agent, its extension beans, and a client round trip against it. */ +final class TestAgents { + + private TestAgents() { + } + + /** Echoes the prompt, with a suffix only {@link SuffixResolver} supplies. */ + @AcpAgent(name = "integration-test-agent", version = "1.0") + public static class EchoAgent { + + /** The threads the prompt handler ran on. */ + final List threads = new CopyOnWriteArrayList<>(); + + @Prompt + public Reply prompt(AcpSchema.PromptRequest request, SyncPromptContext context, Suffix suffix) { + threads.add(Thread.currentThread().getName()); + context.sendMessage("echo: " + ((AcpSchema.TextContent) request.prompt().get(0)).text() + suffix.value()); + return new Reply(); + } + + } + + /** A subclass standing in for a container proxy: it carries no annotations of its own. */ + public static class ProxiedEchoAgent extends EchoAgent { + + final AtomicInteger calls = new AtomicInteger(); + + @Override + public Reply prompt(AcpSchema.PromptRequest request, SyncPromptContext context, Suffix suffix) { + calls.incrementAndGet(); + return super.prompt(request, context, suffix); + } + + } + + record Suffix(String value) { + } + + record Reply() { + } + + static final class SuffixResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return parameter.getParameterType() == Suffix.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return new Suffix("!"); + } + + } + + static final class ReplyHandler implements ReturnValueHandler { + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + return returnType.getParameterType() == Reply.class; + } + + @Override + public Object handleReturnValue(Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + return AcpSchema.PromptResponse.endTurn(); + } + + } + + static final class RecordingInterceptor implements AcpInterceptor { + + final List methods = new CopyOnWriteArrayList<>(); + + @Override + public boolean preInvoke(AcpInvocationContext context) { + methods.add(context.getAcpMethod()); + return true; + } + + } + + /** Initializes, opens a session and prompts once; returns the messages the agent sent. */ + static List roundTrip(AcpClientTransport transport) { + List messages = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(10)) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + }) + .build(); + try { + client.initialize(); + String session = client.newSession(new AcpSchema.NewSessionRequest("/", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + return messages; + } + finally { + client.closeGracefully(); + } + } + + /** An agent transport whose graceful close never finishes; counts its immediate closes. */ + static class StuckAgentTransport implements AcpAgentTransport { + + private final AcpAgentTransport delegate; + + final AtomicInteger closes = new AtomicInteger(); + + StuckAgentTransport(AcpAgentTransport delegate) { + this.delegate = delegate; + } + + @Override + public Mono start(Function, Mono> handler) { + return delegate.start(handler); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public Mono closeGracefully() { + return Mono.never(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + + @Override + public Mono awaitTermination() { + return delegate.awaitTermination(); + } + + @Override + public void setExceptionHandler(Consumer handler) { + delegate.setExceptionHandler(handler); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return delegate.unmarshalFrom(data, typeRef); + } + + } + +} diff --git a/acp-integration/src/test/resources/archunit.properties b/acp-integration/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-integration/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-integration/src/test/resources/logback-test.xml b/acp-integration/src/test/resources/logback-test.xml new file mode 100644 index 00000000..5243e19c --- /dev/null +++ b/acp-integration/src/test/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n + + + diff --git a/acp-json-jackson2/pom.xml b/acp-json-jackson2/pom.xml index dfe68448..768416ef 100644 --- a/acp-json-jackson2/pom.xml +++ b/acp-json-jackson2/pom.xml @@ -7,7 +7,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-json-jackson2 @@ -16,7 +16,18 @@ ACP JSON Jackson 2 ACP JSON mapper implementation based on Jackson 2 (com.fasterxml.jackson.databind) + + + 0.98 + 0.60 + + + + + org.jspecify + jspecify + com.agentclientprotocol acp-core @@ -45,6 +56,11 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + test + org.assertj assertj-core diff --git a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapper.java b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapper.java index 4af9f9ad..53444722 100644 --- a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapper.java +++ b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapper.java @@ -11,17 +11,32 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.type.LogicalType; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * Jackson 2 implementation of {@link AcpJsonMapper}, shipped in {@code acp-json-jackson2}. - * Wraps a Jackson {@link ObjectMapper} but keeps the SDK decoupled from Jackson at the API - * level. It keeps the package it had when it lived in {@code acp-core}, so code that - * constructs it compiles unchanged. + * The Jackson 2 {@link AcpJsonMapper}, from the {@code acp-json-jackson2} module: it reads and + * writes ACP messages with a Jackson {@link ObjectMapper}. {@link AcpJsonMapper#createDefault()} + * returns one built on {@link #defaultObjectMapper()} when this is the only JSON module on the + * classpath; create one yourself to give a transport an {@code ObjectMapper} you configured. Use + * {@code acp-json-jackson3} instead when the application already runs on Jackson 3. + * + *

    It needs jackson-core and jackson-databind 2.18.1 or later, and checks both when it is + * created: an older Jackson 2 fails then with an {@link IllegalStateException} that names the + * versions found, not later while reading a message. It lives in the package of + * {@link AcpJsonMapper}, which acp-core and this module share. + * + *

    Reads and writes fail with Jackson's {@link IOException} subclasses, and conversions with + * {@link IllegalArgumentException}, as {@link AcpJsonMapper} requires. It is safe for concurrent + * use as long as the {@code ObjectMapper} is not reconfigured once in use. * * @author Mark Pollack */ @@ -32,25 +47,35 @@ public final class JacksonAcpJsonMapper implements AcpJsonMapper { private final ObjectMapper objectMapper; /** - * The {@link ObjectMapper} the SDK uses by default: lenient about unknown - * properties, because the ACP specification adds fields between releases and an agent - * newer than this SDK must keep working. Each unknown property is logged once per - * occurrence at DEBUG under this class's logger, so spec drift is observable without - * making the wire strict. + * Returns a new {@link ObjectMapper} set up the way the SDK reads and writes ACP; the module's + * supplier builds its mapper on it. It differs from a bare {@code new ObjectMapper()} in two + * ways: + *

      + *
    • unknown properties are skipped, not rejected, because the ACP specification adds fields + * between releases and an agent newer than this SDK must keep working. Each skipped property is + * logged at DEBUG under this class's logger, so drift can be seen without making the wire + * strict;
    • + *
    • a scalar of the wrong JSON type is refused rather than coerced (a number or boolean where + * a string is expected, a string where a number or boolean is expected): the SDK answers such + * params {@code -32602} (Invalid params), and a coerced value would hide a peer's bug.
    • + *
    * - *

    - * Strictness is the mapper's decision, not the schema's: the schema records carry no + *

    Strictness is the mapper's choice, not the schema's: the schema records carry no * {@code @JsonIgnoreProperties}, so a mapper with - * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} enabled (Jackson's own - * default for a bare {@code new ObjectMapper()}) fails on the first unknown field. - * Consumers who want their own configuration on top of the SDK's defaults should start - * from this method's result. - *

    - * @return a new, independently configurable lenient mapper + * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} enabled (a bare + * {@code ObjectMapper}'s default) fails on the first unknown field. To customise the SDK's + * settings, start from this method's result and pass it to + * {@link #JacksonAcpJsonMapper(ObjectMapper)}. + * @return a new mapper, shared with no other caller */ public static ObjectMapper defaultObjectMapper() { return JsonMapper.builder() .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .withCoercionConfig(LogicalType.Textual, + config -> config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)) .addHandler(new UnknownPropertyLogger()) .build(); } @@ -60,7 +85,7 @@ static final class UnknownPropertyLogger extends DeserializationProblemHandler { @Override public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, - JsonDeserializer deserializer, Object beanOrClass, String propertyName) throws IOException { + JsonDeserializer deserializer, @Nullable Object beanOrClass, String propertyName) throws IOException { if (logger.isDebugEnabled()) { Object type = beanOrClass instanceof Class c ? c.getSimpleName() : beanOrClass != null ? beanOrClass.getClass().getSimpleName() : "?"; @@ -74,11 +99,16 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, } /** - * Constructs a new JacksonAcpJsonMapper with the given ObjectMapper. - * @param objectMapper the ObjectMapper to use. Must not be null. - * @throws IllegalArgumentException if the provided ObjectMapper is null. + * Creates a mapper that reads and writes with the given {@code ObjectMapper}, used as is: none + * of the settings of {@link #defaultObjectMapper()} are added. + * @param objectMapper the Jackson mapper; start from {@link #defaultObjectMapper()} to keep the + * SDK's settings + * @throws IllegalArgumentException if {@code objectMapper} is null + * @throws IllegalStateException if the jackson-core or jackson-databind on the classpath is + * older than 2.18.1 */ public JacksonAcpJsonMapper(ObjectMapper objectMapper) { + JacksonVersions.requireSupported(); if (objectMapper == null) { throw new IllegalArgumentException("ObjectMapper must not be null"); } @@ -86,31 +116,32 @@ public JacksonAcpJsonMapper(ObjectMapper objectMapper) { } /** - * Returns the underlying Jackson {@link ObjectMapper}. - * @return the ObjectMapper instance + * Returns the {@code ObjectMapper} this mapper reads and writes with: the one given to the + * constructor, not a copy. + * @return the Jackson mapper */ public ObjectMapper getObjectMapper() { return objectMapper; } @Override - public T readValue(String content, Class type) throws IOException { + public @Nullable T readValue(String content, Class type) throws IOException { return objectMapper.readValue(content, type); } @Override - public T readValue(byte[] content, Class type) throws IOException { + public @Nullable T readValue(byte[] content, Class type) throws IOException { return objectMapper.readValue(content, type); } @Override - public T readValue(String content, TypeRef type) throws IOException { + public @Nullable T readValue(String content, TypeRef type) throws IOException { JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); return objectMapper.readValue(content, javaType); } @Override - public T readValue(byte[] content, TypeRef type) throws IOException { + public @Nullable T readValue(byte[] content, TypeRef type) throws IOException { JavaType javaType = objectMapper.getTypeFactory().constructType(type.getType()); return objectMapper.readValue(content, javaType); } diff --git a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperSupplier.java b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperSupplier.java index 2e47d328..ce2d0409 100644 --- a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperSupplier.java +++ b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperSupplier.java @@ -5,15 +5,25 @@ package com.agentclientprotocol.sdk.json; /** - * Jackson 2 supplier of {@link AcpJsonMapper} instances, registered via - * {@link java.util.ServiceLoader}. Its {@link #priority()} is {@value #PRIORITY}: below - * the Jackson 3 supplier and below any application supplier that keeps the default. + * The {@link AcpJsonMapperSupplier} of the {@code acp-json-jackson2} module, registered with + * {@link java.util.ServiceLoader} so that {@link AcpJsonMapper#createDefault()} finds it. Each + * {@link #get()} returns a new {@link JacksonAcpJsonMapper} on + * {@link JacksonAcpJsonMapper#defaultObjectMapper()}. Applications do not call it: they call + * {@code createDefault()}, or create the mapper themselves. + * + *

    Its {@link #priority()} is {@value #PRIORITY}, the lowest of the SDK's: the Jackson 3 supplier + * and any application supplier that keeps the default priority win over it. To use it while + * {@code acp-json-jackson3} is on the classpath too, set the system property + * {@code acp.json.mapper.supplier} to this class's name. * * @author Mark Pollack */ public class JacksonAcpJsonMapperSupplier implements AcpJsonMapperSupplier { - /** The priority of this supplier. */ + /** + * This supplier's priority, {@value}: below the Jackson 3 supplier's and below + * {@link AcpJsonMapperSupplier#DEFAULT_PRIORITY}. + */ public static final int PRIORITY = -200; @Override @@ -21,8 +31,18 @@ public int priority() { return PRIORITY; } + /** + * Returns a new {@link JacksonAcpJsonMapper} built on + * {@link JacksonAcpJsonMapper#defaultObjectMapper()}. The Jackson version is checked before the + * {@code ObjectMapper} is built. + * @return a new mapper + * @throws IllegalStateException if the jackson-core or jackson-databind on the classpath is + * older than 2.18.1 + */ @Override public AcpJsonMapper get() { + // Before building the ObjectMapper: an unsupported Jackson may fail inside it. + JacksonVersions.requireSupported(); return new JacksonAcpJsonMapper(JacksonAcpJsonMapper.defaultObjectMapper()); } diff --git a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonVersions.java b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonVersions.java new file mode 100644 index 00000000..e0700ea1 --- /dev/null +++ b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/JacksonVersions.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json; + +import com.fasterxml.jackson.core.Version; + +/** + * The Jackson 2 versions acp-json-jackson2 supports: {@value #FLOOR} or later, for jackson-core + * and jackson-databind alike. Frameworks manage their own Jackson version, and an older + * jackson-core under a newer jackson-databind fails much later with a {@code NoSuchMethodError} + * while reading a message; checking when the mapper is created names the problem instead. + */ +final class JacksonVersions { + + /** + * The oldest Jackson 2 release the SDK's tests pass with. 2.18.0 loses unknown values of the + * SDK's open enumerations (stop reasons, plan entry statuses), and 2.17 the unknown fields the + * SDK keeps for forward compatibility. + */ + static final String FLOOR = "2.18.1"; + + private static final Version FLOOR_VERSION = new Version(2, 18, 1, null, "com.fasterxml.jackson.core", + "jackson-core"); + + private JacksonVersions() { + } + + /** + * Fails unless the jackson-core and jackson-databind on the classpath are {@value #FLOOR} or + * later. + * @throws IllegalStateException naming the version found and the version required + */ + static void requireSupported() { + require("jackson-core", com.fasterxml.jackson.core.json.PackageVersion.VERSION); + require("jackson-databind", com.fasterxml.jackson.databind.cfg.PackageVersion.VERSION); + } + + /** + * Fails unless {@code found} is {@value #FLOOR} or later. + * @param artifact the Jackson artifact the version belongs to + * @param found the version on the classpath + * @throws IllegalStateException naming the version found and the version required + */ + static void require(String artifact, Version found) { + if (found.getMajorVersion() != FLOOR_VERSION.getMajorVersion() + || compare(found, FLOOR_VERSION) < 0) { + throw new IllegalStateException("acp-json-jackson2 needs Jackson " + FLOOR + " or later, but " + artifact + + " " + found.toString() + " is on the classpath (jackson-core " + + com.fasterxml.jackson.core.json.PackageVersion.VERSION.toString() + ", jackson-databind " + + com.fasterxml.jackson.databind.cfg.PackageVersion.VERSION.toString() + + "). Align the Jackson 2 artifacts on one version, " + FLOOR + + " or later, for example by importing com.fasterxml.jackson:jackson-bom."); + } + } + + /** Compares major, minor and patch level only, ignoring the artifact and any snapshot. */ + private static int compare(Version a, Version b) { + int major = Integer.compare(a.getMajorVersion(), b.getMajorVersion()); + if (major != 0) { + return major; + } + int minor = Integer.compare(a.getMinorVersion(), b.getMinorVersion()); + return (minor != 0) ? minor : Integer.compare(a.getPatchLevel(), b.getPatchLevel()); + } + +} diff --git a/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/package-info.java b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/package-info.java new file mode 100644 index 00000000..7feaf003 --- /dev/null +++ b/acp-json-jackson2/src/main/java/com/agentclientprotocol/sdk/json/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Jackson 2 implementation of the SDK's JSON mapping (acp-json-jackson2); this module's part of the package shared with acp-core. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.json; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/Jackson2ArchitectureTest.java b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/Jackson2ArchitectureTest.java new file mode 100644 index 00000000..9f12133a --- /dev/null +++ b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/Jackson2ArchitectureTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-json-jackson2: an {@code AcpJsonMapper} implementation, found through + * {@code AcpJsonMapperSupplier}. It implements acp-core's JSON SPI and may see the schema it maps, + * nothing else of the SDK: no client, agent, session, transport or capability code, no + * package-private acp-core class (it shares the {@code json} package with acp-core, so the compiler would + * allow it), and not the other JSON implementation. + */ +@AnalyzeClasses(locations = Jackson2ArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class Jackson2ArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + /** acp-core's JSON SPI: what an implementation implements and receives. */ + static final DescribedPredicate JSON_SPI = belongToAnyOf(AcpJsonMapper.class, + AcpJsonMapperSupplier.class, TypeRef.class) + .as("the JSON SPI (AcpJsonMapper, AcpJsonMapperSupplier, TypeRef)"); + + @ArchTest + static final ArchRule usesOnlyTheJsonSpiAndTheSchema = noClasses().should() + .dependOnClassesThat(resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(not(JSON_SPI)) + .and(not(resideInAPackage("com.agentclientprotocol.sdk.spec.."))) + .as("SDK classes other than the JSON SPI and the schema")) + .because("a JSON implementation plugs in beneath the protocol layer and must not know what uses it"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = JacksonAcpJsonMapper.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperTest.java b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperTest.java index 2bcd62c3..4130e96d 100644 --- a/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperTest.java +++ b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonAcpJsonMapperTest.java @@ -4,6 +4,7 @@ package com.agentclientprotocol.sdk.json; +import java.nio.charset.StandardCharsets; import java.util.List; import ch.qos.logback.classic.Level; @@ -95,4 +96,21 @@ void strictMapperStillAcceptsEverySpecField() throws Exception { assertThat(response.meta()).containsEntry("k", "v"); } + + /** + * The JSON {@code null} literal reads as null through every overload; this is why + * {@code readValue} is declared {@code @Nullable}. + */ + @Test + void jsonNullLiteralReadsAsNull() throws Exception { + AcpJsonMapper mapper = new JacksonAcpJsonMapperSupplier().get(); + byte[] bytes = "null".getBytes(StandardCharsets.UTF_8); + TypeRef typeRef = new TypeRef<>() { + }; + assertThat(mapper.readValue("null", AcpSchema.JSONRPCError.class)).isNull(); + assertThat(mapper.readValue(bytes, AcpSchema.JSONRPCError.class)).isNull(); + assertThat(mapper.readValue("null", typeRef)).isNull(); + assertThat(mapper.readValue(bytes, typeRef)).isNull(); + } + } diff --git a/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonVersionsTest.java b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonVersionsTest.java new file mode 100644 index 00000000..7ad73df0 --- /dev/null +++ b/acp-json-jackson2/src/test/java/com/agentclientprotocol/sdk/json/JacksonVersionsTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json; + +import com.fasterxml.jackson.core.Version; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * acp-json-jackson2 states its Jackson floor and checks it when a mapper is created: an older + * jackson-core (a framework's BOM pinning 2.16.1 under databind 2.22.3, say) fails at once with + * a message naming both versions, instead of a {@code NoSuchMethodError} on the first message. + */ +class JacksonVersionsTest { + + @Test + void aJacksonOlderThanTheFloorIsRefusedNamingFoundAndRequired() { + assertThatThrownBy(() -> JacksonVersions.require("jackson-core", + new Version(2, 16, 1, null, "com.fasterxml.jackson.core", "jackson-core"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("jackson-core 2.16.1") + .hasMessageContaining("needs Jackson " + JacksonVersions.FLOOR + " or later"); + } + + @Test + void theFloorAndTheJacksonOfQuarkusAreAccepted() { + assertThatCode(() -> JacksonVersions.require("jackson-databind", + new Version(2, 18, 1, null, "com.fasterxml.jackson.core", "jackson-databind"))) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> JacksonVersions.require("jackson-databind", + new Version(2, 18, 0, null, "com.fasterxml.jackson.core", "jackson-databind"))) + .isInstanceOf(IllegalStateException.class); + // Quarkus 3.40 manages Jackson 2.21.7. + assertThatCode(() -> JacksonVersions.require("jackson-databind", + new Version(2, 21, 7, null, "com.fasterxml.jackson.core", "jackson-databind"))) + .doesNotThrowAnyException(); + } + + @Test + void theJacksonOnTheClasspathIsSupportedAndTheSupplierChecksIt() { + assertThatCode(JacksonVersions::requireSupported).doesNotThrowAnyException(); + assertThatCode(() -> new JacksonAcpJsonMapperSupplier().get()).doesNotThrowAnyException(); + } + +} diff --git a/acp-json-jackson2/src/test/resources/archunit.properties b/acp-json-jackson2/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-json-jackson2/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-json-jackson3/pom.xml b/acp-json-jackson3/pom.xml index 4acd5517..173bdc7c 100644 --- a/acp-json-jackson3/pom.xml +++ b/acp-json-jackson3/pom.xml @@ -7,7 +7,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-json-jackson3 @@ -16,7 +16,18 @@ ACP JSON Jackson 3 ACP JSON mapper implementation based on Jackson 3 (tools.jackson.databind) + + + 0.94 + 0.60 + + + + + org.jspecify + jspecify + com.agentclientprotocol acp-core @@ -52,6 +63,11 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + test + org.assertj assertj-core diff --git a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapper.java b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapper.java index 3018a601..f9d3ce06 100644 --- a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapper.java +++ b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapper.java @@ -8,6 +8,7 @@ import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import tools.jackson.core.JacksonException; @@ -17,27 +18,32 @@ import tools.jackson.databind.JavaType; import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.cfg.CoercionAction; +import tools.jackson.databind.cfg.CoercionInputShape; import tools.jackson.databind.cfg.EnumFeature; import tools.jackson.databind.deser.DeserializationProblemHandler; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.type.LogicalType; /** - * Jackson 3 ({@code tools.jackson}) implementation of {@link AcpJsonMapper}, shipped in - * {@code acp-json-jackson3}. Wraps a Jackson {@link JsonMapper} but keeps the SDK - * decoupled from Jackson at the API level. + * The Jackson 3 {@link AcpJsonMapper}, from the {@code acp-json-jackson3} module: it reads and + * writes ACP messages with a Jackson 3 ({@code tools.jackson}) {@link JsonMapper}. + * {@link AcpJsonMapper#createDefault()} returns one built on {@link #defaultJsonMapper()} when this + * module is on the classpath, even next to {@code acp-json-jackson2}; create one yourself to give a + * transport a {@code JsonMapper} you configured. Use it when the application already runs on + * Jackson 3, as under Spring Boot 4. * - *

    - * The schema records carry Jackson 2 annotations ({@code com.fasterxml.jackson.annotation}), - * which Jackson 3 reads unchanged, so both JSON modules serialize the same records. With - * {@link #defaultJsonMapper()} the bytes on the wire are the same as with the Jackson 2 - * module. - *

    + *

    How it differs from the Jackson 2 module's {@code JacksonAcpJsonMapper}: it needs jackson-core + * and jackson-databind 3.0.0 or later, and checks both when it is created (an + * {@link IllegalStateException} names the versions found). Jackson 3 reports errors with the + * unchecked {@link JacksonException}; to keep the {@link AcpJsonMapper} contract, this mapper + * rethrows it as an {@link IOException} from reads and writes and as an + * {@link IllegalArgumentException} from conversions, with the original as the cause. Jackson 3 + * mappers are immutable, so it is safe for concurrent use. * - *

    - * Jackson 3 reports errors with the unchecked {@link JacksonException}. To keep the - * {@link AcpJsonMapper} contract, reads and writes rethrow it as an {@link IOException} - * (the original is the cause) and conversions as an {@link IllegalArgumentException}. - *

    + *

    The schema records carry Jackson 2 annotations ({@code com.fasterxml.jackson.annotation}), + * which Jackson 3 reads unchanged, so both modules serialize the same records. With + * {@link #defaultJsonMapper()} the bytes on the wire are the same as with the Jackson 2 module. * * @author Mark Pollack */ @@ -48,40 +54,36 @@ public final class Jackson3AcpJsonMapper implements AcpJsonMapper { private final JsonMapper jsonMapper; /** - * The {@link JsonMapper} the SDK uses by default. Like the Jackson 2 module's - * default it is lenient about unknown properties, because the ACP - * specification adds fields between releases and an agent newer than this SDK must - * keep working, and it logs each unknown property at DEBUG under this class's logger, - * so spec drift is observable without making the wire strict. + * Returns a new {@link JsonMapper} set up the way the SDK reads and writes ACP; the module's + * supplier builds its mapper on it. Like the Jackson 2 module's default, it skips unknown + * properties instead of rejecting them, because the ACP specification adds fields between + * releases and an agent newer than this SDK must keep working, and logs each one at DEBUG under + * this class's logger, so drift can be seen without making the wire strict. It also refuses a + * scalar of the wrong JSON type rather than coercing it (a number or boolean where a string is + * expected, a string where a number or boolean is expected): the SDK answers such params + * {@code -32602} (Invalid params), and a coerced value would hide a peer's bug. * - *

    - * Jackson 3 changed several defaults from Jackson 2. Where the change would alter - * what the SDK writes or accepts, this mapper restores the Jackson 2 behaviour: - *

    + *

    Jackson 3 changed several defaults from Jackson 2. Where the change would alter what the + * SDK writes or accepts, this mapper restores the Jackson 2 behaviour: *

      - *
    • {@link MapperFeature#SORT_PROPERTIES_ALPHABETICALLY} is disabled, so properties - * are written in record-component order, as with Jackson 2;
    • - *
    • {@link DeserializationFeature#FAIL_ON_NULL_FOR_PRIMITIVES} is disabled, so a - * JSON {@code null} for a primitive component reads as its default value;
    • - *
    • {@link DeserializationFeature#FAIL_ON_TRAILING_TOKENS} is disabled, so content - * after the first JSON value is ignored rather than rejected;
    • + *
    • {@link MapperFeature#SORT_PROPERTIES_ALPHABETICALLY} is disabled, so properties are + * written in record-component order;
    • + *
    • {@link DeserializationFeature#FAIL_ON_NULL_FOR_PRIMITIVES} is disabled, so a JSON + * {@code null} for a primitive component reads as its default value;
    • + *
    • {@link DeserializationFeature#FAIL_ON_TRAILING_TOKENS} is disabled, so content after the + * first JSON value is ignored rather than rejected;
    • *
    • {@link EnumFeature#READ_ENUMS_USING_TO_STRING} and * {@link EnumFeature#WRITE_ENUMS_USING_TO_STRING} are disabled, so enums use their * {@code @JsonProperty} name or constant name, never {@code toString()}.
    • *
    - *

    - * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled explicitly - * (it is already off by default in Jackson 3, unlike Jackson 2). - *

    * - *

    - * Strictness is the mapper's decision, not the schema's: the schema records carry no - * {@code @JsonIgnoreProperties}, so a mapper with - * {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} enabled fails on the - * first unknown field. Jackson 3 mappers are immutable; to customise this one, start - * from {@code defaultJsonMapper().rebuild()}. - *

    - * @return a new, independently configurable lenient mapper + *

    {@link DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES} is disabled explicitly; Jackson + * 3 already has it off by default. Strictness is the mapper's choice, not the schema's: the + * schema records carry no {@code @JsonIgnoreProperties}, so a mapper with that feature enabled + * fails on the first unknown field. Jackson 3 mappers are immutable; to customise this one, + * start from {@code defaultJsonMapper().rebuild()}, which keeps the logging of unknown + * properties, and pass the result to {@link #Jackson3AcpJsonMapper(JsonMapper)}. + * @return a new mapper, shared with no other caller */ public static JsonMapper defaultJsonMapper() { return JsonMapper.builder() @@ -90,6 +92,11 @@ public static JsonMapper defaultJsonMapper() { .disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) .disable(EnumFeature.READ_ENUMS_USING_TO_STRING, EnumFeature.WRITE_ENUMS_USING_TO_STRING) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .withCoercionConfig(LogicalType.Textual, + config -> config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)) .addHandler(new UnknownPropertyLogger()) .build(); } @@ -99,7 +106,7 @@ static final class UnknownPropertyLogger extends DeserializationProblemHandler { @Override public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, - ValueDeserializer deserializer, Object beanOrClass, String propertyName) throws JacksonException { + ValueDeserializer deserializer, @Nullable Object beanOrClass, String propertyName) throws JacksonException { if (logger.isDebugEnabled()) { Object type = beanOrClass instanceof Class c ? c.getSimpleName() : beanOrClass != null ? beanOrClass.getClass().getSimpleName() : "?"; @@ -113,11 +120,16 @@ public boolean handleUnknownProperty(DeserializationContext ctxt, JsonParser p, } /** - * Constructs a new Jackson3AcpJsonMapper with the given JsonMapper, used as is. - * @param jsonMapper the JsonMapper to use. Must not be null. - * @throws IllegalArgumentException if the provided JsonMapper is null. + * Creates a mapper that reads and writes with the given {@code JsonMapper}, used as is: none of + * the settings of {@link #defaultJsonMapper()} are added. + * @param jsonMapper the Jackson mapper; start from {@link #defaultJsonMapper()} to keep the + * SDK's settings + * @throws IllegalArgumentException if {@code jsonMapper} is null + * @throws IllegalStateException if the jackson-core or jackson-databind on the classpath is + * older than 3.0.0 */ public Jackson3AcpJsonMapper(JsonMapper jsonMapper) { + Jackson3Versions.requireSupported(); if (jsonMapper == null) { throw new IllegalArgumentException("JsonMapper must not be null"); } @@ -125,15 +137,16 @@ public Jackson3AcpJsonMapper(JsonMapper jsonMapper) { } /** - * Returns the underlying Jackson {@link JsonMapper}. - * @return the JsonMapper instance + * Returns the {@code JsonMapper} this mapper reads and writes with: the one given to the + * constructor. + * @return the Jackson mapper */ public JsonMapper getJsonMapper() { return jsonMapper; } @Override - public T readValue(String content, Class type) throws IOException { + public @Nullable T readValue(String content, Class type) throws IOException { try { return jsonMapper.readValue(content, type); } @@ -143,7 +156,7 @@ public T readValue(String content, Class type) throws IOException { } @Override - public T readValue(byte[] content, Class type) throws IOException { + public @Nullable T readValue(byte[] content, Class type) throws IOException { try { return jsonMapper.readValue(content, type); } @@ -153,7 +166,7 @@ public T readValue(byte[] content, Class type) throws IOException { } @Override - public T readValue(String content, TypeRef type) throws IOException { + public @Nullable T readValue(String content, TypeRef type) throws IOException { try { return jsonMapper.readValue(content, javaType(type)); } @@ -163,7 +176,7 @@ public T readValue(String content, TypeRef type) throws IOException { } @Override - public T readValue(byte[] content, TypeRef type) throws IOException { + public @Nullable T readValue(byte[] content, TypeRef type) throws IOException { try { return jsonMapper.readValue(content, javaType(type)); } diff --git a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperSupplier.java b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperSupplier.java index 52d36740..8271f5bd 100644 --- a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperSupplier.java +++ b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperSupplier.java @@ -8,17 +8,25 @@ import com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier; /** - * Jackson 3 supplier of {@link AcpJsonMapper} instances, registered via - * {@link java.util.ServiceLoader}. Its {@link #priority()} is {@value #PRIORITY}: above - * the Jackson 2 supplier, so adding {@code acp-json-jackson3} next to the - * {@code acp-json-jackson2} that the transport modules bring in switches to Jackson 3 - * without exclusions, and below any application supplier that keeps the default. + * The {@link AcpJsonMapperSupplier} of the {@code acp-json-jackson3} module, registered with + * {@link java.util.ServiceLoader} so that {@link AcpJsonMapper#createDefault()} finds it. Each + * {@link #get()} returns a new {@link Jackson3AcpJsonMapper} on + * {@link Jackson3AcpJsonMapper#defaultJsonMapper()}. Applications do not call it: they call + * {@code createDefault()}, or create the mapper themselves. + * + *

    Unlike the Jackson 2 module's {@code JacksonAcpJsonMapperSupplier} ({@code -200}), its + * {@link #priority()} is {@value #PRIORITY}, so adding {@code acp-json-jackson3} next to the + * {@code acp-json-jackson2} that other SDK modules bring in switches to Jackson 3 without + * exclusions. An application supplier that keeps the default priority still wins over it. * * @author Mark Pollack */ public class Jackson3AcpJsonMapperSupplier implements AcpJsonMapperSupplier { - /** The priority of this supplier. */ + /** + * This supplier's priority, {@value}: above the Jackson 2 supplier's and below + * {@link AcpJsonMapperSupplier#DEFAULT_PRIORITY}. + */ public static final int PRIORITY = -100; @Override @@ -26,8 +34,18 @@ public int priority() { return PRIORITY; } + /** + * Returns a new {@link Jackson3AcpJsonMapper} built on + * {@link Jackson3AcpJsonMapper#defaultJsonMapper()}. The Jackson version is checked before the + * {@code JsonMapper} is built. + * @return a new mapper + * @throws IllegalStateException if the jackson-core or jackson-databind on the classpath is + * older than 3.0.0 + */ @Override public AcpJsonMapper get() { + // Before building the JsonMapper: an unsupported Jackson may fail inside it. + Jackson3Versions.requireSupported(); return new Jackson3AcpJsonMapper(Jackson3AcpJsonMapper.defaultJsonMapper()); } diff --git a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3Versions.java b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3Versions.java new file mode 100644 index 00000000..e4785273 --- /dev/null +++ b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3Versions.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json.jackson3; + +import tools.jackson.core.Version; + +/** + * The Jackson 3 versions acp-json-jackson3 supports: {@value #FLOOR} or later, for jackson-core + * and jackson-databind alike. Checked when the mapper is created, so a framework that manages an + * older Jackson fails at once with a message naming the versions, not later while reading a + * message. + */ +final class Jackson3Versions { + + /** The oldest Jackson 3 release the SDK's tests pass with: the first release of Jackson 3. */ + static final String FLOOR = "3.0.0"; + + private static final int FLOOR_MAJOR = 3; + + private static final int FLOOR_MINOR = 0; + + private Jackson3Versions() { + } + + /** + * Fails unless the jackson-core and jackson-databind on the classpath are {@value #FLOOR} or + * later. + * @throws IllegalStateException naming the version found and the version required + */ + static void requireSupported() { + require("jackson-core", tools.jackson.core.json.PackageVersion.VERSION); + require("jackson-databind", tools.jackson.databind.cfg.PackageVersion.VERSION); + } + + /** + * Fails unless {@code found} is {@value #FLOOR} or later. + * @param artifact the Jackson artifact the version belongs to + * @param found the version on the classpath + * @throws IllegalStateException naming the version found and the version required + */ + static void require(String artifact, Version found) { + if (found.getMajorVersion() != FLOOR_MAJOR || found.getMinorVersion() < FLOOR_MINOR) { + throw new IllegalStateException("acp-json-jackson3 needs Jackson " + FLOOR + " or later, but " + artifact + + " " + found.toString() + " is on the classpath (jackson-core " + + tools.jackson.core.json.PackageVersion.VERSION.toString() + ", jackson-databind " + + tools.jackson.databind.cfg.PackageVersion.VERSION.toString() + + "). Align the Jackson 3 artifacts on one version, " + FLOOR + + " or later, for example by importing tools.jackson:jackson-bom."); + } + } + +} diff --git a/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/package-info.java b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/package-info.java new file mode 100644 index 00000000..add13773 --- /dev/null +++ b/acp-json-jackson3/src/main/java/com/agentclientprotocol/sdk/json/jackson3/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Jackson 3 implementation of the SDK's JSON mapping (acp-json-jackson3). + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.json.jackson3; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperTest.java b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperTest.java index c2c7d0ec..3f7b3228 100644 --- a/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperTest.java +++ b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3AcpJsonMapperTest.java @@ -5,6 +5,7 @@ package com.agentclientprotocol.sdk.json.jackson3; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.List; import ch.qos.logback.classic.Level; @@ -13,6 +14,7 @@ import ch.qos.logback.core.read.ListAppender; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.StrictMapperFixtures; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; @@ -154,4 +156,21 @@ public static class Payload { } + + /** + * The JSON {@code null} literal reads as null through every overload; this is why + * {@code readValue} is declared {@code @Nullable}. + */ + @Test + void jsonNullLiteralReadsAsNull() throws Exception { + AcpJsonMapper mapper = new Jackson3AcpJsonMapperSupplier().get(); + byte[] bytes = "null".getBytes(StandardCharsets.UTF_8); + TypeRef typeRef = new TypeRef<>() { + }; + assertThat(mapper.readValue("null", AcpSchema.JSONRPCError.class)).isNull(); + assertThat(mapper.readValue(bytes, AcpSchema.JSONRPCError.class)).isNull(); + assertThat(mapper.readValue("null", typeRef)).isNull(); + assertThat(mapper.readValue(bytes, typeRef)).isNull(); + } + } diff --git a/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3ArchitectureTest.java b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3ArchitectureTest.java new file mode 100644 index 00000000..a038cbb7 --- /dev/null +++ b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3ArchitectureTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json.jackson3; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.AcpJsonMapperSupplier; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-json-jackson3: an {@code AcpJsonMapper} implementation, found through + * {@code AcpJsonMapperSupplier}. It implements acp-core's JSON SPI and may see the schema it maps, + * nothing else of the SDK: no client, agent, session, transport or capability code, no + * package-private acp-core class (such as the selector), and not the other JSON implementation. + */ +@AnalyzeClasses(locations = Jackson3ArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class Jackson3ArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + /** acp-core's JSON SPI: what an implementation implements and receives. */ + static final DescribedPredicate JSON_SPI = belongToAnyOf(AcpJsonMapper.class, + AcpJsonMapperSupplier.class, TypeRef.class) + .as("the JSON SPI (AcpJsonMapper, AcpJsonMapperSupplier, TypeRef)"); + + @ArchTest + static final ArchRule usesOnlyTheJsonSpiAndTheSchema = noClasses().should() + .dependOnClassesThat(resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(not(JSON_SPI)) + .and(not(resideInAPackage("com.agentclientprotocol.sdk.spec.."))) + .as("SDK classes other than the JSON SPI and the schema")) + .because("a JSON implementation plugs in beneath the protocol layer and must not know what uses it"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = Jackson3AcpJsonMapper.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3VersionsTest.java b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3VersionsTest.java new file mode 100644 index 00000000..4e45a20f --- /dev/null +++ b/acp-json-jackson3/src/test/java/com/agentclientprotocol/sdk/json/jackson3/Jackson3VersionsTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.json.jackson3; + +import tools.jackson.core.Version; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * acp-json-jackson3 states its Jackson 3 floor and checks it when a mapper is created: a Jackson + * outside it fails at once with a message naming the versions found and required. + */ +class Jackson3VersionsTest { + + @Test + void aJacksonOutsideTheFloorIsRefusedNamingFoundAndRequired() { + assertThatThrownBy(() -> Jackson3Versions.require("jackson-core", + new Version(2, 22, 3, null, "tools.jackson.core", "jackson-core"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("jackson-core 2.22.3") + .hasMessageContaining("needs Jackson " + Jackson3Versions.FLOOR + " or later"); + } + + @Test + void theFloorAndTheJacksonOfSpringBootAreAccepted() { + String[] floor = Jackson3Versions.FLOOR.split("\\."); + assertThatCode(() -> Jackson3Versions.require("jackson-databind", new Version(3, Integer.parseInt(floor[1]), + 0, null, "tools.jackson.core", "jackson-databind"))) + .doesNotThrowAnyException(); + // Spring Boot 4.1 manages Jackson 3.1.7. + assertThatCode(() -> Jackson3Versions.require("jackson-databind", + new Version(3, 1, 7, null, "tools.jackson.core", "jackson-databind"))) + .doesNotThrowAnyException(); + } + + @Test + void theJacksonOnTheClasspathIsSupportedAndTheSupplierChecksIt() { + assertThatCode(Jackson3Versions::requireSupported).doesNotThrowAnyException(); + assertThatCode(() -> new Jackson3AcpJsonMapperSupplier().get()).doesNotThrowAnyException(); + } + +} diff --git a/acp-json-jackson3/src/test/resources/archunit.properties b/acp-json-jackson3/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-json-jackson3/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-micronaut-sample/pom.xml b/acp-micronaut-sample/pom.xml new file mode 100644 index 00000000..302ef26a --- /dev/null +++ b/acp-micronaut-sample/pom.xml @@ -0,0 +1,193 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-micronaut-sample + jar + + ACP Micronaut Sample + A Micronaut application serving an annotated ACP agent, and the end-to-end tests that run it as a process. Not published. + + + + 0.82 + 0.00 + + true + + + + + + + io.projectreactor + reactor-bom + ${reactor.version} + pom + import + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + org.eclipse.jetty + jetty-bom + ${jetty.version} + pom + import + + + org.eclipse.jetty.ee10 + jetty-ee10-bom + ${jetty.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + + + + ch.qos.logback + logback-core + ${logback.version} + + + io.micronaut.platform + micronaut-platform + ${micronaut.platform.version} + pom + import + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-micronaut + ${project.version} + + + + com.agentclientprotocol + acp-streamable-http-jetty + + + io.micronaut + micronaut-context + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + io.micronaut.test + micronaut-test-junit5 + test + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + + + + default-compile + + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + none + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + attach-javadocs + none + + + + + + + diff --git a/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Application.java b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Application.java new file mode 100644 index 00000000..731a43e3 --- /dev/null +++ b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Application.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import io.micronaut.runtime.Micronaut; + +/** + * Starts the agent. Over stdio (the default) standard output carries the protocol, so the + * banner is off and {@code logback.xml} logs to standard error; the process exits when its + * client closes the agent's input. With {@code -Dacp.agent.transport.type=http} it serves + * Streamable HTTP and WebSocket on {@code acp.agent.transport.http.listener.port} until stopped. + */ +public final class Application { + + private Application() { + } + + /** + * Runs the application. + * @param args command-line arguments, which may set properties ({@code --acp.agent...}) + */ + public static void main(String[] args) { + Micronaut.build(args).mainClass(Application.class).banner(false).start(); + } + +} diff --git a/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgent.java b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgent.java new file mode 100644 index 00000000..150c98a9 --- /dev/null +++ b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgent.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import java.util.UUID; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import jakarta.inject.Singleton; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +/** + * Answers every prompt with two message chunks, the greeter's prefix and the prompt's text. + * A Micronaut singleton like any other bean: its dependencies are injected, and Micronaut + * finds it from its compile-time bean definition. One instance serves every session and, over + * HTTP, every connection, so it keeps no per-session state in fields. {@code initialize} is + * the SDK's default. + */ +@Singleton +@AcpAgent(name = "micronaut-echo-agent", version = "1.0.0") +public class EchoAgent { + + private final Greeter greeter; + + /** + * Creates the agent. + * @param greeter the injected greeter + */ + public EchoAgent(Greeter greeter) { + this.greeter = greeter; + } + + /** + * Opens a session; a {@code Publisher}, as Micronaut code often returns. + * @param request the request + * @return the new session's id + */ + @NewSession + public Publisher newSession(AcpSchema.NewSessionRequest request) { + return Mono.fromSupplier(() -> new AcpSchema.NewSessionResponse(UUID.randomUUID().toString(), null, null)); + } + + /** + * Echoes the prompt. + * @param request the prompt + * @param context the turn's context, for sending updates + * @return end of turn + */ + @Prompt + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + context.sendMessage(greeter.prefix()); + context.sendMessage(request.text()); + return AcpSchema.PromptResponse.endTurn(); + } + +} diff --git a/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Greeter.java b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Greeter.java new file mode 100644 index 00000000..2b532f61 --- /dev/null +++ b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/Greeter.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import io.micronaut.context.annotation.Property; +import jakarta.inject.Singleton; + +/** An ordinary application bean, injected into the agent. */ +@Singleton +public class Greeter { + + private final String prefix; + + /** + * Creates the greeter. + * @param prefix what every answer starts with, from {@code sample.prefix} + */ + public Greeter(@Property(name = "sample.prefix", defaultValue = "echo: ") String prefix) { + this.prefix = prefix; + } + + /** + * The answer's first chunk. + * @return the prefix + */ + public String prefix() { + return prefix; + } + +} diff --git a/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/package-info.java b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/package-info.java new file mode 100644 index 00000000..dda34c97 --- /dev/null +++ b/acp-micronaut-sample/src/main/java/com/agentclientprotocol/sdk/micronaut/sample/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * A Micronaut application serving an annotated ACP agent: {@link Application} starts it, and + * {@link EchoAgent} is the agent, a Micronaut bean that takes other beans by injection. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.micronaut.sample; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-micronaut-sample/src/main/resources/application.properties b/acp-micronaut-sample/src/main/resources/application.properties new file mode 100644 index 00000000..4cfebdfe --- /dev/null +++ b/acp-micronaut-sample/src/main/resources/application.properties @@ -0,0 +1,4 @@ +micronaut.application.name=acp-micronaut-sample +# stdio by default: standard output carries the protocol (logback.xml logs to standard error) +acp.agent.transport.type=stdio +acp.agent.transport.http.listener.port=8080 diff --git a/acp-micronaut-sample/src/main/resources/logback.xml b/acp-micronaut-sample/src/main/resources/logback.xml new file mode 100644 index 00000000..68517671 --- /dev/null +++ b/acp-micronaut-sample/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcess.java b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcess.java new file mode 100644 index 00000000..97318acc --- /dev/null +++ b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcess.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** The sample application, started as its own JVM, the way an ACP client starts an agent. */ +final class AgentProcess { + + private AgentProcess() { + } + + /** The test JVM's classpath, which holds the application and its dependencies. */ + static String classpath() { + return System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")); + } + + /** The command line that starts the application with the given arguments. */ + static List command(String... args) { + List command = new ArrayList<>(List.of(java(), "-cp", classpath(), Application.class.getName())); + command.addAll(List.of(args)); + return command; + } + + static String java() { + return System.getProperty("java.home") + "/bin/java"; + } + + static Process start(String... args) { + try { + return new ProcessBuilder(command(args)).start(); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + /** + * Collects a process's standard error lines on a daemon thread. The list is complete once + * {@link #drained} returns. + */ + static List collectStderr(Process process) { + List lines = new CopyOnWriteArrayList<>(); + Thread reader = new Thread(() -> { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = in.readLine()) != null) { + lines.add(line); + } + } + catch (IOException ex) { + // the process ended + } + }, "agent-stderr"); + reader.setDaemon(true); + reader.start(); + READERS.put(System.identityHashCode(lines), reader); + return lines; + } + + private static final java.util.Map READERS = new java.util.concurrent.ConcurrentHashMap<>(); + + /** Waits until the process's standard error has been read to its end. */ + static List drained(List lines) throws InterruptedException { + Thread reader = READERS.remove(System.identityHashCode(lines)); + if (reader != null) { + reader.join(10_000); + } + return lines; + } + +} diff --git a/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcessTest.java b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcessTest.java new file mode 100644 index 00000000..bec6aaf5 --- /dev/null +++ b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/AgentProcessTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The sample run as an agent process: standard output carries only the protocol, the process + * exits 0 by itself when its input ends, and SIGTERM stops it gracefully over stdio and over + * HTTP. + */ +class AgentProcessTest { + + private static final String INITIALIZE = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"," + + "\"params\":{\"protocolVersion\":1,\"clientCapabilities\":{}}}\n"; + + @Test + void stdioExitsZeroWhenItsInputEndsAndWritesOnlyProtocolToStdout() throws Exception { + Process agent = AgentProcess.start(); + List stderr = AgentProcess.collectStderr(agent); + BufferedReader stdout = new BufferedReader(new InputStreamReader(agent.getInputStream(), StandardCharsets.UTF_8)); + OutputStream stdin = agent.getOutputStream(); + + stdin.write(INITIALIZE.getBytes(StandardCharsets.UTF_8)); + stdin.flush(); + assertThat(stdout.readLine()).startsWith("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":1"); + + long closed = System.nanoTime(); + stdin.close(); + assertThat(agent.waitFor(10, TimeUnit.SECONDS)).as("exits after the end of its input").isTrue(); + assertThat(Duration.ofNanos(System.nanoTime() - closed)).isLessThan(Duration.ofSeconds(5)); + assertThat(agent.exitValue()).isZero(); + assertThat(stdout.readLine()).as("nothing else on stdout").isNull(); + assertThat(AgentProcess.drained(stderr)).anyMatch(line -> line.contains("ACP agent transport ended; closing the application context")) + .noneMatch(line -> line.contains(" ERROR ") || line.contains(" WARN ")); + } + + @Test + void stdioStopsGracefullyOnSigterm() throws Exception { + Process agent = AgentProcess.start(); + List stderr = AgentProcess.collectStderr(agent); + BufferedReader stdout = new BufferedReader(new InputStreamReader(agent.getInputStream(), StandardCharsets.UTF_8)); + agent.getOutputStream().write(INITIALIZE.getBytes(StandardCharsets.UTF_8)); + agent.getOutputStream().flush(); + assertThat(stdout.readLine()).contains("\"protocolVersion\":1"); + + // SIGTERM. Not Process.destroy(), which also closes this side's pipes, losing the + // agent's last log lines. + agent.toHandle().destroy(); + + assertThat(agent.waitFor(15, TimeUnit.SECONDS)).isTrue(); + assertThat(AgentProcess.drained(stderr)).anyMatch(line -> line.contains("Stopping the ACP agent")) + .noneMatch(line -> line.contains(" ERROR ")); + } + + @Test + void httpServesUntilSigtermThenStopsGracefully() throws Exception { + Process agent = AgentProcess.start("--acp.agent.transport.type=http", "--acp.agent.transport.http.listener.port=0", + "--acp.agent.transport.http.shutdown-timeout=2s"); + List stderr = AgentProcess.collectStderr(agent); + int port = awaitPort(stderr); + + try (Socket socket = new Socket("localhost", port)) { + assertThat(socket.isConnected()).isTrue(); + } + // SIGTERM. Not Process.destroy(), which also closes this side's pipes, losing the + // agent's last log lines. + agent.toHandle().destroy(); + + assertThat(agent.waitFor(15, TimeUnit.SECONDS)).isTrue(); + assertThat(AgentProcess.drained(stderr)).anyMatch(line -> line.contains("Stopping the ACP agent")).noneMatch(line -> line.contains(" ERROR ")); + } + + static int awaitPort(List stderr) throws InterruptedException { + Pattern listening = Pattern.compile("ACP agent listening on port (\\d+)"); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (System.nanoTime() < deadline) { + for (String line : stderr) { + Matcher matcher = listening.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + } + Thread.sleep(50); + } + throw new AssertionError("the agent did not report its port: " + stderr); + } + +} diff --git a/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgentTest.java b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgentTest.java new file mode 100644 index 00000000..f8958d82 --- /dev/null +++ b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/EchoAgentTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.micronaut.agent.AcpAgentRuntime; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.micronaut.context.annotation.Property; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The sample agent in-process, over WebSocket, with a configured greeting. */ +@MicronautTest +@Property(name = "acp.agent.transport.type", value = "http") +@Property(name = "acp.agent.transport.http.listener.port", value = "0") +@Property(name = "sample.prefix", value = "hi: ") +class EchoAgentTest { + + @Inject + AcpAgentRuntime runtime; + + @Test + void answersOverWebSocketWithTheConfiguredPrefix() { + List chunks = new CopyOnWriteArrayList<>(); + URI uri = URI.create("ws://localhost:" + runtime.port().orElseThrow() + "/acp"); + AcpSyncClient client = AcpClient.sync(new WebSocketAcpClientTransport(uri, AcpJsonMapper.createDefault())) + .requestTimeout(Duration.ofSeconds(10)) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks.add(text.text()); + } + }) + .build(); + try { + AcpSchema.InitializeResponse init = client.initialize(); + // agentInfo comes from @AcpAgent(name, version) on the bean's class + assertThat(init.agentInfo()).isNotNull(); + assertThat(init.agentInfo().name()).isEqualTo("micronaut-echo-agent"); + assertThat(init.agentInfo().version()).isEqualTo("1.0.0"); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + client.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("there")))); + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (!String.join("", chunks).equals("hi: there") && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertThat(String.join("", chunks)).isEqualTo("hi: there"); + } + finally { + client.closeGracefully(); + } + } + +} diff --git a/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/MicronautClientOverStdioTest.java b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/MicronautClientOverStdioTest.java new file mode 100644 index 00000000..98d9b6ce --- /dev/null +++ b/acp-micronaut-sample/src/test/java/com/agentclientprotocol/sdk/micronaut/sample/MicronautClientOverStdioTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.sample; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.micronaut.context.annotation.Requires; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.test.support.TestPropertyProvider; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A Micronaut client application, configured by {@code acp.client.transport.stdio.*}, that + * starts the sample agent as a process, talks to it, and closes it: the agent exits 0 by + * itself on the end of its input, without needing a SIGTERM. + */ +@MicronautTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class MicronautClientOverStdioTest implements TestPropertyProvider { + + @Inject + AcpSyncClient client; + + @Inject + AcpAsyncClient asyncClient; + + @Inject + Chunks chunks; + + @Override + public Map getProperties() { + List command = AgentProcess.command(); + Map properties = new java.util.HashMap<>(); + // This application is a client: the sample's own agent bean stays off here. + properties.put("acp.agent.enabled", "false"); + properties.put("test.chunks", "true"); + properties.put("acp.client.transport.stdio.command", command.get(0)); + properties.put("acp.client.transport.stdio.args", String.join(",", command.subList(1, command.size()))); + // Micronaut reads SAMPLE_PREFIX as sample.prefix: the variable reaches the agent + properties.put("acp.client.transport.stdio.env.SAMPLE_PREFIX", "env: "); + return properties; + } + + @Test + void talksToTheAgentProcessAndClosesItWithoutASignal() { + ListAppender transportLog = new ListAppender<>(); + Logger logger = (Logger) LoggerFactory.getLogger(StdioAcpClientTransport.class); + Level previous = logger.getLevel(); + logger.setLevel(Level.DEBUG); + transportLog.start(); + logger.addAppender(transportLog); + try { + assertThat(client.initialize().protocolVersion()).isEqualTo(1); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("from Micronaut")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + eventually(() -> assertThat(String.join("", chunks.texts)).isEqualTo("env: from Micronaut")); + + asyncClient.closeGracefully().block(Duration.ofSeconds(30)); + + assertThat(transportLog.list).extracting(ILoggingEvent::getFormattedMessage) + .contains("ACP agent process stopped (exit code 0)") + .noneMatch(message -> message.contains("sending TERM")); + } + finally { + logger.detachAppender(transportLog); + logger.setLevel(previous); + } + } + + private static void eventually(Runnable assertion) { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (true) { + try { + assertion.run(); + return; + } + catch (AssertionError ex) { + if (System.nanoTime() > deadline) { + throw ex; + } + Thread.onSpinWait(); + } + } + } + + /** Records the text chunks, through the customizer contract. */ + @Singleton + @Requires(property = "test.chunks", value = "true") + static class Chunks implements AcpClientCustomizer { + + final List texts = new CopyOnWriteArrayList<>(); + + @Override + public void customize(com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + texts.add(text.text()); + } + return Mono.empty(); + }); + } + + } + +} diff --git a/acp-micronaut-sample/src/test/resources/logback-test.xml b/acp-micronaut-sample/src/test/resources/logback-test.xml new file mode 100644 index 00000000..3fff55a1 --- /dev/null +++ b/acp-micronaut-sample/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/acp-micronaut/README.md b/acp-micronaut/README.md new file mode 100644 index 00000000..f5f66583 --- /dev/null +++ b/acp-micronaut/README.md @@ -0,0 +1,161 @@ +# acp-micronaut + +Micronaut 4 integration for the ACP Java SDK. It serves your `@AcpAgent` bean over stdio, +Streamable HTTP or WebSocket, and it builds an ACP client from configuration. It needs Java 17+. + +```xml + + com.agentclientprotocol + acp-micronaut + ${acp.version} + + + + com.agentclientprotocol + acp-streamable-http-jetty + ${acp.version} + +``` + +Micronaut's annotation processor (`micronaut-inject-java`) must be on your compiler's annotation +processor path, as in any Micronaut application. + +## An agent + +Annotate the agent class `@AcpAgent` and make it a bean with `@Singleton` (any bean-defining +annotation works). It is an ordinary bean, so other beans are injected into it. + +```java +@Singleton +@AcpAgent(name = "echo", version = "1.0.0") +public class EchoAgent { + + private final Greeter greeter; + + public EchoAgent(Greeter greeter) { + this.greeter = greeter; + } + + @Prompt + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + context.sendMessage(greeter.prefix() + request.text()); + return AcpSchema.PromptResponse.endTurn(); + } +} + +public final class Application { + public static void main(String[] args) { + // stdout carries the protocol over stdio: no banner, and log to stderr (logback.xml) + Micronaut.build(args).mainClass(Application.class).banner(false).start(); + } +} +``` + +- **Finding the bean.** `AcpAgentRuntime` reads the `@AcpAgent` bean from the bean definitions + the compiler wrote, without scanning the classpath. Exactly one such bean is allowed. With more + than one, startup fails and names them all. An application without one, such as a client, starts + no agent. +- **Handlers.** The handler methods, their parameters and return types are those of + `acp-agent-support`. The `initialize` answer is derived from the class's annotations + (`agentInfo` from `@AcpAgent`, a capability for each declared handler), also when Micronaut + AOP advice makes the bean a generated subclass, which is then invoked so its advice runs. One bean instance serves every session, and over HTTP every connection, so + keep per-session state keyed by session id. Handlers may also return a `Mono`, a single-value + `Flux` or any Reactive Streams `Publisher` (its first element is the response), or a + `CompletionStage`. acp-micronaut does not bring `micronaut-core-reactive`, so Micronaut's + `Publishers` helper is on the classpath only if the application adds that dependency. +- **Extension beans.** Beans of type `AcpInterceptor`, `ArgumentResolver` and + `ReturnValueHandler` are added to the agent in bean order. +- **Lifecycle.** The agent starts with the application context (`StartupEvent`) and closes + gracefully with it. + - **Over stdio**, the application stays up while the transport runs. When the client closes the + agent's input and every answer has been written, the context closes and the process exits 0. + - **Over HTTP**, the listener runs until the application stops. + - **On SIGTERM**, the agent closes gracefully. Without an embedded server, Micronaut registers no + shutdown hook of its own, so the runtime registers one. + - **Graceful shutdown.** With `micronaut.lifecycle.graceful-shutdown.enabled=true`, the HTTP + listener drains through Micronaut's graceful shutdown. +- **Over stdio, standard output is the protocol.** Turn off the banner and send every log line to + standard error. Logback's console appender writes to standard output unless you set + `System.err`. + +### Agent settings (`acp.agent.*`) + +| Property | Default | | +|---|---|---| +| `acp.agent.enabled` | `true` | serve the `@AcpAgent` bean | +| `acp.agent.request-timeout` | `60s` | agent-to-client requests | +| `acp.agent.cancel-grace-period` | `60s` | after `session/cancel`, the SDK answers `cancelled` itself; `0s` for never | +| `acp.agent.max-prompt-duration` | `0s` (none) | a longer prompt is answered `-32800` | +| `acp.agent.shutdown-on-transport-end` | `true` | close the context when the stdio input ends | +| `acp.agent.shutdown-timeout` | `10s` | how long closing waits for a stdio agent's graceful close, then closes it at once | +| `acp.agent.transport.type` | `stdio` | `stdio`, `http` or `websocket`, in any case (the last two mean the same listener) | +| `acp.agent.transport.http.listener.host` | loopback | the address the listener binds; unset binds `127.0.0.1` and `::1` only, `0.0.0.0` every interface (an explicit opt-in: the listener has no authentication) | +| `acp.agent.transport.http.allowed-origins` | none | browser origins accepted besides `localhost`, `127.0.0.1` and `[::1]`; any other `Origin` is answered 403, over HTTP and on the WebSocket handshake; `*` for any | +| `acp.agent.transport.http.listener.port` | `8080` | the listener's own port; `0` for an ephemeral one (`AcpAgentRuntime.port()`) | +| `acp.agent.transport.http.listener.max-concurrent-streams-per-connection` | SDK (1024) | HTTP/2 streams one client connection may hold open | +| `acp.agent.transport.http.path` | `/acp` | | +| `acp.agent.transport.http.max-post-body-size` | SDK (16MB) | e.g. `4MB` | +| `acp.agent.transport.http.keep-alive-interval`, `mailbox-capacity`, `max-pending-sse-events`, `max-web-socket-pending-frames`, `max-provisional-sessions`, `shutdown-timeout`, `web-socket-idle-timeout` (30m: an idle WebSocket is closed with 1001), `initialize-timeout` (30s: a WebSocket without `initialize` is closed with 1008) | SDK | `StreamableHttpAcpAgentTransportOptions`; closing waits for the listener at most `shutdown-timeout` plus 5 seconds | + +An application bean of type `AcpAgentTransport` replaces stdio, for example the in-memory +transport of `acp-test` in a test. + +**HTTP runs on its own port.** Streamable HTTP and WebSocket are served by the SDK's Jetty +listener (`StreamableHttpAcpAgentTransport`). It runs next to Micronaut's HTTP server if you have +one, not inside it. It serves HTTP/1.1, cleartext HTTP/2 and WebSocket upgrades on one path. + +**Micronaut's security does not apply to it.** Because the listener is the SDK's own server, +Micronaut's filters, `micronaut-security` rules, CORS, TLS and metrics never see its requests. It +binds the loopback interface by default, so only programs on the same machine can connect, and it +refuses browser requests from foreign origins. Setting `acp.agent.transport.http.listener.host=0.0.0.0` +exposes an endpoint without authentication to the network: put a proxy that authenticates in front +of it. This is the interim arrangement for 0.80.0; serving ACP on Micronaut's own server, where its +security applies, is planned for 0.81.0. + +## A client + +A client is configured when a transport is named: `acp.client.transport.type`, `.stdio.command`, +`.websocket.uri` or `.http.uri` is set, as in Spring Boot and Quarkus. It has three beans: the transport, +an `AcpAsyncClient`, and an `AcpSyncClient` facade over that same client, which is one session on +one connection. Creating the client bean connects its transport (for stdio, it starts the agent +process); `initialize()` then performs the ACP handshake. It closes gracefully, once, with the +context. Customize it before it is built with `AcpClientCustomizer` beans +(`com.agentclientprotocol.sdk.integration.AcpClientCustomizer`, the same type in every framework +integration), applied in `@Order`: + +```java +@Singleton +class Updates implements AcpClientCustomizer { + public void customize(AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(notification -> { /* ... */ return Mono.empty(); }); + } +} +``` + +Without a session-update handler of your own, session updates are logged at DEBUG, content +included. DEBUG and TRACE on `com.agentclientprotocol.sdk` log message payloads in full; do not +enable them where logs are shipped. A stdio agent's standard error is logged at INFO, unfiltered, +on `com.agentclientprotocol.sdk.client.transport.agent-stderr`, and can carry sensitive data: set +that logger to `WARN` in `logback.xml` to silence it. + +| Property | Default | | +|---|---|---| +| `acp.client.request-timeout` | `60s` | | +| `acp.client.prompt-timeout` | none | a longer prompt turn fails and is cancelled | +| `acp.client.transport.type` | inferred | `stdio`, `websocket` or `http`, in any case. When it is unset, the transport is the only one whose command or URI is set; with several set, it is required | +| `acp.client.transport.stdio.command`, `.args`, `.env.*` | | the agent process; `env` keys keep their case | +| `acp.client.transport.websocket.uri`, `.connect-timeout` | `10s` | `ws://host:port/acp` | +| `acp.client.transport.http.uri` | | `http://host:port/acp` | +| `acp.client.capabilities.read-text-file`, `write-text-file`, `terminal`, `elicitation-form`, `elicitation-url`, `boolean-config-options` | `false` | advertise one only together with its handler, registered in a customizer | + +## Dependencies + +The Micronaut modules import the Micronaut 4.10 platform BOM. The SDK's Reactor, Jackson, Jetty +and JUnit versions take precedence over it. The JSON wire format is the SDK's own Jackson mapper, +not Micronaut Serialization. + +## Sample + +`acp-micronaut-sample` is a runnable agent built on this module. Its tests start it as a process +over stdio and HTTP. In `integration-testing/`, the `micronaut-typescript-*` scenarios run it +against the TypeScript SDK's client. diff --git a/acp-micronaut/pom.xml b/acp-micronaut/pom.xml new file mode 100644 index 00000000..dd071f17 --- /dev/null +++ b/acp-micronaut/pom.xml @@ -0,0 +1,208 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-micronaut + jar + + ACP Micronaut + Micronaut integration for ACP: serves an @AcpAgent bean over stdio, Streamable HTTP or WebSocket, and builds an ACP client from configuration + + + + 0.94 + 0.76 + + + + + + + io.projectreactor + reactor-bom + ${reactor.version} + pom + import + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + org.eclipse.jetty + jetty-bom + ${jetty.version} + pom + import + + + org.eclipse.jetty.ee10 + jetty-ee10-bom + ${jetty.version} + pom + import + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + + + + ch.qos.logback + logback-core + ${logback.version} + + + io.micronaut.platform + micronaut-platform + ${micronaut.platform.version} + pom + import + + + + + + + org.jspecify + jspecify + + + + com.agentclientprotocol + acp-integration + + + com.agentclientprotocol + acp-agent-support + ${project.version} + + + com.agentclientprotocol + acp-core + + + + com.agentclientprotocol + acp-streamable-http-jetty + true + + + + io.micronaut + micronaut-inject + + + io.micronaut + micronaut-context + + + jakarta.annotation + jakarta.annotation-api + + + + io.micronaut.test + micronaut-test-junit5 + test + + + com.agentclientprotocol + acp-test + test + + + org.junit.jupiter + junit-jupiter + test + + + io.projectreactor + reactor-test + test + + + ch.qos.logback + logback-classic + test + + + org.assertj + assertj-core + test + + + com.tngtech.archunit + archunit-junit5 + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + + + + default-compile + + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + + + + + + + + diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/AcpTransportTypeConverter.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/AcpTransportTypeConverter.java new file mode 100644 index 00000000..5f7816de --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/AcpTransportTypeConverter.java @@ -0,0 +1,31 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut; + +import java.util.Optional; + +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.micronaut.core.convert.ConversionContext; +import io.micronaut.core.convert.TypeConverter; +import jakarta.inject.Singleton; + +/** + * Binds {@code acp.agent.transport.type} and {@code acp.client.transport.type} by + * {@link AcpTransportType#parse(String)}: in any case, ignoring surrounding white space, as the + * Spring Boot and Quarkus integrations do. Micronaut's own enum conversion accepted only the + * constant's name or its lower-case form, so {@code WebSocket} failed to bind. + */ +@Singleton +final class AcpTransportTypeConverter implements TypeConverter { + + @Override + public Optional convert(CharSequence value, Class targetType, + ConversionContext context) { + // Thrown, not rejected: Micronaut skips a rejected value of a @Nullable property, and the + // client's type would then be inferred as if it were unset. + return Optional.of(AcpTransportType.parse(value.toString())); + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfiguration.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfiguration.java new file mode 100644 index 00000000..3350ecdc --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfiguration.java @@ -0,0 +1,672 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.convert.format.ReadableBytes; +import org.jspecify.annotations.Nullable; + +/** + * The {@code acp.agent.*} configuration properties: how the application's {@code @AcpAgent} bean is + * served. They choose the transport (stdio by default, or the SDK's Streamable HTTP listener), and + * set the agent's timeouts and the listener's port, path and limits. {@link AcpAgentRuntime} reads + * them when the application context starts. Every property has a default, so an application that + * declares an {@code @AcpAgent} bean and nothing else serves it over stdio. + * + *
    + * acp.agent.enabled                      true
    + * acp.agent.request-timeout              60s     agent-to-client requests
    + * acp.agent.cancel-grace-period          60s     after session/cancel, then answer "cancelled"
    + * acp.agent.max-prompt-duration          0s      none; else answer -32800 after it
    + * acp.agent.shutdown-on-transport-end    true    close the context when stdio input ends
    + * acp.agent.shutdown-timeout             10s     closing waits this long for a stdio agent
    + * acp.agent.transport.type               stdio   stdio | http | websocket
    + * acp.agent.transport.http.listener.host (unset) loopback only; 0.0.0.0 for every interface
    + * acp.agent.transport.http.listener.port 8080    0 for an ephemeral port
    + * acp.agent.transport.http.listener.max-concurrent-streams-per-connection   (unset: the SDK default)
    + * acp.agent.transport.http.allowed-origins (none) browser origins accepted besides loopback
    + * acp.agent.transport.http.path          /acp
    + * acp.agent.transport.http.max-post-body-size, keep-alive-interval, mailbox-capacity,
    + *     max-pending-sse-events, max-web-socket-pending-frames, max-provisional-sessions,
    + *     shutdown-timeout   (unset: the SDK defaults)
    + * 
    + * + *

    Durations take Micronaut's forms, such as {@code 30s} or {@code 500ms}; a size is a number of + * bytes or an amount such as {@code 4MB}. A value the SDK refuses, such as a negative duration, + * binds, then fails the startup when the agent is built. The properties map onto + * {@link AcpAgentSettings}, the framework-neutral settings the Spring Boot and Quarkus integrations + * fill from their own keys, so each one means the same there; the timeouts end up on the annotated + * agent's builder, {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport.Builder}. The + * module's README walks through an agent and its settings. + */ +@ConfigurationProperties(AcpAgentConfiguration.PREFIX) +public class AcpAgentConfiguration { + + /** The prefix of the agent's settings. */ + public static final String PREFIX = "acp.agent"; + + /** + * The default of {@code acp.agent.shutdown-timeout}, 10 seconds: as long as the SDK's own + * graceful close of an agent takes at most. + */ + public static final Duration DEFAULT_SHUTDOWN_TIMEOUT = Duration.ofSeconds(10); + + private boolean enabled = true; + + private Duration requestTimeout = Duration.ofSeconds(60); + + private Duration cancelGracePeriod = Duration.ofSeconds(60); + + private Duration maxPromptDuration = Duration.ZERO; + + private boolean shutdownOnTransportEnd = true; + + private Duration shutdownTimeout = DEFAULT_SHUTDOWN_TIMEOUT; + + private Transport transport = new Transport(); + + /** + * Returns whether the application's {@code @AcpAgent} bean is served + * ({@code acp.agent.enabled}). Default {@code true}. With {@code false} there is no + * {@link AcpAgentRuntime} bean, so no agent is started. Maps to + * {@link AcpAgentSettings#enabled()}. + * @return whether the agent is served + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets whether to serve the application's @AcpAgent bean; default true. + * @param enabled whether the agent is served + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns how long a request the agent sends to the client, such as a permission request or a + * file read, waits for its answer ({@code acp.agent.request-timeout}). Default 60 seconds, the + * SDK's default. Maps to {@code AcpAgentSupport.Builder.requestTimeout}. + * @return the request timeout + */ + public Duration getRequestTimeout() { + return requestTimeout; + } + + /** + * Sets how long a request the agent sends to the client waits for its answer; default 60 + * seconds. + * @param requestTimeout the request timeout + */ + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + /** + * Returns how long a {@code @Prompt} method has to return after the client sends + * {@code session/cancel} ({@code acp.agent.cancel-grace-period}). When it passes, the agent + * answers the prompt with stop reason {@code cancelled} itself and interrupts the method's + * thread. Default 60 seconds, the SDK's default; zero turns the limit off. Maps to + * {@code AcpAgentSupport.Builder.cancelGracePeriod}. + * @return the cancel grace period + */ + public Duration getCancelGracePeriod() { + return cancelGracePeriod; + } + + /** + * Sets how long a @Prompt method has to return after session/cancel; default 60 seconds, zero + * for no limit. + * @param cancelGracePeriod the cancel grace period; zero for none, not negative + */ + public void setCancelGracePeriod(Duration cancelGracePeriod) { + this.cancelGracePeriod = cancelGracePeriod; + } + + /** + * Returns how long a prompt turn may run before the agent answers it with error {@code -32800} + * (request cancelled) ({@code acp.agent.max-prompt-duration}). Default zero, the SDK's default: + * no limit. Maps to {@code AcpAgentSupport.Builder.maxPromptDuration}. + * @return the maximum prompt duration + */ + public Duration getMaxPromptDuration() { + return maxPromptDuration; + } + + /** + * Sets how long a prompt turn may run before the agent answers it with error -32800; default + * zero, no limit. + * @param maxPromptDuration the maximum prompt duration; zero for none, not negative + */ + public void setMaxPromptDuration(Duration maxPromptDuration) { + this.maxPromptDuration = maxPromptDuration; + } + + /** + * Returns whether the application context closes when the agent's transport ends on its own + * ({@code acp.agent.shutdown-on-transport-end}): for stdio, when the client closes the agent's + * standard input and every answer has been written. Default {@code true}, so the process exits + * with its client. It applies to a single transport (stdio, or an {@code AcpAgentTransport} + * bean of the application's own), not to the HTTP listener, which serves many clients. Maps to + * {@link AcpAgentSettings#shutdownOnTransportEnd()}. + * @return whether the context closes at transport end + */ + public boolean isShutdownOnTransportEnd() { + return shutdownOnTransportEnd; + } + + /** + * Sets whether to close the application context when the stdio transport ends on its own; + * default true. + * @param shutdownOnTransportEnd whether to close the context at transport end + */ + public void setShutdownOnTransportEnd(boolean shutdownOnTransportEnd) { + this.shutdownOnTransportEnd = shutdownOnTransportEnd; + } + + /** + * Returns how long closing the application context waits for a stdio agent to close + * gracefully, answering or cancelling what is in flight, before it closes the agent at once + * ({@code acp.agent.shutdown-timeout}). Default 10 seconds + * ({@link #DEFAULT_SHUTDOWN_TIMEOUT}), as long as the SDK's graceful close takes at most, so a + * longer value changes nothing. It applies to a single transport (stdio, or an + * {@code AcpAgentTransport} bean of the application's own); the HTTP listener has its own, + * {@code acp.agent.transport.http.shutdown-timeout}. Spring Boot and Quarkus have no such + * property: they wait at most 30 seconds. + * @return the shutdown timeout + */ + public Duration getShutdownTimeout() { + return shutdownTimeout; + } + + /** + * Sets how long closing waits for a stdio agent to close gracefully; default 10 seconds. + * @param shutdownTimeout the timeout; zero closes the agent at once + */ + public void setShutdownTimeout(Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + } + + /** + * Returns the transport properties, {@code acp.agent.transport.*}. + * @return the transport properties + */ + public Transport getTransport() { + return transport; + } + + /** + * Replaces the transport properties. + * @param transport the transport properties + */ + public void setTransport(Transport transport) { + this.transport = transport; + } + + /** + * Returns these properties as the SDK's framework-neutral {@link AcpAgentSettings}, which + * {@link AcpAgentRuntime} passes to {@code acp-integration}. A listener limit left unset stays + * unset ({@code null}), so the SDK applies its default. Each call builds new settings from the + * current values. + * @return the settings + */ + public AcpAgentSettings toSettings() { + Transport.Http http = transport.getHttp(); + return AcpAgentSettings.builder() + .enabled(enabled) + .requestTimeout(requestTimeout) + .cancelGracePeriod(cancelGracePeriod) + .maxPromptDuration(maxPromptDuration) + .shutdownOnTransportEnd(shutdownOnTransportEnd) + .transport(transport.getType()) + .path(http.getPath()) + .maxPostBodyBytes(http.getMaxPostBodySize()) + .keepAliveInterval(http.getKeepAliveInterval()) + .mailboxCapacity(http.getMailboxCapacity()) + .maxPendingSseEvents(http.getMaxPendingSseEvents()) + .maxWebSocketPendingFrames(http.getMaxWebSocketPendingFrames()) + .maxProvisionalSessions(http.getMaxProvisionalSessions()) + .shutdownTimeout(http.getShutdownTimeout()) + .webSocketIdleTimeout(http.getWebSocketIdleTimeout()) + .initializeTimeout(http.getInitializeTimeout()) + .listenerHost(http.getListener().getHost()) + .allowedOrigins(http.getAllowedOrigins()) + .listenerPort(http.getListener().getPort()) + .maxConcurrentStreamsPerConnection(http.getListener().getMaxConcurrentStreamsPerConnection()) + .build(); + } + + /** + * The {@code acp.agent.transport.*} properties: which transport serves the agent, and the + * Streamable HTTP listener's settings ({@link Http}). + */ + @ConfigurationProperties("transport") + public static class Transport { + + private AcpTransportType type = AcpTransportType.STDIO; + + private Http http = new Http(); + + /** + * Returns the transport ({@code acp.agent.transport.type}). Maps to + * {@link AcpAgentSettings#transport()}. + *

      + *
    • {@code stdio} (the default): the agent reads standard input and writes standard + * output. An application bean of type {@code AcpAgentTransport} is used instead of + * stdio.
    • + *
    • {@code http}: the SDK's Streamable HTTP listener, which also accepts WebSocket + * upgrades on its path. It needs {@code acp-streamable-http-jetty} on the classpath; + * without it the startup fails naming the module.
    • + *
    • {@code websocket}: the same listener as {@code http}.
    • + *
    + * The value is read in any case, such as {@code http} or {@code Http}. + * @return the transport type + */ + public AcpTransportType getType() { + return type; + } + + /** + * Sets the transport: stdio (the default), http or websocket. + * @param type the transport type + */ + public void setType(AcpTransportType type) { + this.type = type; + } + + /** + * Returns the Streamable HTTP listener's properties, {@code acp.agent.transport.http.*}. + * @return the HTTP properties + */ + public Http getHttp() { + return http; + } + + /** + * Replaces the Streamable HTTP listener's properties. + * @param http the HTTP properties + */ + public void setHttp(Http http) { + this.http = http; + } + + /** + * The {@code acp.agent.transport.http.*} properties: the Streamable HTTP listener that + * serves the agent when {@code acp.agent.transport.type} is {@code http} or + * {@code websocket}. It is the SDK's own Jetty listener on its own port, beside Micronaut's + * HTTP server if the application has one, not inside it. It serves HTTP/1.1, cleartext + * HTTP/2 and WebSocket upgrades on {@code path}, with one agent per connection. + * + *

    The limits bound every buffer the listener keeps for a client. Each one left unset + * keeps the SDK's default, which its getter names; together they map to + * {@code StreamableHttpAcpAgentTransportOptions}, the options of + * {@code acp-streamable-http-jetty}'s listener. + */ + @ConfigurationProperties("http") + public static class Http { + + private List allowedOrigins = new ArrayList<>(); + + private String path = AcpAgentSettings.DEFAULT_PATH; + + private @Nullable Long maxPostBodySize; + + private @Nullable Duration keepAliveInterval; + + private @Nullable Integer mailboxCapacity; + + private @Nullable Integer maxPendingSseEvents; + + private @Nullable Integer maxWebSocketPendingFrames; + + private @Nullable Integer maxProvisionalSessions; + + private @Nullable Duration shutdownTimeout; + + private @Nullable Duration webSocketIdleTimeout; + + private @Nullable Duration initializeTimeout; + + private Listener listener = new Listener(); + + /** + * Returns the browser origins the listener accepts besides the loopback ones + * ({@code acp.agent.transport.http.allowed-origins}), such as + * {@code https://app.example.com}, or {@code *} for any. A request without an + * {@code Origin} header, or from {@code http(s)://localhost}, {@code 127.0.0.1} or + * {@code [::1]} on any port, is always accepted; any other origin is answered 403, over + * HTTP and on the WebSocket handshake. Default empty. Maps to + * {@link AcpAgentSettings.Http#allowedOrigins()}. + * @return the origins + */ + public List getAllowedOrigins() { + return allowedOrigins; + } + + /** + * Sets the browser origins accepted besides the loopback ones; see + * {@link #getAllowedOrigins()}. + * @param allowedOrigins the origins + */ + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + /** + * Returns the endpoint's path ({@code acp.agent.transport.http.path}). Default + * {@value AcpAgentSettings#DEFAULT_PATH}. Maps to {@link AcpAgentSettings.Http#path()}. + * @return the path + */ + public String getPath() { + return path; + } + + /** + * Sets the endpoint path; default /acp. + * @param path the endpoint path + */ + public void setPath(String path) { + this.path = path; + } + + /** + * Returns the largest message accepted from a client, in bytes + * ({@code acp.agent.transport.http.max-post-body-size}). A larger POST body is answered + * 413, and a larger WebSocket text message closes the connection. Default: unset, which + * keeps the SDK's default of 16 MB. Maps to the transport option + * {@code maxPostBodyBytes}. + * @return the size, or {@code null} for the SDK's default + */ + public @Nullable Long getMaxPostBodySize() { + return maxPostBodySize; + } + + /** + * Sets the largest inbound message, a POST body or WebSocket text message, such as 4MB; + * unset keeps the SDK default, 16MB. + * @param maxPostBodySize the largest inbound message, in bytes or as an amount such as + * {@code 4MB}; positive + */ + public void setMaxPostBodySize(@ReadableBytes @Nullable Long maxPostBodySize) { + this.maxPostBodySize = maxPostBodySize; + } + + /** + * Returns the interval between keep-alive comments on open SSE streams, which stop + * proxies from cutting idle streams + * ({@code acp.agent.transport.http.keep-alive-interval}). Zero turns them off. Default: + * unset, which keeps the SDK's default of 15 seconds. Maps to the transport option + * {@code keepAliveInterval}. + * @return the interval, or {@code null} for the SDK's default + */ + public @Nullable Duration getKeepAliveInterval() { + return keepAliveInterval; + } + + /** + * Sets the interval between SSE keep-alive comments; zero disables them, unset keeps + * the SDK default, 15 seconds. + * @param keepAliveInterval the interval; zero for none, not negative + */ + public void setKeepAliveInterval(@Nullable Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + /** + * Returns how many events one SSE stream keeps unsent while no client is reading it, to + * send when the client reconnects ({@code acp.agent.transport.http.mailbox-capacity}). + * One more closes the connection. Default: unset, which keeps the SDK's default of + * 1024. Maps to the transport option {@code mailboxCapacity}. + * @return the capacity, or {@code null} for the SDK's default + */ + public @Nullable Integer getMailboxCapacity() { + return mailboxCapacity; + } + + /** + * Sets the events retained per outbound stream while no subscriber is attached; unset + * keeps the SDK default, 1024. + * @param mailboxCapacity the event count; positive + */ + public void setMailboxCapacity(@Nullable Integer mailboxCapacity) { + this.mailboxCapacity = mailboxCapacity; + } + + /** + * Returns how many events are queued for a client that is reading an SSE stream + * ({@code acp.agent.transport.http.max-pending-sse-events}). One more detaches that + * client and keeps the events for its next GET. Default: unset, which keeps the SDK's + * default of 1024. Maps to the transport option {@code maxPendingSseEvents}. + * @return the limit, or {@code null} for the SDK's default + */ + public @Nullable Integer getMaxPendingSseEvents() { + return maxPendingSseEvents; + } + + /** + * Sets the events queued for one attached SSE subscriber before it is detached; unset + * keeps the SDK default, 1024. + * @param maxPendingSseEvents the event count; positive + */ + public void setMaxPendingSseEvents(@Nullable Integer maxPendingSseEvents) { + this.maxPendingSseEvents = maxPendingSseEvents; + } + + /** + * Returns how many frames are queued for one WebSocket connection + * ({@code acp.agent.transport.http.max-web-socket-pending-frames}). One more closes the + * connection. Default: unset, which keeps the SDK's default of 1024. Maps to the + * transport option {@code maxWebSocketPendingFrames}. + * @return the limit, or {@code null} for the SDK's default + */ + public @Nullable Integer getMaxWebSocketPendingFrames() { + return maxWebSocketPendingFrames; + } + + /** + * Sets the frames queued for one WebSocket connection before it is closed; unset keeps + * the SDK default, 1024. + * @param maxWebSocketPendingFrames the frame count; positive + */ + public void setMaxWebSocketPendingFrames(@Nullable Integer maxWebSocketPendingFrames) { + this.maxWebSocketPendingFrames = maxWebSocketPendingFrames; + } + + /** + * Returns how many session streams a connection may open before the agent knows the + * session, as a client does before {@code session/load} + * ({@code acp.agent.transport.http.max-provisional-sessions}). A further one is + * refused. Default: unset, which keeps the SDK's default of 64. Maps to the transport + * option {@code maxProvisionalSessions}. + * @return the limit, or {@code null} for the SDK's default + */ + public @Nullable Integer getMaxProvisionalSessions() { + return maxProvisionalSessions; + } + + /** + * Sets the session streams a connection may open before the session is known; unset + * keeps the SDK default, 64. + * @param maxProvisionalSessions the stream count; positive + */ + public void setMaxProvisionalSessions(@Nullable Integer maxProvisionalSessions) { + this.maxProvisionalSessions = maxProvisionalSessions; + } + + /** + * Returns how long closing the listener waits for its connections' agents to close + * before it closes the rest at once + * ({@code acp.agent.transport.http.shutdown-timeout}). Default: unset, which keeps the + * SDK's default of 5 seconds. Closing the application context waits for the listener at + * most this long plus 5 seconds. Only the listener reads it: a stdio agent's close is + * bounded by {@code acp.agent.shutdown-timeout}. Maps to the transport option + * {@code shutdownTimeout}. + * @return the timeout, or {@code null} for the SDK's default + */ + public @Nullable Duration getShutdownTimeout() { + return shutdownTimeout; + } + + /** + * Sets how long closing the listener waits for its connections to close gracefully; + * unset keeps the SDK default, 5 seconds. + * @param shutdownTimeout the timeout; positive + */ + public void setShutdownTimeout(@Nullable Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + } + + /** + * Returns how long a WebSocket connection may pass no frame in either direction before + * the endpoint closes it with 1001 + * ({@code acp.agent.transport.http.web-socket-idle-timeout}). Default: unset, which + * keeps the SDK's default of 30 minutes. Maps to the transport option + * {@code webSocketIdleTimeout}. + * @return the timeout, or {@code null} for the SDK's default + */ + public @Nullable Duration getWebSocketIdleTimeout() { + return webSocketIdleTimeout; + } + + /** + * Sets how long a WebSocket connection may stay idle; unset keeps the SDK default, 30 + * minutes. + * @param webSocketIdleTimeout the timeout; positive + */ + public void setWebSocketIdleTimeout(@Nullable Duration webSocketIdleTimeout) { + this.webSocketIdleTimeout = webSocketIdleTimeout; + } + + /** + * Returns how long a connection has to complete {@code initialize}: a WebSocket that + * has not sent it is closed with 1008, and a POST {@code initialize} the agent has not + * answered is answered 500 ({@code acp.agent.transport.http.initialize-timeout}). + * Default: unset, which keeps the SDK's default of 30 seconds. Maps to the transport + * option {@code initializeTimeout}. + * @return the timeout, or {@code null} for the SDK's default + */ + public @Nullable Duration getInitializeTimeout() { + return initializeTimeout; + } + + /** + * Sets how long a connection has to complete {@code initialize}; unset keeps the SDK + * default, 30 seconds. + * @param initializeTimeout the timeout; positive + */ + public void setInitializeTimeout(@Nullable Duration initializeTimeout) { + this.initializeTimeout = initializeTimeout; + } + + /** + * Returns the SDK listener's own properties, {@code acp.agent.transport.http.listener.*}: + * the address and port it binds and its HTTP/2 stream limit. The other + * {@code acp.agent.transport.http.*} properties apply to whichever server serves the + * endpoint. + * @return the listener properties + */ + public Listener getListener() { + return listener; + } + + /** + * Replaces the listener's own properties. + * @param listener the listener properties + */ + public void setListener(Listener listener) { + this.listener = listener; + } + + /** + * The {@code acp.agent.transport.http.listener.*} properties: the SDK's own Jetty + * listener's address, port and HTTP/2 stream limit, the keys Spring Boot and + * {@code acp-integration} also keep under {@code listener}. + */ + @ConfigurationProperties("listener") + public static class Listener { + + private @Nullable String host; + + private int port = 8080; + + private @Nullable Integer maxConcurrentStreamsPerConnection; + + /** + * Returns the address the listener binds + * ({@code acp.agent.transport.http.listener.host}). Default: unset, which binds the + * loopback interface only ({@code 127.0.0.1}, and {@code ::1} where the machine has + * IPv6), so only programs on the same machine can connect. {@code 0.0.0.0} exposes the + * agent on every interface. The listener is the SDK's own server, not Micronaut's: + * Micronaut's security filters do not apply to it and it has no authentication of its + * own, so expose it only behind a proxy or firewall that controls who connects. Maps to + * {@link AcpAgentSettings.Listener#host()}. + * @return the host, or {@code null} for loopback only + */ + public @Nullable String getHost() { + return host; + } + + /** + * Sets the address the listener binds; see {@link #getHost()}. + * @param host a host name or address, or {@code null} for loopback only + */ + public void setHost(@Nullable String host) { + this.host = host; + } + + /** + * Returns the listener's port ({@code acp.agent.transport.http.listener.port}). + * Default 8080; {@code 0} picks a free port, which {@link AcpAgentRuntime#port()} then + * returns. Maps to {@link AcpAgentSettings.Listener#port()}. + * @return the port + */ + public int getPort() { + return port; + } + + /** + * Sets the listener's port; default 8080, 0 for an ephemeral port. + * @param port the listener's port; 0 for an ephemeral port + */ + public void setPort(int port) { + this.port = port; + } + + /** + * Returns how many HTTP/2 streams one client connection may hold open at once, each + * open SSE stream holding one + * ({@code acp.agent.transport.http.listener.max-concurrent-streams-per-connection}). + * Default: unset, which keeps the SDK's default of 1024. Maps to the transport option + * {@code maxConcurrentStreamsPerConnection}. + * @return the limit, or {@code null} for the SDK's default + */ + public @Nullable Integer getMaxConcurrentStreamsPerConnection() { + return maxConcurrentStreamsPerConnection; + } + + /** + * Sets the HTTP/2 streams one client connection may hold open; unset keeps the SDK + * default, 1024. + * @param maxConcurrentStreamsPerConnection the stream count; positive + */ + public void setMaxConcurrentStreamsPerConnection( + @Nullable Integer maxConcurrentStreamsPerConnection) { + this.maxConcurrentStreamsPerConnection = maxConcurrentStreamsPerConnection; + } + + } + + } + + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntime.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntime.java new file mode 100644 index 00000000..89304eb2 --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntime.java @@ -0,0 +1,345 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.time.Duration; +import java.util.List; +import java.util.OptionalInt; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.integration.AcpAgentHost; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpAgentTransports; +import com.agentclientprotocol.sdk.integration.AcpAgents; +import com.agentclientprotocol.sdk.integration.AcpHost; +import com.agentclientprotocol.sdk.integration.AcpListenerHost; +import com.agentclientprotocol.sdk.integration.AcpListeners; +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.BeanProvider; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.event.ApplicationEventListener; +import io.micronaut.context.event.StartupEvent; +import io.micronaut.core.util.StringUtils; +import io.micronaut.inject.BeanDefinition; +import io.micronaut.inject.qualifiers.Qualifiers; +import io.micronaut.runtime.EmbeddedApplication; +import io.micronaut.runtime.graceful.GracefulShutdownCapable; +import io.micronaut.scheduling.TaskExecutors; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Singleton; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Serves the application's {@code @AcpAgent} bean, from the application context's start to its + * close. Add {@code acp-micronaut}, and annotate the agent class {@code @Singleton} (or another + * bean-defining annotation) as well as {@code @AcpAgent}: it is a bean like any other, found from + * its compile-time bean definition with no classpath scan. Exactly one such bean is allowed; with + * none, as in a client-only application, nothing is started. The application rarely calls this + * class itself; inject it for {@link #port()} in a test. The module's README walks through an + * agent. + * + *

    The transport comes from {@link AcpAgentConfiguration}: + *

      + *
    • {@code stdio} (default): the agent reads standard input and writes standard output, which + * therefore must carry nothing else: send logging to standard error and turn off the banner + * ({@code Micronaut.build(args).banner(false)}). The SDK's threads are daemons, so the runtime + * holds the JVM open until the transport ends; when the client closes the agent's input and every + * answer is written, the runtime closes the application context + * ({@code acp.agent.shutdown-on-transport-end}), and the process exits. An application bean of type + * {@code AcpAgentTransport} replaces stdio, for tests among others.
    • + *
    • {@code http} or {@code websocket}: the SDK's Streamable HTTP listener from + * {@code acp-streamable-http-jetty}, on its own port, serving Streamable HTTP and WebSocket on one + * path, with one agent per connection invoking the same bean, whose handlers must therefore be + * thread-safe.
    • + *
    + * + *

    The agent's timeouts come from {@link AcpAgentConfiguration}. {@code AcpInterceptor}, + * {@code ArgumentResolver} and {@code ReturnValueHandler} beans are added to the agent, in their + * bean order. Handler methods run on Micronaut's virtual-thread executor + * ({@code TaskExecutors.VIRTUAL}) on JDK 21 and later, and so does the HTTP listener, which then + * creates no pool of its own; before, they run on the SDK's own pool and Jetty's. Handler methods + * may also return a {@code Mono}, a {@code CompletionStage} or a single-value Reactive Streams + * {@code Publisher}, which {@code AcpAgentSupport} waits for. + * + *

    Stopping: closing the context closes the agent gracefully, then at once: over stdio after at + * most {@code acp.agent.shutdown-timeout} (10 seconds by default), over HTTP after at most + * {@code acp.agent.transport.http.shutdown-timeout} (5 seconds by default) plus 5 seconds. With + * {@code micronaut.lifecycle.graceful-shutdown.enabled} the listener drains through Micronaut's + * graceful shutdown first. An application without an embedded server has no shutdown + * hook of Micronaut's, so the runtime registers one that closes the agent and the context on + * SIGTERM. With {@code acp.agent.enabled=false} this bean does not exist. + */ +@Singleton +@Requires(property = AcpAgentConfiguration.PREFIX + ".enabled", notEquals = StringUtils.FALSE) +public final class AcpAgentRuntime implements ApplicationEventListener, GracefulShutdownCapable { + + private static final Logger logger = LoggerFactory.getLogger(AcpAgentRuntime.class); + + /** Margin over the listener's own shutdown bound when closing waits for it. */ + private static final Duration CLOSE_MARGIN = Duration.ofSeconds(5); + + private final ApplicationContext context; + + private final AcpAgentConfiguration config; + + private final BeanProvider transportBean; + + private final List interceptors; + + private final List argumentResolvers; + + private final List returnValueHandlers; + + private final Object lock = new Object(); + + private @Nullable AcpHost host; + + private @Nullable Thread shutdownHook; + + private boolean started; + + private volatile boolean closing; + + AcpAgentRuntime(ApplicationContext context, AcpAgentConfiguration config, + BeanProvider transportBean, List interceptors, + List argumentResolvers, List returnValueHandlers) { + this.context = context; + this.config = config; + this.transportBean = transportBean; + this.interceptors = interceptors; + this.argumentResolvers = argumentResolvers; + this.returnValueHandlers = returnValueHandlers; + } + + /** + * Starts serving the agent ({@link #start()}) when Micronaut publishes the + * {@code StartupEvent}, once the application context has started. + * @param event the startup event + */ + @Override + public void onApplicationEvent(StartupEvent event) { + start(); + } + + /** + * Starts serving the {@code @AcpAgent} bean, once; the application context's start does this. + * Does nothing when the application has no such bean, when it has started already, or once + * closing has begun. Returns once the transport or the listener has started. Over a single + * transport (stdio) it also starts a non-daemon thread that keeps the JVM up until the + * transport ends, since the SDK's own threads are daemons. + * @throws IllegalStateException if the application has more than one {@code @AcpAgent} bean, or + * asks for HTTP without {@code acp-streamable-http-jetty} + */ + public void start() { + synchronized (lock) { + if (started || closing) { + return; + } + started = true; + AgentCandidate agent = findAgent(context); + if (agent == null) { + logger.debug("No @AcpAgent bean; no ACP agent is started"); + return; + } + AcpAgentSettings settings = config.toSettings(); + logger.info("Serving @AcpAgent bean {} over {}", agent.userClass().getName(), settings.transport()); + // Handlers block: they run on Micronaut's virtual-thread executor where the JVM has + // one, else on the SDK's own pool. The listener serves on the same executor. + AcpHost newHost = createHost(AcpAgents.builder(agent, settings, interceptors, argumentResolvers, + returnValueHandlers, virtualExecutor()), settings); + // Before the host serves anything, so a SIGTERM after the first answer is handled. + registerShutdownHook(); + try { + newHost.start(); + } + catch (RuntimeException ex) { + removeShutdownHook(); + throw ex; + } + newHost.port() + .ifPresent(port -> logger.info("ACP agent listening on port {}, path {}", port, settings.http().path())); + this.host = newHost; + if (!settings.servesHttp()) { + // The SDK's threads are daemons: hold the JVM while the single transport runs. + newHost.holdJvmUntilTermination(); + } + } + } + + /** + * Returns the port the HTTP listener is bound to, useful with + * {@code acp.agent.transport.http.listener.port=0}. + * @return the port, or empty over stdio, before the start and after the close + */ + public OptionalInt port() { + AcpHost current = currentHost(); + return (current != null) ? current.port() : OptionalInt.empty(); + } + + /** + * Returns whether an agent is being served. + * @return true from a successful start until the close or the graceful shutdown begins + */ + public boolean isRunning() { + return currentHost() != null && !closing; + } + + /** + * Stops the agent gracefully when Micronaut's graceful shutdown asks: in-flight requests are + * answered or cancelled and the transport or listener closes. Completes at once when no agent + * is served. + * @return completes when the agent has stopped + */ + @Override + public CompletionStage shutdownGracefully() { + AcpHost current = currentHost(); + if (current == null) { + return CompletableFuture.completedFuture(null); + } + closing = true; + return current.stopGracefully(); + } + + /** + * Closes the agent gracefully, then at once: over stdio after at most + * {@code acp.agent.shutdown-timeout} (10 seconds by default), over HTTP after at most + * {@code acp.agent.transport.http.shutdown-timeout} (5 seconds by default) plus 5 seconds. The + * application context's close does this. Later calls do nothing. + */ + @PreDestroy + public void close() { + AcpHost current; + synchronized (lock) { + closing = true; + current = this.host; + this.host = null; + } + removeShutdownHook(); + if (current != null) { + logger.info("Stopping the ACP agent"); + current.stop(closeTimeout()); + } + } + + private @Nullable AcpHost currentHost() { + synchronized (lock) { + return host; + } + } + + /** How long {@link #close()} waits for the graceful close, by transport. */ + Duration closeTimeout() { + if (!config.toSettings().servesHttp()) { + return config.getShutdownTimeout(); + } + Duration listenerTimeout = config.getTransport().getHttp().getShutdownTimeout(); + Duration bound = (listenerTimeout != null) ? listenerTimeout : Duration.ofSeconds(5); + return bound.plus(CLOSE_MARGIN); + } + + /** + * The single {@code @AcpAgent} bean, found from the annotation metadata the compiler wrote, + * or null when there is none. + * @throws IllegalStateException when there is more than one + */ + static @Nullable AgentCandidate findAgent(ApplicationContext context) { + List> candidates = context.getBeanDefinitions(Qualifiers.byStereotype(AcpAgent.class)) + .stream() + .>map(definition -> candidate(context, definition)) + .toList(); + return AcpAgentDiscovery.requireSingle(candidates, AcpAgentConfiguration.PREFIX + ".enabled").orElse(null); + } + + private static AgentCandidate candidate(ApplicationContext context, BeanDefinition definition) { + // The declared bean type, not the instance's class, carries the handler annotations. + return new AgentCandidate<>(definition.getName(), definition.getBeanType(), () -> context.getBean(definition)); + } + + /** + * Micronaut's virtual-thread executor ({@code TaskExecutors.VIRTUAL}), present on JDK 21 and + * later. Not the blocking executor on older JDKs: that is the I/O pool, whose non-daemon + * threads outlive a context closed at the end of stdio input, so the process would not exit. + */ + private @Nullable ExecutorService virtualExecutor() { + return context.findBean(ExecutorService.class, Qualifiers.byName(TaskExecutors.VIRTUAL)).orElse(null); + } + + private AcpHost createHost(AcpAgentSupport.Builder builder, AcpAgentSettings settings) { + if (!settings.servesHttp()) { + AcpAgentTransport transport = transportBean.isPresent() ? transportBean.get() : AcpAgentTransports.stdio(); + Runnable onTransportEnd = settings.shutdownOnTransportEnd() ? this::onTransportEnd : () -> { + }; + return new AcpAgentHost(builder.transport(transport).build(), onTransportEnd); + } + if (!AcpListeners.isListenerAvailable()) { + throw new IllegalStateException(AcpAgentConfiguration.PREFIX + ".transport.type=" + + settings.transport().value() + " needs com.agentclientprotocol:acp-streamable-http-jetty on the classpath"); + } + ExecutorService virtual = virtualExecutor(); + return new AcpListenerHost(AcpListeners.listener(settings, builder.buildFactory(), + (virtual != null) ? AcpTransportThreads.executor(virtual) : AcpTransportThreads.sdkDefault())); + } + + /** Closes the context on SIGTERM when no embedded server's hook of Micronaut's does. */ + private void registerShutdownHook() { + if (context.findBean(EmbeddedApplication.class).isEmpty()) { + Thread hook = new Thread(this::onSignal, "acp-agent-shutdown-hook"); + Runtime.getRuntime().addShutdownHook(hook); + this.shutdownHook = hook; + } + } + + private void onTransportEnd() { + if (closing) { + return; + } + logger.info("ACP agent transport ended; closing the application context"); + // On the host's thread, not the transport's: closing the context closes the transport. + closeContext(); + } + + /** + * SIGTERM: closes the agent itself first, since the context may still be starting (it + * counts as running only once every startup listener has returned), then the context. + */ + private void onSignal() { + close(); + closeContext(); + } + + private void closeContext() { + if (context.isRunning()) { + context.close(); + } + } + + private void removeShutdownHook() { + Thread hook = this.shutdownHook; + this.shutdownHook = null; + if (hook == null || hook.equals(Thread.currentThread())) { + return; + } + try { + Runtime.getRuntime().removeShutdownHook(hook); + } + catch (IllegalStateException ex) { + // The JVM is already shutting down; the hook runs or has run. + } + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/package-info.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/package-info.java new file mode 100644 index 00000000..98f85bd5 --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/agent/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Serves the application's {@code @AcpAgent} bean: {@link AcpAgentRuntime} starts it with the + * application context over the transport {@link AcpAgentConfiguration} names, and stops it + * with the context. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.micronaut.agent; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeans.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeans.java new file mode 100644 index 00000000..e91aa0fa --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeans.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.util.List; +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.integration.AcpClientTransports; +import com.agentclientprotocol.sdk.integration.AcpClients; +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Requires; +import io.micronaut.inject.qualifiers.Qualifiers; +import io.micronaut.scheduling.TaskExecutors; +import jakarta.inject.Singleton; + +/** + * Creates the application's ACP client from {@code acp.client.*} ({@link AcpClientConfiguration}) + * when a transport is named, that is when {@code acp.client.transport.type}, + * {@code .stdio.command}, {@code .websocket.uri} or {@code .http.uri} is set: its transport, an + * {@link AcpAsyncClient}, and an {@link AcpSyncClient} facade over that same client, so both share + * one transport connection. Inject either. An application that sets none of these four gets none + * of these beans, even with other client properties set, such as + * {@code acp.client.transport.websocket.connect-timeout}; Spring Boot and Quarkus decide the same + * way ({@link com.agentclientprotocol.sdk.integration.AcpClientSettings#hasTransport()}). + * + *

    The beans are singletons, created when first injected or looked up. Creating the client + * connects its transport (for stdio, starts the agent process); the application then calls + * {@code initialize()} and opens sessions. The client closes gracefully, once, with the application + * context, waiting at most its request timeout plus 10 seconds. + * + *

    The builder gets the configured timeouts and capabilities and a session-update handler that + * logs at DEBUG, then every {@link AcpClientCustomizer} bean in order; a session-update handler a + * customizer registers replaces the logging one. Replace the transport with an application bean + * annotated {@code @Replaces(bean = AcpClientTransport.class, factory = AcpClientBeans.class)}. + */ +@Factory +@Requires(condition = AcpClientTransportCondition.class) +public class AcpClientBeans { + + /** + * Creates the transport {@code acp.client.transport.*} describes, by the SDK's rule + * ({@link AcpClientTransports}): a {@code type} that is set wins; otherwise the one transport + * whose command or URI is set. Not connected yet; the client connects it. The WebSocket and + * HTTP transports run on Micronaut's virtual-thread executor ({@code TaskExecutors.VIRTUAL}) + * on JDK 21 and later, creating no pool of their own; before, on the SDK's own threads. + * @param config the client settings + * @param context the application context, which holds the virtual-thread executor + * @return the transport + * @throws IllegalStateException if the settings name no transport, several without a type, or a + * type without its command or URI + */ + @Singleton + public AcpClientTransport acpClientTransport(AcpClientConfiguration config, BeanContext context) { + AcpTransportThreads threads = context + .findBean(ExecutorService.class, Qualifiers.byName(TaskExecutors.VIRTUAL)) + .map(AcpTransportThreads::executor) + .orElse(AcpTransportThreads.sdkDefault()); + return AcpClientTransports.create(config.toSettings(), AcpClientConfiguration.PREFIX, threads) + .orElseThrow(() -> new IllegalStateException("An ACP client needs a transport: set " + + AcpClientConfiguration.PREFIX + ".transport.stdio.command, .websocket.uri or .http.uri")); + } + + /** + * Creates the client, once, over the transport, which this connects. Its capabilities and + * timeouts come from the configuration; then each customizer is applied to its builder. + * @param transport the client transport + * @param config the client settings + * @param customizers every customizer bean, in bean order + * @return the async client + * @throws IllegalStateException if a capability property is true but no customizer registers + * its handler; the message names the property + */ + @Singleton + public AcpAsyncClient acpAsyncClient(AcpClientTransport transport, AcpClientConfiguration config, + List customizers) { + return AcpClients.async(transport, config.toSettings(), customizers, AcpClientConfiguration.PREFIX); + } + + /** + * Creates the sync facade over the one async client. Building it from the transport instead + * would connect that transport a second time, which the SDK refuses. + * @param client the async client + * @return the sync client + */ + @Singleton + public AcpSyncClient acpSyncClient(AcpAsyncClient client) { + return AcpClients.sync(client); + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientCloser.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientCloser.java new file mode 100644 index 00000000..ffb583e4 --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientCloser.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientHost; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.event.BeanPreDestroyEvent; +import io.micronaut.context.event.BeanPreDestroyEventListener; +import jakarta.inject.Singleton; + +/** + * Closes the configured client gracefully, once, when the application context destroys it + * ({@link AcpClientHost}): pending notifications are delivered (bounded by the request + * timeout) and the transport is closed. The sync facade and the transport are not closed + * separately. + */ +@Singleton +@Requires(property = AcpClientConfiguration.PREFIX + ".transport") +final class AcpClientCloser implements BeanPreDestroyEventListener { + + private final AcpClientConfiguration config; + + AcpClientCloser(AcpClientConfiguration config) { + this.config = config; + } + + @Override + public AcpAsyncClient onPreDestroy(BeanPreDestroyEvent event) { + AcpAsyncClient client = event.getBean(); + new AcpClientHost(client).close(config.toSettings().closeTimeout()); + return client; + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientConfiguration.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientConfiguration.java new file mode 100644 index 00000000..03f040da --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientConfiguration.java @@ -0,0 +1,545 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.convert.format.MapFormat; +import io.micronaut.core.naming.conventions.StringConvention; +import org.jspecify.annotations.Nullable; + +/** + * The {@code acp.client.*} configuration properties: which agent the application's ACP client + * connects to, and how. They choose the transport (start an agent process over stdio, or connect to + * a WebSocket or Streamable HTTP endpoint), set the client's timeouts, and say which capabilities + * the client advertises. {@link AcpClientBeans} builds the client from them. + * + *

    Naming a transport is what creates the client beans: setting + * {@code acp.client.transport.type}, {@code .stdio.command}, {@code .websocket.uri} or + * {@code .http.uri}, as in Spring Boot and Quarkus. Another transport property alone, such as + * {@code .websocket.connect-timeout}, creates none. Then {@code .stdio.command}, + * {@code .websocket.uri} or {@code .http.uri} must name the agent (with + * {@code acp.client.transport.type} when more than one is set). The type binds in any case, such + * as {@code websocket} or {@code WebSocket}. + * + *

    + * acp.client.request-timeout                    60s
    + * acp.client.prompt-timeout                     none
    + * acp.client.transport.type                     stdio | websocket | http (else from what is set)
    + * acp.client.transport.stdio.command, args, env the agent process to start
    + * acp.client.transport.websocket.uri            ws://host:port/acp
    + * acp.client.transport.websocket.connect-timeout 10s
    + * acp.client.transport.http.uri                 http://host:port/acp
    + * acp.client.capabilities.read-text-file        false
    + * acp.client.capabilities.write-text-file       false
    + * acp.client.capabilities.terminal              false
    + * acp.client.capabilities.elicitation-form      false
    + * acp.client.capabilities.elicitation-url       false
    + * acp.client.capabilities.boolean-config-options false
    + * 
    + * + *

    Durations take Micronaut's forms, such as {@code 30s} or {@code 500ms}. The properties map + * onto {@link AcpClientSettings}, the framework-neutral settings the Spring Boot and Quarkus + * integrations fill from their own keys, so each one means the same there; the timeouts end up on + * the client's builder, {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec}. The + * module's README walks through a client. + */ +@ConfigurationProperties(AcpClientConfiguration.PREFIX) +public class AcpClientConfiguration { + + /** The prefix of the client's settings. */ + public static final String PREFIX = "acp.client"; + + private Duration requestTimeout = Duration.ofSeconds(60); + + private @Nullable Duration promptTimeout; + + private Transport transport = new Transport(); + + private Capabilities capabilities = new Capabilities(); + + /** + * Returns how long the client waits for the agent to answer a request other than a prompt + * ({@code acp.client.request-timeout}). Default 60 seconds, the SDK's default. Closing the + * client with the application context also waits at most this long plus 10 seconds before it + * closes at once. Maps to {@code AcpClient.AsyncSpec.requestTimeout}. + * @return the request timeout + */ + public Duration getRequestTimeout() { + return requestTimeout; + } + + /** + * Sets how long the client waits for the agent to answer a request; default 60 seconds. + * @param requestTimeout the request timeout + */ + public void setRequestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + /** + * Returns how long a prompt turn ({@code session/prompt}) may take + * ({@code acp.client.prompt-timeout}). When it passes, {@code prompt} fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}. Default: unset, no limit; zero also means no limit. The request + * timeout never applies to prompts. Maps to {@code AcpClient.AsyncSpec.promptTimeout}. + * @return the prompt timeout, or {@code null} for none + */ + public @Nullable Duration getPromptTimeout() { + return promptTimeout; + } + + /** + * Sets how long a prompt turn may take before the client cancels it; unset (the default) for no + * limit. + * @param promptTimeout the longest a turn may take, zero or {@code null} for no limit; not + * negative + */ + public void setPromptTimeout(@Nullable Duration promptTimeout) { + this.promptTimeout = promptTimeout; + } + + /** + * Returns the transport properties, {@code acp.client.transport.*}. + * @return the transport properties + */ + public Transport getTransport() { + return transport; + } + + /** + * Replaces the transport properties. + * @param transport the transport properties + */ + public void setTransport(Transport transport) { + this.transport = transport; + } + + /** + * Returns the advertised capabilities, {@code acp.client.capabilities.*}. + * @return the capabilities + */ + public Capabilities getCapabilities() { + return capabilities; + } + + /** + * Replaces the advertised capabilities. + * @param capabilities the capabilities + */ + public void setCapabilities(Capabilities capabilities) { + this.capabilities = capabilities; + } + + /** + * Returns these properties as the SDK's framework-neutral {@link AcpClientSettings}, which + * {@link AcpClientBeans} passes to {@code acp-integration}. An unset transport type stays unset + * ({@code null}), so the SDK infers it. Each call builds new settings from the current values. + * @return the settings + */ + public AcpClientSettings toSettings() { + Transport.Stdio stdio = transport.getStdio(); + return AcpClientSettings.builder() + .requestTimeout(requestTimeout) + .promptTimeout(promptTimeout) + .transport(transport.getType()) + .stdioCommand(stdio.getCommand()) + .stdioArgs(stdio.getArgs()) + .stdioEnv(stdio.getEnv()) + .websocketUri(transport.getWebsocket().getUri()) + .websocketConnectTimeout(transport.getWebsocket().getConnectTimeout()) + .httpUri(transport.getHttp().getUri()) + .capabilities(new AcpClientSettings.Capabilities(capabilities.isReadTextFile(), + capabilities.isWriteTextFile(), capabilities.isTerminal(), capabilities.isElicitationForm(), + capabilities.isElicitationUrl(), capabilities.isBooleanConfigOptions())) + .build(); + } + + /** + * The {@code acp.client.transport.*} properties: which transport the client uses, and each + * transport's own settings. Set the command or URI of one transport and it is chosen; set + * {@code type} as well only when more than one is set. + */ + @ConfigurationProperties("transport") + public static class Transport { + + private @Nullable AcpTransportType type; + + private Stdio stdio = new Stdio(); + + private WebSocket websocket = new WebSocket(); + + private Http http = new Http(); + + /** + * Returns the transport ({@code acp.client.transport.type}): {@code stdio}, + * {@code websocket} or {@code http}, in any case. Maps to + * {@link AcpClientSettings#transport()}. + * + *

    Unset (the default), the transport is the one whose {@code stdio.command}, + * {@code websocket.uri} or {@code http.uri} is set; with more than one set, creating the + * client fails naming them. A type that is set wins, and creating the client fails when its + * own command or URI is missing, naming that property. + * @return the type, or {@code null} to infer it + */ + public @Nullable AcpTransportType getType() { + return type; + } + + /** + * Sets the transport: stdio, websocket or http; unset, the one whose command or URI is set. + * @param type the transport type, or {@code null} to infer it + */ + public void setType(@Nullable AcpTransportType type) { + this.type = type; + } + + /** + * Returns the stdio properties, {@code acp.client.transport.stdio.*}. + * @return the stdio properties + */ + public Stdio getStdio() { + return stdio; + } + + /** + * Replaces the stdio properties. + * @param stdio the stdio properties + */ + public void setStdio(Stdio stdio) { + this.stdio = stdio; + } + + /** + * Returns the WebSocket properties, {@code acp.client.transport.websocket.*}. + * @return the WebSocket properties + */ + public WebSocket getWebsocket() { + return websocket; + } + + /** + * Replaces the WebSocket properties. + * @param websocket the WebSocket properties + */ + public void setWebsocket(WebSocket websocket) { + this.websocket = websocket; + } + + /** + * Returns the Streamable HTTP properties, {@code acp.client.transport.http.*}. + * @return the HTTP properties + */ + public Http getHttp() { + return http; + } + + /** + * Replaces the Streamable HTTP properties. + * @param http the HTTP properties + */ + public void setHttp(Http http) { + this.http = http; + } + + /** + * The {@code acp.client.transport.stdio.*} properties: the agent process the stdio client + * transport starts, then talks to over the process's standard input and output. + */ + @ConfigurationProperties("stdio") + public static class Stdio { + + private @Nullable String command; + + private List args = new ArrayList<>(); + + private Map env = new LinkedHashMap<>(); + + /** + * Returns the command that starts the agent process + * ({@code acp.client.transport.stdio.command}). Setting it selects the stdio transport, + * {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport}, which + * starts the process when the client bean is created. No default. Maps to + * {@link AcpClientSettings.Stdio#command()}. + * @return the command, or {@code null} when unset + */ + public @Nullable String getCommand() { + return command; + } + + /** + * Sets the command that starts the agent process, such as an executable on the PATH. + * @param command the command + */ + public void setCommand(@Nullable String command) { + this.command = command; + } + + /** + * Returns the command's arguments ({@code acp.client.transport.stdio.args}), in order. + * Default none. Maps to {@link AcpClientSettings.Stdio#args()}. + * @return the arguments; the list the binder fills + */ + public List getArgs() { + return args; + } + + /** + * Sets the arguments passed to the agent's command, in order. + * @param args the arguments + */ + public void setArgs(List args) { + this.args = args; + } + + /** + * Returns the environment variables added to the agent process + * ({@code acp.client.transport.stdio.env.*}); their names keep the case they are + * written in. The process inherits the application's whole environment, and these add + * to it or replace a variable of the same name. Default none. Maps to + * {@link AcpClientSettings.Stdio#env()}. + * @return the variables, by name + */ + public Map getEnv() { + return env; + } + + /** + * Sets environment variables added to the agent process's environment. + * @param env the variables, by name + */ + public void setEnv(@MapFormat(transformation = MapFormat.MapTransformation.FLAT, + keyFormat = StringConvention.RAW) Map env) { + this.env = env; + } + + } + + /** + * The {@code acp.client.transport.websocket.*} properties: the WebSocket client transport, + * for an agent that accepts WebSocket connections, such as the SDK's listener. + */ + @ConfigurationProperties("websocket") + public static class WebSocket { + + private @Nullable URI uri; + + private Duration connectTimeout = AcpClientSettings.DEFAULT_CONNECT_TIMEOUT; + + /** + * Returns the agent's endpoint ({@code acp.client.transport.websocket.uri}), such as + * {@code ws://localhost:8080/acp}. Setting it selects the WebSocket transport, + * {@link com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport}. No + * default. Maps to {@link AcpClientSettings.WebSocket#uri()}. + * @return the URI, or {@code null} when unset + */ + public @Nullable URI getUri() { + return uri; + } + + /** + * Sets the agent's WebSocket endpoint, such as ws://localhost:8080/acp. + * @param uri the agent's endpoint + */ + public void setUri(@Nullable URI uri) { + this.uri = uri; + } + + /** + * Returns how long connecting to the agent, the WebSocket handshake, may take + * ({@code acp.client.transport.websocket.connect-timeout}). Default 10 seconds + * ({@link AcpClientSettings#DEFAULT_CONNECT_TIMEOUT}), which replaces the transport's + * own default. Maps to {@code WebSocketAcpClientTransport.connectTimeout}. + * @return the connect timeout + */ + public Duration getConnectTimeout() { + return connectTimeout; + } + + /** + * Sets how long connecting to the agent over WebSocket may take; default 10 seconds. + * @param connectTimeout the connect timeout; positive + */ + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + } + + /** + * The {@code acp.client.transport.http.*} properties: the Streamable HTTP client transport, + * for an agent served over HTTP. + */ + @ConfigurationProperties("http") + public static class Http { + + private @Nullable URI uri; + + /** + * Returns the agent's endpoint ({@code acp.client.transport.http.uri}), such as + * {@code http://localhost:8080/acp}. Setting it selects the Streamable HTTP transport, + * {@code StreamableHttpAcpClientTransport}. No default. Maps to + * {@link AcpClientSettings.Http#uri()}. + * @return the URI, or {@code null} when unset + */ + public @Nullable URI getUri() { + return uri; + } + + /** + * Sets the agent's Streamable HTTP endpoint, such as http://localhost:8080/acp. + * @param uri the agent's endpoint + */ + public void setUri(@Nullable URI uri) { + this.uri = uri; + } + + } + + } + + /** + * The {@code acp.client.capabilities.*} properties: the capabilities the client advertises to + * the agent in {@code initialize}. Each is off by default: enable one together with the handler + * for it, registered through an {@link AcpClientCustomizer} bean. A capability turned on + * without its handler makes creating the client fail, naming the property. Together they map to + * {@link AcpClientSettings.Capabilities}. + */ + @ConfigurationProperties("capabilities") + public static class Capabilities { + + private boolean readTextFile; + + private boolean writeTextFile; + + private boolean terminal; + + private boolean elicitationForm; + + private boolean elicitationUrl; + + private boolean booleanConfigOptions; + + /** + * Returns whether the client advertises {@code fs/read_text_file} + * ({@code acp.client.capabilities.read-text-file}). Default {@code false}. Needs a + * read-file handler. Maps to {@link AcpClientSettings.Capabilities#readTextFile()}. + * @return whether advertised + */ + public boolean isReadTextFile() { + return readTextFile; + } + + /** + * Sets whether to advertise fs/read_text_file; default false. + * @param readTextFile whether to advertise {@code fs/read_text_file} + */ + public void setReadTextFile(boolean readTextFile) { + this.readTextFile = readTextFile; + } + + /** + * Returns whether the client advertises {@code fs/write_text_file} + * ({@code acp.client.capabilities.write-text-file}). Default {@code false}. Needs a + * write-file handler. Maps to {@link AcpClientSettings.Capabilities#writeTextFile()}. + * @return whether advertised + */ + public boolean isWriteTextFile() { + return writeTextFile; + } + + /** + * Sets whether to advertise fs/write_text_file; default false. + * @param writeTextFile whether to advertise {@code fs/write_text_file} + */ + public void setWriteTextFile(boolean writeTextFile) { + this.writeTextFile = writeTextFile; + } + + /** + * Returns whether the client advertises the {@code terminal/*} methods + * ({@code acp.client.capabilities.terminal}). Default {@code false}. Needs the terminal + * handlers. Maps to {@link AcpClientSettings.Capabilities#terminal()}. + * @return whether advertised + */ + public boolean isTerminal() { + return terminal; + } + + /** + * Sets whether to advertise the terminal methods; default false. + * @param terminal whether to advertise the terminal methods + */ + public void setTerminal(boolean terminal) { + this.terminal = terminal; + } + + /** + * Returns whether the client advertises form-mode {@code elicitation/create} + * ({@code acp.client.capabilities.elicitation-form}). Default {@code false}. Needs an + * elicitation handler. Maps to {@link AcpClientSettings.Capabilities#elicitationForm()}. + * @return whether advertised + */ + public boolean isElicitationForm() { + return elicitationForm; + } + + /** + * Sets whether to advertise form-mode elicitation; default false. + * @param elicitationForm whether to advertise {@code elicitation.form} + */ + public void setElicitationForm(boolean elicitationForm) { + this.elicitationForm = elicitationForm; + } + + /** + * Returns whether the client advertises URL-mode {@code elicitation/create} + * ({@code acp.client.capabilities.elicitation-url}). Default {@code false}. Needs an + * elicitation handler. Maps to {@link AcpClientSettings.Capabilities#elicitationUrl()}. + * @return whether advertised + */ + public boolean isElicitationUrl() { + return elicitationUrl; + } + + /** + * Sets whether to advertise URL-mode elicitation; default false. + * @param elicitationUrl whether to advertise {@code elicitation.url} + */ + public void setElicitationUrl(boolean elicitationUrl) { + this.elicitationUrl = elicitationUrl; + } + + /** + * Returns whether the client advertises that it accepts boolean session config options + * ({@code acp.client.capabilities.boolean-config-options}). Default {@code false}. Needs no + * handler. Maps to {@link AcpClientSettings.Capabilities#booleanConfigOptions()}. + * @return whether advertised + */ + public boolean isBooleanConfigOptions() { + return booleanConfigOptions; + } + + /** + * Sets whether to advertise that the client accepts boolean session config options; default + * false. + * @param booleanConfigOptions whether to advertise boolean session config options + */ + public void setBooleanConfigOptions(boolean booleanConfigOptions) { + this.booleanConfigOptions = booleanConfigOptions; + } + + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientTransportCondition.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientTransportCondition.java new file mode 100644 index 00000000..927530b5 --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientTransportCondition.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.util.List; + +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; + +/** + * Whether the application asks for a client: {@code acp.client.transport.type}, + * {@code .stdio.command}, {@code .websocket.uri} or {@code .http.uri} is set. The same test as + * {@link AcpClientSettings#hasTransport()} and the Spring Boot autoconfiguration's; another + * transport property alone, such as {@code .websocket.connect-timeout}, names no agent. + */ +final class AcpClientTransportCondition implements Condition { + + private static final List KEYS = List.of("type", "stdio.command", "websocket.uri", "http.uri"); + + @Override + public boolean matches(ConditionContext context) { + for (String key : KEYS) { + if (context.containsProperty(AcpClientConfiguration.PREFIX + ".transport." + key)) { + return true; + } + } + context.fail("No ACP client transport is set: " + AcpClientConfiguration.PREFIX + + ".transport.type, .stdio.command, .websocket.uri or .http.uri"); + return false; + } + +} diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/package-info.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/package-info.java new file mode 100644 index 00000000..8d3d9a4d --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/client/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Builds an ACP client from {@code acp.client.*}: {@link AcpClientBeans} provides the + * transport, an {@code AcpAsyncClient} customized by every + * {@link com.agentclientprotocol.sdk.integration.AcpClientCustomizer AcpClientCustomizer} bean, + * and an {@code AcpSyncClient} over it. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.micronaut.client; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/package-info.java b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/package-info.java new file mode 100644 index 00000000..884f4f66 --- /dev/null +++ b/acp-micronaut/src/main/java/com/agentclientprotocol/sdk/micronaut/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * What the {@code agent} and {@code client} packages share: binding {@code transport.type} in any + * case. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.micronaut; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/Eventually.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/Eventually.java new file mode 100644 index 00000000..c8ba1386 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/Eventually.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut; + +import java.time.Duration; + +/** Retries an assertion until it holds or the time is up. */ +public final class Eventually { + + private Eventually() { + } + + /** + * Runs the assertion until it passes, for at most {@code within}. + * @param within how long to keep trying + * @param assertion the assertion + */ + public static void eventually(Duration within, Runnable assertion) { + long deadline = System.nanoTime() + within.toNanos(); + while (true) { + try { + assertion.run(); + return; + } + catch (AssertionError ex) { + if (System.nanoTime() > deadline) { + throw ex; + } + } + try { + Thread.sleep(20); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted", ex); + } + } + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/MicronautArchitectureTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/MicronautArchitectureTest.java new file mode 100644 index 00000000..ce714254 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/MicronautArchitectureTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.json.JacksonAcpJsonMapper; +import com.agentclientprotocol.sdk.micronaut.agent.AcpAgentRuntime; +import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import org.junit.jupiter.api.Test; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-micronaut is a thin client of the SDK's public API, and the SDK never depends on + * Micronaut. + * + *
    + *        agent        client
    + *            \         /
    + *        micronaut (TransportType)
    + * 
    + */ +class MicronautArchitectureTest { + + private static final String MICRONAUT_MODULE = "com.agentclientprotocol.sdk.micronaut.."; + + /** This module's main classes (the bean definitions Micronaut generated included). */ + private static final JavaClasses MODULE = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importLocations(java.util.Set.of(location(AcpAgentRuntime.class))); + + /** The SDK modules this one builds on: annotations, core, JSON, annotation support, Jetty. */ + private static final JavaClasses SDK = new ClassFileImporter() + .importLocations(Stream.of(AcpAgent.class, AcpSchema.class, JacksonAcpJsonMapper.class, AcpAgentSupport.class, + StreamableHttpAcpAgentTransport.class) + .map(MicronautArchitectureTest::location) + .collect(java.util.stream.Collectors.toSet())); + + @Test + void importsThisModuleOnly() { + assertThat(MODULE.contain(AcpAgentRuntime.class)).isTrue(); + assertThat(MODULE).allMatch(type -> type.getPackageName().startsWith("com.agentclientprotocol.sdk.micronaut"), + "in this module's packages"); + assertThat(SDK.contain(AcpAgentSupport.class)).isTrue(); + } + + @Test + void theSdkNeverDependsOnMicronaut() { + noClasses().that() + .resideOutsideOfPackage(MICRONAUT_MODULE) + .should() + .dependOnClassesThat() + .resideInAnyPackage("io.micronaut..", MICRONAUT_MODULE) + .because("the SDK is framework-neutral: Micronaut is reached only from acp-micronaut") + .check(SDK); + } + + @Test + void noPackageCycles() { + slices().matching("com.agentclientprotocol.sdk.(**)").should().beFreeOfCycles().check(MODULE); + } + + @Test + void agentAndClientAreIndependent() { + noClasses().that() + .resideInAPackage("..micronaut.agent..") + .should() + .dependOnClassesThat() + .resideInAPackage("..micronaut.client..") + .check(MODULE); + noClasses().that() + .resideInAPackage("..micronaut.client..") + .should() + .dependOnClassesThat() + .resideInAPackage("..micronaut.agent..") + .check(MODULE); + } + + @Test + void usesOnlyThePublicSdkApi() { + DescribedPredicate internals = resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(resideInAPackage(MICRONAUT_MODULE))) + .and(belongToAnyOf(AcpAgentSession.class, AcpClientSession.class).or(not(modifier(JavaModifier.PUBLIC)))) + .as("SDK internals (session implementations, non-public classes)"); + noClasses().should() + .dependOnClassesThat(internals) + .because("the integration is a client of the SDK's public API, like any application") + .check(MODULE); + } + + /** The directory or jar a class was loaded from. */ + private static Location location(Class type) { + Path root = root(type); + try { + return Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile())); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static Path root(Class type) { + try { + return Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfigurationTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfigurationTest.java new file mode 100644 index 00000000..01407614 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentConfigurationTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.time.Duration; +import java.util.Map; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.micronaut.context.ApplicationContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Every {@code acp.agent.*} setting binds, and the defaults hold when none is set. */ +class AcpAgentConfigurationTest { + + @Test + void defaults() { + try (ApplicationContext context = ApplicationContext.run()) { + AcpAgentConfiguration config = context.getBean(AcpAgentConfiguration.class); + assertThat(config.isEnabled()).isTrue(); + assertThat(config.getRequestTimeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(config.getCancelGracePeriod()).isEqualTo(Duration.ofSeconds(60)); + assertThat(config.getMaxPromptDuration()).isEqualTo(Duration.ZERO); + assertThat(config.isShutdownOnTransportEnd()).isTrue(); + assertThat(config.getShutdownTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(config.getTransport().getType()).isEqualTo(AcpTransportType.STDIO); + AcpAgentConfiguration.Transport.Http http = config.getTransport().getHttp(); + assertThat(http.getListener().getPort()).isEqualTo(8080); + assertThat(http.getListener().getHost()).isNull(); + assertThat(http.getAllowedOrigins()).isEmpty(); + assertThat(http.getPath()).isEqualTo("/acp"); + assertThat(http.getMaxPostBodySize()).isNull(); + assertThat(http.getKeepAliveInterval()).isNull(); + assertThat(http.getMailboxCapacity()).isNull(); + assertThat(http.getMaxPendingSseEvents()).isNull(); + assertThat(http.getMaxWebSocketPendingFrames()).isNull(); + assertThat(http.getMaxProvisionalSessions()).isNull(); + assertThat(http.getListener().getMaxConcurrentStreamsPerConnection()).isNull(); + assertThat(http.getShutdownTimeout()).isNull(); + // unset limits keep the SDK's defaults + assertThat(config.toSettings().toOptions(true)).isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults()); + } + } + + @Test + void everySettingBinds() { + Map properties = Map.ofEntries(Map.entry("acp.agent.enabled", "false"), + Map.entry("acp.agent.request-timeout", "7s"), Map.entry("acp.agent.cancel-grace-period", "3s"), + Map.entry("acp.agent.max-prompt-duration", "2m"), + Map.entry("acp.agent.shutdown-on-transport-end", "false"), Map.entry("acp.agent.shutdown-timeout", "6s"), + Map.entry("acp.agent.transport.type", "http"), Map.entry("acp.agent.transport.http.listener.port", "9123"), + Map.entry("acp.agent.transport.http.path", "/agents/acp"), + Map.entry("acp.agent.transport.http.max-post-body-size", "2MB"), + Map.entry("acp.agent.transport.http.keep-alive-interval", "20s"), + Map.entry("acp.agent.transport.http.mailbox-capacity", "11"), + Map.entry("acp.agent.transport.http.max-pending-sse-events", "12"), + Map.entry("acp.agent.transport.http.max-web-socket-pending-frames", "13"), + Map.entry("acp.agent.transport.http.max-provisional-sessions", "14"), + Map.entry("acp.agent.transport.http.listener.max-concurrent-streams-per-connection", "15"), + Map.entry("acp.agent.transport.http.shutdown-timeout", "4s"), + Map.entry("acp.agent.transport.http.web-socket-idle-timeout", "45m"), + Map.entry("acp.agent.transport.http.initialize-timeout", "9s"), + Map.entry("acp.agent.transport.http.listener.host", "0.0.0.0"), + Map.entry("acp.agent.transport.http.allowed-origins", "https://a.example,https://b.example")); + try (ApplicationContext context = ApplicationContext.run(properties)) { + AcpAgentConfiguration config = context.getBean(AcpAgentConfiguration.class); + assertThat(config.isEnabled()).isFalse(); + assertThat(config.getRequestTimeout()).isEqualTo(Duration.ofSeconds(7)); + assertThat(config.getCancelGracePeriod()).isEqualTo(Duration.ofSeconds(3)); + assertThat(config.getMaxPromptDuration()).isEqualTo(Duration.ofMinutes(2)); + assertThat(config.isShutdownOnTransportEnd()).isFalse(); + assertThat(config.getShutdownTimeout()).isEqualTo(Duration.ofSeconds(6)); + assertThat(config.getTransport().getType()).isEqualTo(AcpTransportType.HTTP); + AcpAgentConfiguration.Transport.Http http = config.getTransport().getHttp(); + assertThat(http.getListener().getPort()).isEqualTo(9123); + assertThat(http.getPath()).isEqualTo("/agents/acp"); + + StreamableHttpAcpAgentTransportOptions options = config.toSettings().toOptions(true); + assertThat(options.maxPostBodyBytes()).isEqualTo(2L * 1024 * 1024); + assertThat(options.keepAliveInterval()).isEqualTo(Duration.ofSeconds(20)); + assertThat(options.mailboxCapacity()).isEqualTo(11); + assertThat(options.maxPendingSseEvents()).isEqualTo(12); + assertThat(options.maxWebSocketPendingFrames()).isEqualTo(13); + assertThat(options.maxProvisionalSessions()).isEqualTo(14); + assertThat(options.maxConcurrentStreamsPerConnection()).isEqualTo(15); + assertThat(options.shutdownTimeout()).isEqualTo(Duration.ofSeconds(4)); + assertThat(options.webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(45)); + assertThat(options.initializeTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(options.host()).isEqualTo("0.0.0.0"); + assertThat(options.allowedOrigins()).containsExactlyInAnyOrder("https://a.example", "https://b.example"); + } + } + + /** + * The listener's own keys sit under {@code acp.agent.transport.http.listener}, as in Spring Boot + * and {@code acp-integration}; the keys every host of the endpoint reads stay one level up. + */ + @Test + void listenerKeysSitUnderListener() { + Map properties = Map.of("acp.agent.enabled", "false", + "acp.agent.transport.http.listener.host", "0.0.0.0", "acp.agent.transport.http.listener.port", "9124", + "acp.agent.transport.http.listener.max-concurrent-streams-per-connection", "16"); + try (ApplicationContext context = ApplicationContext.run(properties)) { + AcpAgentSettings.Listener listener = context.getBean(AcpAgentConfiguration.class) + .toSettings() + .http() + .listener(); + assertThat(listener.host()).isEqualTo("0.0.0.0"); + assertThat(listener.port()).isEqualTo(9124); + assertThat(listener.maxConcurrentStreamsPerConnection()).isEqualTo(16); + } + } + + @Test + void transportTypeInLowerOrUpperCase() { + for (String value : new String[] { "stdio", "HTTP", "websocket", "WEBSOCKET" }) { + try (ApplicationContext context = ApplicationContext.run(Map.of("acp.agent.transport.type", value, + "acp.agent.enabled", "false"))) { + assertThat(context.getBean(AcpAgentConfiguration.class).getTransport().getType().name()) + .isEqualToIgnoringCase(value); + } + } + } + + /** The type binds in any case, as {@code AcpTransportType} reads it everywhere else. */ + @Test + void theTransportTypeBindsInAnyCase() { + for (Map.Entry value : Map + .of("WebSocket", AcpTransportType.WEBSOCKET, "Http", AcpTransportType.HTTP, " StdIO ", AcpTransportType.STDIO) + .entrySet()) { + try (ApplicationContext context = ApplicationContext + .run(Map.of("acp.agent.transport.type", value.getKey()))) { + assertThat(context.getBean(AcpAgentConfiguration.class).getTransport().getType()).as(value.getKey()) + .isEqualTo(value.getValue()); + } + } + assertThatThrownBy(() -> ApplicationContext.run(Map.of("acp.agent.transport.type", "pigeon"))) + .hasMessageContaining("Unknown ACP transport type 'pigeon': use stdio, websocket or http"); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentHttpTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentHttpTest.java new file mode 100644 index 00000000..27c0fc43 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentHttpTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.io.IOException; +import java.net.Socket; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.micronaut.client.TestCustomizers; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static com.agentclientprotocol.sdk.micronaut.Eventually.eventually; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The {@code @AcpAgent} bean served by the Streamable HTTP listener, and a client built by + * acp-micronaut from {@code acp.client.*} talking to it over Streamable HTTP and over + * WebSocket on the same endpoint. + */ +@MicronautTest +@Property(name = TestAgents.ECHO, value = "true") +@Property(name = "acp.agent.transport.type", value = "http") +@Property(name = "acp.agent.transport.http.listener.port", value = "0") +@Property(name = "acp.agent.transport.http.path", value = "/agent") +class AcpAgentHttpTest { + + @Inject + AcpAgentRuntime runtime; + + @Inject + TestAgents.EchoAgent agent; + + @Test + void aMicronautClientOverStreamableHttp() { + int port = runtime.port().orElseThrow(); + assertThat(port).isPositive(); + conversation(Map.of("acp.client.transport.http.uri", "http://localhost:" + port + "/agent", + "acp.client.capabilities.read-text-file", "true", "acp.client.capabilities.terminal", "true", + TestCustomizers.SERVES_FILES_AND_TERMINALS, "true")); + // the configured capabilities, as the agent negotiated them on the wire + NegotiatedCapabilities caps = agent.clientCapabilities.get(agent.clientCapabilities.size() - 1); + assertThat(caps.supportsReadTextFile()).isTrue(); + assertThat(caps.supportsWriteTextFile()).isFalse(); + assertThat(caps.supportsTerminal()).isTrue(); + } + + @Test + void aMicronautClientOverWebSocket() { + int port = runtime.port().orElseThrow(); + conversation(Map.of("acp.client.transport.websocket.uri", "ws://localhost:" + port + "/agent")); + } + + /** One client application: initialize, a session, a prompt, then its close. */ + private static void conversation(Map clientProperties) { + Map properties = new HashMap<>(clientProperties); + properties.put(TestCustomizers.RECORDING, "true"); + ApplicationContext client = ApplicationContext.run(properties); + TestCustomizers.Record record = client.getBean(TestCustomizers.Record.class); + AcpSyncClient acp = client.getBean(AcpSyncClient.class); + + assertThat(acp.initialize().protocolVersion()).isEqualTo(1); + String sessionId = acp.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = acp + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("over the wire")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + eventually(Duration.ofSeconds(5), + () -> assertThat(String.join("", record.chunks)).isEqualTo("echo: over the wire [fr-CA]")); + assertThat(record.customizers).containsExactly("first", "second"); + + client.close(); + assertThat(record.transportCloses).hasValue(1); + } + + @Test + void websocketTypeServesTheSameListenerAndGracefulShutdownClosesIt() { + ListAppender logs = new ListAppender<>(); + Logger root = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + logs.start(); + root.addAppender(logs); + try { + ApplicationContext agent = ApplicationContext.run(Map.of(TestAgents.ECHO, "true", + "acp.agent.transport.type", "websocket", "acp.agent.transport.http.listener.port", "0", + "acp.agent.transport.http.shutdown-timeout", "2s", + "micronaut.lifecycle.graceful-shutdown.enabled", "true")); + int port = agent.getBean(AcpAgentRuntime.class).port().orElseThrow(); + conversation(Map.of("acp.client.transport.type", "websocket", "acp.client.transport.websocket.uri", + "ws://localhost:" + port + "/acp")); + // a connection left open at shutdown + ApplicationContext openClient = ApplicationContext + .run(Map.of("acp.client.transport.http.uri", "http://localhost:" + port + "/acp")); + openClient.getBean(AcpSyncClient.class).initialize(); + + agent.close(); + + assertThatThrownBy(() -> new Socket("localhost", port).close()).isInstanceOf(IOException.class); + openClient.close(); + } + finally { + root.detachAppender(logs); + } + assertThat(logs.list).filteredOn(event -> event.getLevel().isGreaterOrEqual(Level.ERROR)).isEmpty(); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentLifecycleTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentLifecycleTest.java new file mode 100644 index 00000000..9bd70821 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentLifecycleTest.java @@ -0,0 +1,130 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import io.micronaut.context.ApplicationContext; +import org.junit.jupiter.api.Test; + +import static com.agentclientprotocol.sdk.micronaut.Eventually.eventually; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Starting and stopping with the application context: the transport's end closes the + * context, closing the context closes the agent, and the "exactly one agent" rule. + */ +class AcpAgentLifecycleTest { + + @Test + void theTransportEndingClosesTheApplicationContext() { + try (ApplicationContext context = ApplicationContext.run(TestAgents.echoInMemory())) { + InMemoryTransportPair pair = context.getBean(InMemoryTransportPair.class); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(Duration.ofSeconds(10)).build(); + client.initialize(); + + // What the end of standard input does for stdio: the agent transport ends by itself + pair.agentTransport().closeGracefully().block(Duration.ofSeconds(5)); + + eventually(Duration.ofSeconds(10), () -> assertThat(context.isRunning()).isFalse()); + eventually(Duration.ofSeconds(5), () -> assertThat(threadNamed("acp-agent-await")).isFalse()); + } + } + + @Test + void withShutdownOnTransportEndOffTheContextStaysUp() { + Map properties = new HashMap<>(TestAgents.echoInMemory()); + properties.put("acp.agent.shutdown-on-transport-end", "false"); + try (ApplicationContext context = ApplicationContext.run(properties)) { + InMemoryTransportPair pair = context.getBean(InMemoryTransportPair.class); + pair.agentTransport().closeGracefully().block(Duration.ofSeconds(5)); + eventually(Duration.ofSeconds(5), () -> assertThat(threadNamed("acp-agent-await")).isFalse()); + assertThat(context.isRunning()).isTrue(); + } + } + + @Test + void closingTheContextClosesTheAgentOnce() { + ApplicationContext context = ApplicationContext.run(TestAgents.echoInMemory()); + AcpAgentRuntime runtime = context.getBean(AcpAgentRuntime.class); + assertThat(runtime.isRunning()).isTrue(); + assertThat(threadNamed("acp-agent-await")).isTrue(); + + context.close(); + + assertThat(runtime.isRunning()).isFalse(); + runtime.close(); // a second close does nothing + runtime.start(); // nor does a start after the close + assertThat(runtime.isRunning()).isFalse(); + assertThat(runtime.shutdownGracefully().toCompletableFuture().isDone()).isTrue(); + eventually(Duration.ofSeconds(5), () -> assertThat(threadNamed("acp-agent-await")).isFalse()); + } + + @Test + void withoutAnAgentBeanNothingIsServed() { + try (ApplicationContext context = ApplicationContext.run(Map.of(TestAgents.IN_MEMORY, "true"))) { + AcpAgentRuntime runtime = context.getBean(AcpAgentRuntime.class); + assertThat(runtime.isRunning()).isFalse(); + assertThat(runtime.port()).isEmpty(); + assertThat(threadNamed("acp-agent-await")).isFalse(); + } + } + + @Test + void disabledLeavesNoRuntime() { + Map properties = new HashMap<>(TestAgents.echoInMemory()); + properties.put("acp.agent.enabled", "false"); + try (ApplicationContext context = ApplicationContext.run(properties)) { + assertThat(context.findBean(AcpAgentRuntime.class)).isEmpty(); + } + } + + @Test + void moreThanOneAgentBeanIsAnErrorNamingThem() { + Map properties = new HashMap<>(TestAgents.echoInMemory()); + properties.put(TestAgents.OTHER, "true"); + assertThatThrownBy(() -> ApplicationContext.run(properties)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Found 2 @AcpAgent beans") + .hasMessageContaining(TestAgents.EchoAgent.class.getName()) + .hasMessageContaining(TestAgents.OtherAgent.class.getName()); + } + + /** + * Closing a stdio (or application-transport) agent waits at most + * {@code acp.agent.shutdown-timeout}, 10 seconds by default, whatever the HTTP listener's + * {@code acp.agent.transport.http.shutdown-timeout} says; the listener's close keeps its own + * bound, that timeout plus 5 seconds. + */ + @Test + void theStdioCloseBoundIsItsOwnSetting() { + assertThat(closeTimeout(Map.of())).isEqualTo(Duration.ofSeconds(10)); + assertThat(closeTimeout(Map.of("acp.agent.transport.http.shutdown-timeout", "60s"))) + .isEqualTo(Duration.ofSeconds(10)); + assertThat(closeTimeout(Map.of("acp.agent.shutdown-timeout", "3s"))).isEqualTo(Duration.ofSeconds(3)); + assertThat(closeTimeout(Map.of("acp.agent.transport.type", "http", "acp.agent.shutdown-timeout", "3s", + "acp.agent.transport.http.shutdown-timeout", "2s"))) + .isEqualTo(Duration.ofSeconds(7)); + assertThat(closeTimeout(Map.of("acp.agent.transport.type", "http"))).isEqualTo(Duration.ofSeconds(10)); + } + + private static Duration closeTimeout(Map settings) { + Map properties = new HashMap<>(settings); + properties.put(TestAgents.IN_MEMORY, "true"); + try (ApplicationContext context = ApplicationContext.run(properties)) { + return context.getBean(AcpAgentRuntime.class).closeTimeout(); + } + } + + static boolean threadNamed(String name) { + return Thread.getAllStackTraces().keySet().stream().anyMatch(t -> t.isAlive() && t.getName().equals(name)); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntimeTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntimeTest.java new file mode 100644 index 00000000..ed343865 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpAgentRuntimeTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import io.micronaut.context.annotation.Property; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static com.agentclientprotocol.sdk.micronaut.Eventually.eventually; + +/** + * The {@code @AcpAgent} bean served over an application transport bean (here in memory, + * in place of stdio), driven by an SDK client: the bean is found, its injected dependency, + * interceptor and argument resolver beans take part, and Publisher, Flux and Mono returns + * are answered. + */ +@MicronautTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Property(name = TestAgents.ECHO, value = "true") +@Property(name = TestAgents.IN_MEMORY, value = "true") +class AcpAgentRuntimeTest { + + @Inject + InMemoryTransportPair pair; + + @Inject + AcpAgentRuntime runtime; + + @Inject + TestAgents.RecordingInterceptor interceptor; + + @Inject + TestAgents.EchoAgent agent; + + private final List chunks = new CopyOnWriteArrayList<>(); + + /** One client: the in-memory transport connects once, for the context's life. */ + private AcpSyncClient acp; + + private AcpSchema.InitializeResponse init; + + @BeforeAll + void connect() { + acp = AcpClient.sync(pair.clientTransport()) + .requestTimeout(Duration.ofSeconds(10)) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks.add(text.text()); + } + }) + .build(); + init = acp.initialize(); + } + + @Test + void servesTheAgentBeanWithItsDependenciesInterceptorsAndResolvers() { + assertThat(init.protocolVersion()).isEqualTo(1); + // Derived from the bean's annotations: agentInfo from @AcpAgent(name, version), and a + // capability for each declared handler that needs one (@CloseSession here, no @LoadSession). + assertThat(init.agentInfo()).isNotNull(); + assertThat(init.agentInfo().name()).isEqualTo("micronaut-test-agent"); + assertThat(init.agentInfo().version()).isEqualTo("1.2.3"); + assertThat(init.agentCapabilities().sessionCapabilities()).isNotNull(); + assertThat(init.agentCapabilities().sessionCapabilities().close()).isNotNull(); + assertThat(init.agentCapabilities().sessionCapabilities().list()).isNull(); + assertThat(init.agentCapabilities().loadSession()).isNotEqualTo(Boolean.TRUE); + + // @NewSession returns a Flux: a Publisher of one response + String sessionId = acp.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + assertThat(sessionId).isNotBlank(); + + AcpSchema.PromptResponse response = acp + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + eventually(Duration.ofSeconds(5), () -> assertThat(String.join("", chunks)) + // the injected Prefix bean, the prompt, and the LocaleResolver bean's argument + .isEqualTo("echo: hello [fr-CA]")); + + // @SetSessionMode returns a Mono + assertThat(acp.setSessionMode(new AcpSchema.SetSessionModeRequest(sessionId, "any"))).isNotNull(); + + // The derived initialize answer (no @Initialize declared) is intercepted like any handler. + assertThat(interceptor.methods).contains("initialize", "session/new", "session/prompt", "session/set_mode"); + assertThat(runtime.isRunning()).isTrue(); + assertThat(runtime.port()).isEmpty(); + + // The handler ran on Micronaut's virtual-thread executor where the JVM has one (JDK 21+), + // else on the SDK's own pool. + assertThat(agent.promptThreads).hasSize(1).allSatisfy(thread -> { + if (Runtime.version().feature() >= 21) { + assertThat(isVirtual(thread)).as("a virtual thread: %s", thread).isTrue(); + } + else { + assertThat(thread.getName()).startsWith("acp-agent-sync-handler"); + } + }); + } + + @Test + void anEmptyPublisherIsNoResponse() { + assertThatThrownBy(() -> acp.sendExtRequest("_test/empty", Map.of())).isInstanceOf(AcpError.class) + .hasMessageContaining("produced no response") + .satisfies(error -> assertThat(((AcpError) error).getCode()).isEqualTo(-32603)); + } + + static boolean isVirtual(Thread thread) { + try { + return (boolean) Thread.class.getMethod("isVirtual").invoke(thread); + } + catch (ReflectiveOperationException ex) { + return false; // JDK 17: no virtual threads + } + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpClientTransportThreadsTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpClientTransportThreadsTest.java new file mode 100644 index 00000000..bc38d63e --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpClientTransportThreadsTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The client's network transport runs on Micronaut's virtual-thread executor + * ({@code TaskExecutors.VIRTUAL}) where the JDK has virtual threads, and on the SDK's own threads + * where Micronaut has no such executor (JDK 17). The session-update handler of the async client + * runs on the thread that read the agent's message, so it shows where the transport's work runs: + * Micronaut names its virtual threads {@code virtual-executor-*}, the SDK names its own + * {@code acp-*}. + */ +class AcpClientTransportThreadsTest { + + static final String RECORD_THREADS = "test.client.record-threads"; + + @ParameterizedTest + @ValueSource(strings = { "http", "websocket" }) + void theTransportRunsOnMicronautsVirtualThreadsOnJdk21ElseOnTheSdksOwn(String type) { + try (ApplicationContext agent = ApplicationContext.run(Map.of(TestAgents.ECHO, "true", + "acp.agent.transport.type", type, "acp.agent.transport.http.listener.port", "0"))) { + int port = agent.getBean(AcpAgentRuntime.class).port().orElseThrow(); + String uri = (type.equals("http") ? "http" : "ws") + "://localhost:" + port + "/acp"; + try (ApplicationContext client = ApplicationContext.run(Map.of("acp.client.transport.type", type, + "acp.client.transport." + type + ".uri", uri, RECORD_THREADS, "true"))) { + AcpSyncClient acp = client.getBean(AcpSyncClient.class); + acp.initialize(); + String sessionId = acp.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + acp.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("hi")))); + List threads = client.getBean(ThreadRecorder.class).threads; + assertThat(threads).isNotEmpty().allSatisfy(thread -> { + assertThat(VirtualThreads.isVirtual(thread)).as("virtual: %s", thread) + .isEqualTo(VirtualThreads.isSupported()); + assertThat(thread.getName()).as(type) + .startsWith(VirtualThreads.isSupported() ? "virtual-executor" : "acp-"); + }); + } + } + } + + /** Records the threads the session-update handler runs on. */ + @Singleton + @Requires(property = RECORD_THREADS, value = "true") + static class ThreadRecorder implements AcpClientCustomizer { + + final List threads = new CopyOnWriteArrayList<>(); + + @Override + public void customize(AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(notification -> { + threads.add(Thread.currentThread()); + return Mono.empty(); + }); + } + + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpThreadsTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpThreadsTest.java new file mode 100644 index 00000000..3bfc31a8 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/AcpThreadsTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import io.micronaut.context.ApplicationContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * On JDK 21 the agent's handlers, its listener and the client's network transport all run on + * Micronaut's virtual-thread executor, so the SDK creates no pool of its own. + */ +class AcpThreadsTest { + + @ParameterizedTest + @ValueSource(strings = { "http", "websocket" }) + void onJdk21TheSdkCreatesNoPoolOfItsOwn(String type) { + assumeTrue(VirtualThreads.isSupported(), "virtual threads need JDK 21"); + Set before = Thread.getAllStackTraces().keySet(); + Set created = new HashSet<>(); + try (ApplicationContext agent = ApplicationContext.run(Map.of(TestAgents.ECHO, "true", + "acp.agent.transport.type", type, "acp.agent.transport.http.listener.port", "0"))) { + int port = agent.getBean(AcpAgentRuntime.class).port().orElseThrow(); + String uri = (type.equals("http") ? "http" : "ws") + "://localhost:" + port + "/acp"; + try (ApplicationContext client = ApplicationContext.run(Map.of("acp.client.transport.type", type, + "acp.client.transport." + type + ".uri", uri))) { + AcpSyncClient acp = client.getBean(AcpSyncClient.class); + acp.initialize(); + String sessionId = acp.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = acp + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("hi")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + created.addAll(newPlatformThreads(before)); + } + assertThat(agent.getBean(TestAgents.EchoAgent.class).promptThreads).singleElement() + .satisfies(thread -> assertThat(VirtualThreads.isVirtual(thread)).as("handler on %s", thread).isTrue()); + } + // What remains is not a pool of the SDK's: Jetty's VirtualThreadPool parks one platform + // thread while the listener runs, the JDK's HttpClient has one selector thread, and the + // SDK's one JVM-wide timer serves every timeout. + assertThat(created).allMatch(name -> name.equals("jetty-virtual-thread-pool-keepalive") + || name.equals("acp-timeout") || name.matches("HttpClient-\\d+-SelectorManager"), "allowed") + .contains("jetty-virtual-thread-pool-keepalive"); + assertThat(created.stream().filter(name -> name.startsWith("HttpClient-"))).hasSizeLessThanOrEqualTo(1); + } + + private static Set newPlatformThreads(Set before) { + return Thread.getAllStackTraces() + .keySet() + .stream() + .filter(thread -> !before.contains(thread)) + .map(Thread::getName) + .filter(name -> Stream.of("acp-", "qtp", "HttpClient-", "Scheduler-", "jetty-").anyMatch(name::startsWith)) + .collect(Collectors.toSet()); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/InterceptedAgentTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/InterceptedAgentTest.java new file mode 100644 index 00000000..459f0848 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/InterceptedAgentTest.java @@ -0,0 +1,146 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.LoadSession; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import io.micronaut.aop.Around; +import io.micronaut.aop.Intercepted; +import io.micronaut.aop.InterceptorBean; +import io.micronaut.aop.MethodInterceptor; +import io.micronaut.aop.MethodInvocationContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import static com.agentclientprotocol.sdk.micronaut.Eventually.eventually; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An {@code @AcpAgent} bean with Micronaut AOP advice: Micronaut generates an unannotated + * {@code $Intercepted} subclass for it. The agent is still found, discovered on its own class + * (agentInfo and derived capabilities from its annotations), and invoked through the proxy, so + * the advice runs on every handler call. + */ +@MicronautTest +@Property(name = InterceptedAgentTest.ENABLED, value = "true") +@Property(name = TestAgents.IN_MEMORY, value = "true") +class InterceptedAgentTest { + + static final String ENABLED = "test.agent.intercepted"; + + @Inject + InMemoryTransportPair pair; + + @Inject + InterceptedAgent agent; + + @Inject + CountingInterceptor advice; + + @Test + void aProxiedAgentBeanIsServedThroughItsProxy() { + assertThat(agent).as("the bean is a Micronaut AOP proxy").isInstanceOf(Intercepted.class); + assertThat(agent.getClass()).isNotEqualTo(InterceptedAgent.class); + + List chunks = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()) + .requestTimeout(Duration.ofSeconds(10)) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks.add(text.text()); + } + }) + .build(); + + AcpSchema.InitializeResponse init = client.initialize(); + assertThat(init.agentInfo()).isNotNull(); + assertThat(init.agentInfo().name()).as("the user class's name, not the proxy's") + .isEqualTo("intercepted-agent"); + assertThat(init.agentCapabilities().loadSession()).as("derived from @LoadSession on the user class") + .isTrue(); + + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("advised")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + eventually(Duration.ofSeconds(5), () -> assertThat(String.join("", chunks)).isEqualTo("advised")); + + assertThat(advice.calls).as("newSession and prompt ran through the proxy's advice") + .contains("newSession", "prompt"); + } + + /** Marks a bean whose public methods {@link CountingInterceptor} advises. */ + @Documented + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.TYPE, ElementType.METHOD }) + @Around + @interface Counted { + + } + + /** Records the name of every advised method call. */ + @Singleton + @InterceptorBean(Counted.class) + @Requires(property = ENABLED, value = "true") + static class CountingInterceptor implements MethodInterceptor { + + final List calls = new CopyOnWriteArrayList<>(); + + @Override + public Object intercept(MethodInvocationContext context) { + calls.add(context.getMethodName()); + return context.proceed(); + } + + } + + /** The advised agent. */ + @Singleton + @Counted + @AcpAgent(name = "intercepted-agent", version = "2.0") + @Requires(property = ENABLED, value = "true") + public static class InterceptedAgent { + + @NewSession + public AcpSchema.NewSessionResponse newSession(AcpSchema.NewSessionRequest request) { + return new AcpSchema.NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @LoadSession + public AcpSchema.LoadSessionResponse loadSession(AcpSchema.LoadSessionRequest request) { + return new AcpSchema.LoadSessionResponse(null); + } + + @Prompt + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + context.sendMessage(request.text()); + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/TestAgents.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/TestAgents.java new file mode 100644 index 00000000..7d33e5e9 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/agent/TestAgents.java @@ -0,0 +1,181 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.agent; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.CloseSession; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.SetSessionMode; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Beans for the agent tests, each switched on by its own property so that a test context + * holds exactly the agents it asks for. + */ +final class TestAgents { + + static final String ECHO = "test.agent.echo"; + + static final String OTHER = "test.agent.other"; + + static final String IN_MEMORY = "test.in-memory"; + + private TestAgents() { + } + + /** An ordinary bean the agent depends on. */ + @Singleton + static class Prefix { + + String value() { + return "echo: "; + } + + } + + /** + * The echo agent: a Micronaut singleton with an injected dependency, returning plain, + * Publisher (Flux) and Mono values, and taking a parameter only a custom resolver bean + * can supply. + */ + @Singleton + @AcpAgent(name = "micronaut-test-agent", version = "1.2.3") + @Requires(property = ECHO, value = "true") + static class EchoAgent { + + /** The client capabilities each prompt's connection negotiated. */ + final List clientCapabilities = new CopyOnWriteArrayList<>(); + + /** The threads the prompt handler ran on. */ + final List promptThreads = new CopyOnWriteArrayList<>(); + + private final Prefix prefix; + + EchoAgent(Prefix prefix) { + this.prefix = prefix; + } + + @NewSession + Publisher newSession(AcpSchema.NewSessionRequest request) { + return Flux.just(new AcpSchema.NewSessionResponse(UUID.randomUUID().toString(), null, null)); + } + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context, Locale locale, + NegotiatedCapabilities client) { + clientCapabilities.add(client); + promptThreads.add(Thread.currentThread()); + context.sendMessage(prefix.value()); + context.sendMessage(request.text() + " [" + locale.toLanguageTag() + "]"); + return AcpSchema.PromptResponse.endTurn(); + } + + @SetSessionMode + Mono setMode(AcpSchema.SetSessionModeRequest request) { + return Mono.just(new AcpSchema.SetSessionModeResponse()); + } + + /** Declared so that initialize advertises sessionCapabilities.close (devex derivation). */ + @CloseSession + AcpSchema.CloseSessionResponse close(AcpSchema.CloseSessionRequest request) { + return new AcpSchema.CloseSessionResponse(); + } + + @ExtRequest("_test/empty") + Publisher empty(Object params) { + return Flux.empty(); + } + + } + + /** A second agent, for the "exactly one" rule. */ + @Singleton + @AcpAgent + @Requires(property = OTHER, value = "true") + static class OtherAgent { + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request) { + return AcpSchema.PromptResponse.endTurn(); + } + + } + + /** Records the ACP methods it sees: interceptor beans are applied to the agent. */ + @Singleton + @Requires(property = ECHO, value = "true") + static class RecordingInterceptor implements AcpInterceptor { + + final List methods = new CopyOnWriteArrayList<>(); + + @Override + public boolean preInvoke(AcpInvocationContext context) { + methods.add(context.getAcpMethod()); + return true; + } + + } + + /** Supplies {@link Locale} parameters: argument resolver beans are applied to the agent. */ + @Singleton + @Requires(property = ECHO, value = "true") + static class LocaleResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return parameter.getParameterType() == Locale.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return Locale.CANADA_FRENCH; + } + + } + + /** An in-memory transport replacing stdio, with its client end for the test. */ + @Factory + @Requires(property = IN_MEMORY, value = "true") + static class InMemoryTransport { + + @Singleton + InMemoryTransportPair pair() { + return InMemoryTransportPair.create(); + } + + @Singleton + AcpAgentTransport agentTransport(InMemoryTransportPair pair) { + return pair.agentTransport(); + } + + } + + static Map echoInMemory() { + return Map.of(ECHO, "true", IN_MEMORY, "true"); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeansTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeansTest.java new file mode 100644 index 00000000..bc13dbb5 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/AcpClientBeansTest.java @@ -0,0 +1,203 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import io.micronaut.context.ApplicationContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Every {@code acp.client.*} setting binds; the transport is chosen by the documented rule; + * an application without client settings gets no client. + */ +class AcpClientBeansTest { + + @Test + void defaultsAndNoClientWithoutTransportSettings() { + try (ApplicationContext context = ApplicationContext.run()) { + AcpClientConfiguration config = context.getBean(AcpClientConfiguration.class); + assertThat(config.getRequestTimeout()).isEqualTo(Duration.ofSeconds(60)); + assertThat(config.getTransport().getType()).isNull(); + assertThat(config.getTransport().getStdio().getCommand()).isNull(); + assertThat(config.getTransport().getStdio().getArgs()).isEmpty(); + assertThat(config.getTransport().getStdio().getEnv()).isEmpty(); + assertThat(config.getTransport().getWebsocket().getUri()).isNull(); + assertThat(config.getTransport().getWebsocket().getConnectTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(config.getTransport().getHttp().getUri()).isNull(); + assertThat(config.getCapabilities().isReadTextFile()).isFalse(); + assertThat(config.getCapabilities().isWriteTextFile()).isFalse(); + assertThat(config.getCapabilities().isTerminal()).isFalse(); + + assertThat(context.findBean(AcpClientTransport.class)).isEmpty(); + assertThat(context.findBean(AcpAsyncClient.class)).isEmpty(); + assertThat(context.findBean(AcpSyncClient.class)).isEmpty(); + } + } + + @Test + void everySettingBinds() { + Map properties = Map.ofEntries(Map.entry("acp.client.request-timeout", "45s"), + Map.entry("acp.client.prompt-timeout", "10m"), + Map.entry("acp.client.transport.type", "stdio"), + Map.entry("acp.client.transport.stdio.command", "my-agent"), + Map.entry("acp.client.transport.stdio.args", List.of("--acp", "--verbose")), + Map.entry("acp.client.transport.stdio.env.AGENT_MODE", "test"), + Map.entry("acp.client.transport.websocket.uri", "ws://localhost:9/acp"), + Map.entry("acp.client.transport.websocket.connect-timeout", "3s"), + Map.entry("acp.client.transport.http.uri", "http://localhost:9/acp"), + Map.entry("acp.client.capabilities.read-text-file", "true"), + Map.entry("acp.client.capabilities.write-text-file", "true"), + Map.entry("acp.client.capabilities.terminal", "true")); + try (ApplicationContext context = ApplicationContext.run(properties)) { + AcpClientConfiguration config = context.getBean(AcpClientConfiguration.class); + assertThat(config.getRequestTimeout()).isEqualTo(Duration.ofSeconds(45)); + assertThat(config.getPromptTimeout()).isEqualTo(Duration.ofMinutes(10)); + assertThat(config.getTransport().getType()).isEqualTo(AcpTransportType.STDIO); + assertThat(config.getTransport().getStdio().getCommand()).isEqualTo("my-agent"); + assertThat(config.getTransport().getStdio().getArgs()).containsExactly("--acp", "--verbose"); + assertThat(config.getTransport().getStdio().getEnv()).containsEntry("AGENT_MODE", "test"); + assertThat(config.getTransport().getWebsocket().getUri()).isEqualTo(URI.create("ws://localhost:9/acp")); + assertThat(config.getTransport().getWebsocket().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(config.getTransport().getHttp().getUri()).isEqualTo(URI.create("http://localhost:9/acp")); + assertThat(config.getCapabilities().isReadTextFile()).isTrue(); + assertThat(config.getCapabilities().isWriteTextFile()).isTrue(); + assertThat(config.getCapabilities().isTerminal()).isTrue(); + + // the explicit type wins over the URIs also set + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(StdioAcpClientTransport.class); + } + } + + /** The capabilities Spring and Quarkus bind too, which this module used to hard-code false. */ + @Test + void elicitationAndBooleanConfigOptionCapabilitiesBind() { + try (ApplicationContext context = ApplicationContext.run()) { + assertThat(context.getBean(AcpClientConfiguration.class).toSettings().capabilities()) + .isEqualTo(AcpClientSettings.Capabilities.NONE); + } + Map properties = Map.of("acp.client.capabilities.elicitation-form", "true", + "acp.client.capabilities.elicitation-url", "true", + "acp.client.capabilities.boolean-config-options", "true"); + try (ApplicationContext context = ApplicationContext.run(properties)) { + assertThat(context.getBean(AcpClientConfiguration.class).toSettings().capabilities()) + .isEqualTo(new AcpClientSettings.Capabilities(false, false, false, true, true, true)); + } + try (ApplicationContext context = ApplicationContext + .run(Map.of("acp.client.capabilities.elicitation-url", "true"))) { + AcpClientSettings.Capabilities capabilities = context.getBean(AcpClientConfiguration.class) + .toSettings() + .capabilities(); + assertThat(capabilities.elicitationForm()).isFalse(); + assertThat(capabilities.elicitationUrl()).isTrue(); + assertThat(capabilities.booleanConfigOptions()).isFalse(); + } + } + + @Test + void theTransportIsInferredFromTheOneSetting() { + assertThat(transport(Map.of("acp.client.transport.stdio.command", "my-agent"))) + .isInstanceOf(StdioAcpClientTransport.class); + assertThat(transport(Map.of("acp.client.transport.websocket.uri", "ws://localhost:9/acp"))) + .isInstanceOf(WebSocketAcpClientTransport.class); + assertThat(transport(Map.of("acp.client.transport.http.uri", "http://localhost:9/acp"))) + .isInstanceOf(StreamableHttpAcpClientTransport.class); + assertThat(transport(Map.of("acp.client.transport.type", "HTTP", "acp.client.transport.http.uri", + "http://localhost:9/acp", "acp.client.transport.websocket.uri", "ws://localhost:9/acp"))) + .isInstanceOf(StreamableHttpAcpClientTransport.class); + } + + @Test + void ambiguousOrIncompleteTransportSettingsAreErrors() { + assertThatThrownBy(() -> transport(Map.of("acp.client.transport.http.uri", "http://localhost:9/acp", + "acp.client.transport.websocket.uri", "ws://localhost:9/acp"))) + .rootCause() + .hasMessageContaining("Several ACP client transports are configured [WEBSOCKET, HTTP]") + .hasMessageContaining("acp.client.transport.type"); + assertThatThrownBy(() -> transport(Map.of("acp.client.transport.type", "http"))).rootCause() + .hasMessageContaining("acp.client.transport.type=http requires acp.client.transport.http.uri"); + assertThatThrownBy(() -> transport(Map.of("acp.client.transport.type", "websocket"))).rootCause() + .hasMessageContaining("acp.client.transport.type=websocket requires acp.client.transport.websocket.uri"); + assertThatThrownBy(() -> transport(Map.of("acp.client.transport.type", "stdio"))).rootCause() + .hasMessageContaining("acp.client.transport.type=stdio requires acp.client.transport.stdio.command"); + } + + /** The type binds in any case, as {@code AcpTransportType} reads it everywhere else. */ + @Test + void theTransportTypeBindsInAnyCase() { + assertThat(transport(Map.of("acp.client.transport.type", "WebSocket", "acp.client.transport.websocket.uri", + "ws://localhost:9/acp", "acp.client.transport.http.uri", "http://localhost:9/acp"))) + .isInstanceOf(WebSocketAcpClientTransport.class); + assertThat(transport(Map.of("acp.client.transport.type", " Http ", "acp.client.transport.http.uri", + "http://localhost:9/acp"))) + .isInstanceOf(StreamableHttpAcpClientTransport.class); + assertThat(transport(Map.of("acp.client.transport.type", "StdIO", "acp.client.transport.stdio.command", + "my-agent"))) + .isInstanceOf(StdioAcpClientTransport.class); + assertThatThrownBy(() -> transport(Map.of("acp.client.transport.type", "pigeon", + "acp.client.transport.http.uri", "http://localhost:9/acp"))) + .rootCause() + .hasMessageContaining("Unknown ACP transport type 'pigeon': use stdio, websocket or http"); + } + + /** + * As in Spring Boot and {@link AcpClientSettings#hasTransport()}: only a type, a command or a + * URI asks for a client. Another transport setting alone, such as the WebSocket connect + * timeout, creates no client beans. + */ + @Test + void aTransportSettingThatNamesNoAgentCreatesNoClient() { + for (String key : List.of("acp.client.transport.websocket.connect-timeout", + "acp.client.transport.stdio.args", "acp.client.transport.stdio.env.AGENT_MODE")) { + try (ApplicationContext context = ApplicationContext.run(Map.of(key, "1s"))) { + assertThat(context.findBean(AcpClientTransport.class)).as(key).isEmpty(); + assertThat(context.findBean(AcpAsyncClient.class)).as(key).isEmpty(); + assertThat(context.findBean(AcpSyncClient.class)).as(key).isEmpty(); + } + } + } + + @Test + void oneAsyncClientWithASyncFacadeOverIt() { + try (ApplicationContext context = ApplicationContext + .run(Map.of("acp.client.transport.http.uri", "http://localhost:9/acp"))) { + AcpAsyncClient async = context.getBean(AcpAsyncClient.class); + assertThat(context.getBean(AcpAsyncClient.class)).isSameAs(async); + assertThat(context.getBean(AcpSyncClient.class)).isSameAs(context.getBean(AcpSyncClient.class)); + } + } + + @Test + void aCapabilitySettingWithoutItsHandlerFailsNamingTheSetting() { + assertThatThrownBy(() -> { + try (ApplicationContext context = ApplicationContext.run(Map.of("acp.client.transport.http.uri", + "http://localhost:9/acp", "acp.client.capabilities.write-text-file", "true"))) { + context.getBean(AcpAsyncClient.class); + } + }).hasStackTraceContaining("writeTextFileHandler") + .hasStackTraceContaining("The capabilities come from acp.client.capabilities.write-text-file=true:"); + } + + private static AcpClientTransport transport(Map properties) { + try (ApplicationContext context = ApplicationContext.run(properties)) { + return context.getBean(AcpClientTransport.class); + } + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/CountingTransport.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/CountingTransport.java new file mode 100644 index 00000000..b472771e --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/CountingTransport.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** A client transport that counts its graceful closes and delegates everything. */ +final class CountingTransport implements AcpClientTransport { + + private final AcpClientTransport delegate; + + private final AtomicInteger closes; + + CountingTransport(AcpClientTransport delegate, AtomicInteger closes) { + this.delegate = delegate; + this.closes = closes; + } + + @Override + public Mono connect(Function, Mono> handler) { + return delegate.connect(handler); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public Mono closeGracefully() { + closes.incrementAndGet(); + return delegate.closeGracefully(); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public void setExceptionHandler(Consumer handler) { + delegate.setExceptionHandler(handler); + } + + @Override + public Mono awaitTermination() { + return delegate.awaitTermination(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return delegate.unmarshalFrom(data, typeRef); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/DefaultSessionUpdateLoggingTest.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/DefaultSessionUpdateLoggingTest.java new file mode 100644 index 00000000..5aac79b9 --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/DefaultSessionUpdateLoggingTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The client bean logs session updates at DEBUG only while the application adds no update handler of + * its own: an application update handler replaces that default instead of running beside it. + */ +class DefaultSessionUpdateLoggingTest { + + private final Logger logger = (Logger) LoggerFactory.getLogger(com.agentclientprotocol.sdk.integration.AcpClients.class); + + private final ListAppender logged = new ListAppender<>(); + + private Level level; + + @BeforeEach + void captureDebugLogs() { + logged.start(); + level = logger.getLevel(); + logger.setLevel(Level.DEBUG); + logger.addAppender(logged); + } + + @AfterEach + void restoreLogging() { + logger.detachAppender(logged); + logger.setLevel(level); + } + + @Test + void withoutAnApplicationConsumerTheDefaultLogs() { + prompt(List.of()); + + assertThat(logged.list).anySatisfy( + event -> assertThat(event.getFormattedMessage()).startsWith("Session update for session-1")); + } + + @Test + void anApplicationConsumerReplacesTheDefault() { + List received = new CopyOnWriteArrayList<>(); + prompt(List.of(spec -> spec.sessionUpdateHandler(notification -> { + received.add(notification); + return Mono.empty(); + }))); + + assertThat(received).singleElement(); + assertThat(logged.list) + .noneSatisfy(event -> assertThat(event.getFormattedMessage()).startsWith("Session update for")); + } + + private static void prompt(List customizers) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null)) + .promptHandler((request, context) -> { + context.sendMessage("hello"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + agent.start(); + AcpSyncClient client = new AcpSyncClient( + new AcpClientBeans().acpAsyncClient(pair.clientTransport(), new AcpClientConfiguration(), customizers)); + try { + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())); + client.prompt(new AcpSchema.PromptRequest("session-1", List.of(new AcpSchema.TextContent("hi")))); + } + finally { + client.close(); + agent.closeGracefully(); + } + } + +} diff --git a/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/TestCustomizers.java b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/TestCustomizers.java new file mode 100644 index 00000000..ea5b260b --- /dev/null +++ b/acp-micronaut/src/test/java/com/agentclientprotocol/sdk/micronaut/client/TestCustomizers.java @@ -0,0 +1,134 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.micronaut.client; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.event.BeanCreatedEvent; +import io.micronaut.context.event.BeanCreatedEventListener; +import io.micronaut.core.annotation.Order; +import jakarta.inject.Singleton; +import reactor.core.publisher.Mono; + +/** Client-side beans for the tests, switched on by {@link #RECORDING}. */ +public final class TestCustomizers { + + /** Switches the beans below on. */ + public static final String RECORDING = "test.client.recording"; + + /** Switches {@link FilesAndTerminals} on. */ + public static final String SERVES_FILES_AND_TERMINALS = "test.client.serves-files-and-terminals"; + + private TestCustomizers() { + } + + /** The order customizers ran in, and the text chunks the client received. */ + @Singleton + @Requires(property = RECORDING, value = "true") + public static class Record { + + /** Customizer names, in the order they ran. */ + public final List customizers = new CopyOnWriteArrayList<>(); + + /** agent_message_chunk texts received. */ + public final List chunks = new CopyOnWriteArrayList<>(); + + /** How often the transport was closed gracefully. */ + public final AtomicInteger transportCloses = new AtomicInteger(); + + } + + /** Runs second, and adds the session-update handler that records chunks. */ + @Singleton + @Order(20) + @Requires(property = RECORDING, value = "true") + public static class Second implements AcpClientCustomizer { + + private final Record record; + + Second(Record record) { + this.record = record; + } + + @Override + public void customize(com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec spec) { + record.customizers.add("second"); + spec.sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + record.chunks.add(text.text()); + } + return Mono.empty(); + }); + } + + } + + /** Runs first. */ + @Singleton + @Order(10) + @Requires(property = RECORDING, value = "true") + public static class First implements AcpClientCustomizer { + + private final Record record; + + First(Record record) { + this.record = record; + } + + @Override + public void customize(com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec spec) { + record.customizers.add("first"); + } + + } + + /** + * Serves file reads and terminals, so a test may advertise them with + * {@code acp.client.capabilities.*}: a client that advertises a capability must have its + * handlers. The agents under test never call them. + */ + @Singleton + @Order(30) + @Requires(property = SERVES_FILES_AND_TERMINALS, value = "true") + public static class FilesAndTerminals implements AcpClientCustomizer { + + @Override + public void customize(com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec spec) { + spec.readTextFileHandler(request -> Mono.empty()) + .createTerminalHandler(request -> Mono.empty()) + .terminalOutputHandler(request -> Mono.empty()) + .releaseTerminalHandler(request -> Mono.empty()) + .waitForTerminalExitHandler(request -> Mono.empty()) + .killTerminalHandler(request -> Mono.empty()); + } + + } + + /** Wraps the configured transport to count its graceful closes. */ + @Singleton + @Requires(property = RECORDING, value = "true") + public static class CountCloses implements BeanCreatedEventListener { + + private final Record record; + + CountCloses(Record record) { + this.record = record; + } + + @Override + public AcpClientTransport onCreated(BeanCreatedEvent event) { + return new CountingTransport(event.getBean(), record.transportCloses); + } + + } + +} diff --git a/acp-micronaut/src/test/resources/logback-test.xml b/acp-micronaut/src/test/resources/logback-test.xml new file mode 100644 index 00000000..3fff55a1 --- /dev/null +++ b/acp-micronaut/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/acp-quarkus-deployment/pom.xml b/acp-quarkus-deployment/pom.xml new file mode 100644 index 00000000..79622d7e --- /dev/null +++ b/acp-quarkus-deployment/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-quarkus-deployment + ACP Quarkus - Deployment + Build-time half of the ACP Quarkus extension: finds the @AcpAgent bean in the index and wires its transport + + + + 0.96 + 0.90 + + + + + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-quarkus + ${project.version} + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-vertx-http-deployment + + + + io.quarkus + quarkus-junit-internal + test + + + + io.quarkus + quarkus-elytron-security-properties-file + test + + + com.agentclientprotocol + acp-test + ${project.version} + test + + + org.assertj + assertj-core + test + + + com.tngtech.archunit + archunit-junit5 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + + 0 + + + + + + + diff --git a/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpAgentBuildItem.java b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpAgentBuildItem.java new file mode 100644 index 00000000..226ce3d3 --- /dev/null +++ b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpAgentBuildItem.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; +import org.jboss.jandex.DotName; + +/** + * The application's one {@code @AcpAgent} class, as the extension's build steps pass it between + * them: present only when there is one and {@code quarkus.acp.agent.enabled} is true. Its presence + * makes the build add the agent host for the configured transport; without it the application is a + * client only. A second {@code @AcpAgent} class fails the build, naming both. An application does + * not use it. + * + * @author Mark Pollack + */ +public final class AcpAgentBuildItem extends SimpleBuildItem { + + private final DotName agentClass; + + AcpAgentBuildItem(DotName agentClass) { + this.agentClass = agentClass; + } + + /** + * Returns the name of the annotated class. + * @return the class name + */ + public DotName agentClass() { + return agentClass; + } + +} diff --git a/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpProcessor.java b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpProcessor.java new file mode 100644 index 00000000..bce71567 --- /dev/null +++ b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/AcpProcessor.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.util.List; +import java.util.Optional; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpAgentAssembly; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpAgentClass; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpClientProducers; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpExecutors; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpHttpAgentHost; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpHttpConfigBuilder; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpRecorder; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioAgentHost; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioConfigBuilder; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioTransportProducer; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpVertxHost; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanDefiningAnnotationBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.arc.processor.BuiltinScope; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.RunTimeConfigBuilderBuildItem; +import io.quarkus.deployment.builditem.StaticInitConfigBuilderBuildItem; +import jakarta.inject.Singleton; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.DotName; + +/** + * Build steps of the ACP extension. The {@code @AcpAgent} class is found in the index + * here, at build time: it becomes a singleton bean (no client proxy), and a second one + * fails the build with both names. With one, the agent host for the configured + * transport is added; with none, the application is a client only. + * + * @author Mark Pollack + */ +class AcpProcessor { + + static final String FEATURE = "acp"; + + static final DotName ACP_AGENT = DotName.createSimple(AcpAgent.class); + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + /** + * The client beans, each created only when the application injects it, and the executor + * they and the agent run on. + */ + @BuildStep + void clientBeans(BuildProducer beans) { + beans.produce(new AdditionalBeanBuildItem(AcpClientProducers.class)); + // The agent looks it up when it builds a handler chain, which Arc does not see. + beans.produce(AdditionalBeanBuildItem.unremovableOf(AcpExecutors.class)); + } + + /** {@code @AcpAgent} alone makes a class a bean, a singleton unless it declares a scope. */ + @BuildStep + BeanDefiningAnnotationBuildItem agentIsBean() { + return new BeanDefiningAnnotationBuildItem(ACP_AGENT, BuiltinScope.SINGLETON.getName()); + } + + @BuildStep + void findAgent(CombinedIndexBuildItem index, AcpBuildTimeConfig config, BuildProducer agent) { + if (!config.agent().enabled()) { + return; + } + List agents = index.getIndex() + .getAnnotations(ACP_AGENT) + .stream() + .map(AnnotationInstance::target) + .filter(target -> target.kind() == AnnotationTarget.Kind.CLASS) + .map(target -> target.asClass().name().toString()) + .toList(); + AcpAgentDiscovery.requireSingle(agents, "quarkus.acp.agent.enabled") + .ifPresent(name -> agent.produce(new AcpAgentBuildItem(DotName.createSimple(name)))); + } + + /** The {@code @AcpAgent} class as a bean the runtime reads, and the agent bean kept. */ + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void agentClass(Optional agent, AcpRecorder recorder, + BuildProducer syntheticBeans, BuildProducer unremovable) { + if (agent.isEmpty()) { + return; + } + String agentClass = agent.get().agentClass().toString(); + syntheticBeans.produce(SyntheticBeanBuildItem.configure(AcpAgentClass.class) + .scope(Singleton.class) + .supplier(recorder.agentClass(agentClass)) + .done()); + unremovable.produce(UnremovableBeanBuildItem.beanClassNames(agentClass)); + } + + /** The host for the configured transport, and the Quarkus defaults it needs. */ + @BuildStep + void agentHost(Optional agent, AcpBuildTimeConfig config, + BuildProducer beans, + BuildProducer staticInitConfig, + BuildProducer runTimeConfig) { + if (agent.isEmpty()) { + return; + } + beans.produce(AdditionalBeanBuildItem.unremovableOf(AcpAgentAssembly.class)); + if (config.agent().transport().type() == AcpTransportType.STDIO) { + beans.produce(AdditionalBeanBuildItem.unremovableOf(AcpStdioAgentHost.class)); + beans.produce(AdditionalBeanBuildItem.unremovableOf(AcpStdioTransportProducer.class)); + // Standard output carries the protocol: nothing else may write there, and a + // stdio agent listens on no port. + staticInitConfig.produce(new StaticInitConfigBuilderBuildItem(AcpStdioConfigBuilder.class)); + runTimeConfig.produce(new RunTimeConfigBuilderBuildItem(AcpStdioConfigBuilder.class)); + } + else { + beans.produce(AdditionalBeanBuildItem.builder() + .addBeanClasses(AcpVertxHost.class, AcpHttpAgentHost.class) + .setUnremovable() + .build()); + runTimeConfig.produce(new RunTimeConfigBuilderBuildItem(AcpHttpConfigBuilder.class)); + } + } + +} diff --git a/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/package-info.java b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/package-info.java new file mode 100644 index 00000000..e26945af --- /dev/null +++ b/acp-quarkus-deployment/src/main/java/com/agentclientprotocol/sdk/quarkus/deployment/package-info.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The build steps of the ACP Quarkus extension. + */ +@NullMarked +package com.agentclientprotocol.sdk.quarkus.deployment; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentBeanTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentBeanTest.java new file mode 100644 index 00000000..3b7f04bd --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentBeanTest.java @@ -0,0 +1,160 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioAgentHost; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import io.quarkus.arc.Arc; +import io.quarkus.test.QuarkusUnitTest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An {@code @AcpAgent} class with no scope annotation becomes a singleton bean with its + * collaborators injected, and its handlers are served with the application's interceptor + * and argument resolver beans, over the agent transport bean. + */ +class AgentBeanTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(EchoAgent.class, Shouter.class, CountingInterceptor.class, + SuffixResolver.class, Suffix.class, InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false"); + + @Inject + InMemoryAgentTransport transport; + + @Inject + CountingInterceptor interceptor; + + @Inject + AcpStdioAgentHost host; + + @Test + void agentClassIsASingletonBeanWithoutAClientProxy() { + var bean = Arc.container().instance(EchoAgent.class).getBean(); + assertThat(bean.getScope()).isEqualTo(Singleton.class); + assertThat(Arc.container().instance(EchoAgent.class).get().getClass()).isEqualTo(EchoAgent.class); + assertThat(host.agent()).isNotNull(); + } + + @Test + void stdioKeepsStandardOutputForTheProtocol() { + var config = ConfigProvider.getConfig(); + assertThat(config.getValue("quarkus.log.console.stderr", Boolean.class)).isTrue(); + assertThat(config.getValue("quarkus.banner.enabled", Boolean.class)).isFalse(); + assertThat(config.getValue("quarkus.http.host-enabled", Boolean.class)).isFalse(); + } + + @Test + void promptIsServedWithInjectedCollaboratorsInterceptorsAndResolvers() { + AcpSyncClient client = transport.client(); + AcpSchema.InitializeResponse initialized = client.initialize(); + // Derived from the annotations: agentInfo from @AcpAgent, no capability beyond the + // baseline (the agent declares only @Prompt). + assertThat(initialized.protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + assertThat(initialized.agentInfo()).isNotNull(); + assertThat(initialized.agentInfo().name()).isEqualTo("echo"); + assertThat(initialized.agentCapabilities().loadSession()).isFalse(); + + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + AcpSchema.PromptResponse response = client.prompt(InMemoryAgentTransport.prompt(sessionId, "hello")); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(transport.messages).containsExactly("HELLO!"); + // The derived initialize and the default session/new run through the interceptor beans too. + assertThat(interceptor.methods).containsExactly("initialize", "session/new", "session/prompt"); + // quarkus.acp.handler-executor=managed, the default: the handler ran on Quarkus' worker pool + // through the ManagedExecutor on every JDK; never on the SDK's own pool. + assertThat(Arc.container().instance(EchoAgent.class).get().threads).singleElement().satisfies(thread -> { + assertThat(VirtualThreads.isVirtual(thread)).as("virtual: %s", thread).isFalse(); + assertThat(thread.getName()).startsWith("executor-thread-"); + }); + } + + @AcpAgent(name = "echo") + public static class EchoAgent { + + private final Shouter shouter; + + /** The threads the prompt handler ran on. */ + final List threads = new CopyOnWriteArrayList<>(); + + @Inject + EchoAgent(Shouter shouter) { + this.shouter = shouter; + } + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context, Suffix suffix) { + threads.add(Thread.currentThread()); + AcpSchema.TextContent text = (AcpSchema.TextContent) request.prompt().get(0); + context.sendMessage(shouter.shout(text.text()) + suffix.value()); + return AcpSchema.PromptResponse.endTurn(); + } + + } + + @ApplicationScoped + public static class Shouter { + + String shout(String text) { + return text.toUpperCase(java.util.Locale.ROOT); + } + + } + + /** A value the agent's handler declares, resolved by an application resolver bean. */ + public record Suffix(String value) { + } + + @Singleton + public static class SuffixResolver implements ArgumentResolver { + + @Override + public boolean supportsParameter(AcpMethodParameter parameter) { + return parameter.getParameterType() == Suffix.class; + } + + @Override + public Object resolveArgument(AcpMethodParameter parameter, AcpInvocationContext context) { + return new Suffix("!"); + } + + } + + @Singleton + public static class CountingInterceptor implements AcpInterceptor { + + final List methods = new CopyOnWriteArrayList<>(); + + @Override + public boolean preInvoke(AcpInvocationContext context) { + methods.add(context.getAcpMethod()); + return true; + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentConfigBindingTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentConfigBindingTest.java new file mode 100644 index 00000000..512dc436 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentConfigBindingTest.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpVertxHost; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every {@code quarkus.acp.agent.*} setting binds, and reaches the SDK: the HTTP limits + * become the endpoint's transport options. The extension's Quarkus HTTP defaults yield to + * the application's own settings. + */ +class AgentConfigBindingTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(ConfiguredAgent.class)) + .overrideConfigKey("quarkus.acp.agent.enabled", "true") + .overrideConfigKey("quarkus.acp.agent.transport.type", "http") + .overrideConfigKey("quarkus.acp.agent.transport.http.path", "/configured") + .overrideConfigKey("quarkus.acp.agent.request-timeout", "42s") + .overrideConfigKey("quarkus.acp.agent.cancel-grace-period", "7s") + .overrideConfigKey("quarkus.acp.agent.max-prompt-duration", "5m") + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false") + .overrideConfigKey("quarkus.acp.agent.transport.http.max-post-body-size", "2M") + .overrideConfigKey("quarkus.acp.agent.transport.http.keep-alive-interval", "3s") + .overrideConfigKey("quarkus.acp.agent.transport.http.mailbox-capacity", "11") + .overrideConfigKey("quarkus.acp.agent.transport.http.max-pending-sse-events", "12") + .overrideConfigKey("quarkus.acp.agent.transport.http.max-web-socket-pending-frames", "13") + .overrideConfigKey("quarkus.acp.agent.transport.http.max-provisional-sessions", "14") + .overrideConfigKey("quarkus.acp.agent.transport.http.shutdown-timeout", "4s") + .overrideConfigKey("quarkus.acp.agent.transport.http.web-socket-idle-timeout", "45m") + .overrideConfigKey("quarkus.acp.agent.transport.http.initialize-timeout", "9s") + .overrideConfigKey("quarkus.http.limits.max-concurrent-streams", "77"); + + @Inject + AcpBuildTimeConfig buildTime; + + @Inject + AcpRuntimeConfig runtime; + + @Inject + AcpVertxHost endpoint; + + @Test + void everyAgentSettingBinds() { + assertThat(buildTime.agent().enabled()).isTrue(); + assertThat(buildTime.agent().transport().type()).isEqualTo(AcpTransportType.HTTP); + assertThat(buildTime.agent().transport().http().path()).isEqualTo("/configured"); + + AcpRuntimeConfig.Agent agent = runtime.agent(); + assertThat(agent.requestTimeout()).contains(Duration.ofSeconds(42)); + assertThat(agent.cancelGracePeriod()).contains(Duration.ofSeconds(7)); + assertThat(agent.maxPromptDuration()).contains(Duration.ofMinutes(5)); + assertThat(agent.shutdownOnTransportEnd()).isFalse(); + } + + @Test + void httpLimitsBecomeTheEndpointOptions() { + StreamableHttpAcpAgentTransportOptions options = endpoint.endpoint().options(); + assertThat(options.maxPostBodyBytes()).isEqualTo(2L * 1024 * 1024); + assertThat(options.keepAliveInterval()).isEqualTo(Duration.ofSeconds(3)); + assertThat(options.mailboxCapacity()).isEqualTo(11); + assertThat(options.maxPendingSseEvents()).isEqualTo(12); + assertThat(options.maxWebSocketPendingFrames()).isEqualTo(13); + assertThat(options.maxProvisionalSessions()).isEqualTo(14); + assertThat(options.shutdownTimeout()).isEqualTo(Duration.ofSeconds(4)); + assertThat(options.webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(45)); + assertThat(options.initializeTimeout()).isEqualTo(Duration.ofSeconds(9)); + } + + @Test + void quarkusHttpLimitsDefaultToTheSdkButTheApplicationWins() { + var config = ConfigProvider.getConfig(); + assertThat(config.getValue("quarkus.http.limits.max-body-size", String.class)).isEqualTo("16M"); + assertThat(config.getValue("quarkus.http.websocket-server.max-message-size", Integer.class)) + .isEqualTo(16 * 1024 * 1024); + assertThat(config.getValue("quarkus.http.limits.max-concurrent-streams", Long.class)).isEqualTo(77L); + } + + @AcpAgent + public static class ConfiguredAgent { + + @Prompt + AcpSchema.PromptResponse prompt() { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentDisabledTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentDisabledTest.java new file mode 100644 index 00000000..d25fbd3f --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/AgentDisabledTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpAgentAssembly; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioAgentHost; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.arc.Arc; +import io.quarkus.test.QuarkusUnitTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * With {@code quarkus.acp.agent.enabled=false} the {@code @AcpAgent} class stays an + * ordinary bean and no agent is served (so nothing reads standard input). + */ +class AgentDisabledTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(QuietAgent.class)) + .overrideConfigKey("quarkus.acp.agent.enabled", "false"); + + @Test + void noAgentIsServed() { + assertThat(Arc.container().instance(AcpStdioAgentHost.class).isAvailable()).isFalse(); + assertThat(Arc.container().instance(AcpAgentAssembly.class).isAvailable()).isFalse(); + } + + @AcpAgent + public static class QuietAgent { + + @Prompt + AcpSchema.PromptResponse prompt() { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ClientConfigBindingTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ClientConfigBindingTest.java new file mode 100644 index 00000000..63f5f37a --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ClientConfigBindingTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpStdioAgentHost; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import io.quarkus.arc.Arc; +import io.quarkus.test.QuarkusUnitTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Every {@code quarkus.acp.client.*} setting binds; an explicit transport type wins over + * the URIs that would otherwise select one. With no {@code @AcpAgent} the application is a + * client only: no agent is served. + */ +class ClientConfigBindingTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest().withEmptyApplication() + .overrideConfigKey("quarkus.acp.client.request-timeout", "9s") + .overrideConfigKey("quarkus.acp.client.prompt-timeout", "10m") + .overrideConfigKey("quarkus.acp.client.transport.type", "stdio") + .overrideConfigKey("quarkus.acp.client.transport.stdio.command", "my-agent") + .overrideConfigKey("quarkus.acp.client.transport.stdio.args", "--acp,--verbose") + .overrideConfigKey("quarkus.acp.client.transport.stdio.env.AGENT_MODE", "test") + .overrideConfigKey("quarkus.acp.client.transport.websocket.uri", "ws://localhost:9/acp") + .overrideConfigKey("quarkus.acp.client.transport.websocket.connect-timeout", "3s") + .overrideConfigKey("quarkus.acp.client.transport.http.uri", "http://localhost:9/acp") + .overrideConfigKey("quarkus.acp.client.capabilities.read-text-file", "true") + .overrideConfigKey("quarkus.acp.client.capabilities.write-text-file", "true") + .overrideConfigKey("quarkus.acp.client.capabilities.terminal", "true") + .overrideConfigKey("quarkus.acp.client.capabilities.elicitation-form", "true") + .overrideConfigKey("quarkus.acp.client.capabilities.elicitation-url", "true") + .overrideConfigKey("quarkus.acp.client.capabilities.boolean-config-options", "true"); + + @Inject + AcpRuntimeConfig config; + + @Inject + AcpClientTransport transport; + + @Test + void everyClientSettingBinds() { + AcpRuntimeConfig.Client client = config.client(); + assertThat(client.requestTimeout()).contains(Duration.ofSeconds(9)); + assertThat(client.promptTimeout()).contains(Duration.ofMinutes(10)); + assertThat(client.transport().type()).contains(AcpTransportType.STDIO); + assertThat(client.transport().stdio().command()).contains("my-agent"); + assertThat(client.transport().stdio().args()).contains(List.of("--acp", "--verbose")); + assertThat(client.transport().stdio().env()).isEqualTo(Map.of("AGENT_MODE", "test")); + assertThat(client.transport().websocket().uri()).contains(URI.create("ws://localhost:9/acp")); + assertThat(client.transport().websocket().connectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(client.transport().http().uri()).contains(URI.create("http://localhost:9/acp")); + assertThat(client.capabilities().readTextFile()).isTrue(); + assertThat(client.capabilities().writeTextFile()).isTrue(); + assertThat(client.capabilities().terminal()).isTrue(); + assertThat(client.capabilities().elicitationForm()).isTrue(); + assertThat(client.capabilities().elicitationUrl()).isTrue(); + assertThat(client.capabilities().booleanConfigOptions()).isTrue(); + } + + @Test + void explicitTypeWins() { + assertThat(transport).isInstanceOf(StdioAcpClientTransport.class); + } + + @Test + void clientOnlyApplicationServesNoAgent() { + assertThat(Arc.container().instance(AcpStdioAgentHost.class).isAvailable()).isFalse(); + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/DeploymentArchitectureTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/DeploymentArchitectureTest.java new file mode 100644 index 00000000..65c5c237 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/DeploymentArchitectureTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-quarkus-deployment: build steps only. They read the index and the build-time + * configuration and register the runtime beans; they never call the SDK's runtime API + * (agents, clients, transports), which runs in the application, not in the build. + */ +@AnalyzeClasses(locations = DeploymentArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class DeploymentArchitectureTest { + + @ArchTest + static void importsThisModule(JavaClasses classes) { + assertThat(classes.contain(AcpProcessor.class)).isTrue(); + } + + @ArchTest + static final ArchRule buildStepsNeverRunTheSdk = noClasses().should() + .dependOnClassesThat() + .resideInAnyPackage("com.agentclientprotocol.sdk.agent..", "com.agentclientprotocol.sdk.client..", + "com.agentclientprotocol.sdk.spec..", "org.eclipse.jetty..") + .because("the SDK runs in the application; the build only finds the agent and registers beans"); + + /** Imports the directory or jar holding this module's main classes. */ + public static final class ThisModule implements LocationProvider { + + @Override + public Set get(Class testClass) { + try { + Path root = Path.of(AcpProcessor.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpSecurityTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpSecurityTest.java new file mode 100644 index 00000000..a3ef6424 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpSecurityTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Quarkus's own HTTP security protects {@code /acp}, over HTTP and on the WebSocket handshake, + * because the endpoint is a route on the Quarkus router behind its authentication and + * permission checks: an unauthenticated request is refused, an authenticated one served. + */ +class HttpSecurityTest { + + private static final Map ALICE = Map.of("Authorization", + "Basic " + Base64.getEncoder().encodeToString("alice:secret".getBytes(StandardCharsets.UTF_8))); + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest().withApplicationRoot(jar -> jar.addClasses(SecuredAgent.class)) + .overrideConfigKey("quarkus.acp.agent.transport.type", "http") + .overrideConfigKey("quarkus.http.auth.basic", "true") + .overrideConfigKey("quarkus.security.users.embedded.enabled", "true") + .overrideConfigKey("quarkus.security.users.embedded.plain-text", "true") + .overrideConfigKey("quarkus.security.users.embedded.users.alice", "secret") + .overrideConfigKey("quarkus.http.auth.permission.acp.paths", "/acp") + .overrideConfigKey("quarkus.http.auth.permission.acp.policy", "authenticated"); + + @TestHTTPResource("/acp") + URI endpoint; + + @Test + void anUnauthenticatedRequestIsRefusedOverHttpAndOnTheHandshake() throws Exception { + assertThat(HttpProbes.initialize(endpoint, null).statusCode()).isEqualTo(401); + assertThat(HttpProbes.webSocketHandshake(endpoint, null)).isEqualTo(401); + } + + @Test + void anAuthenticatedRequestIsServedOverHttpAndOnTheHandshake() throws Exception { + assertThat(HttpProbes.initialize(endpoint, null, ALICE).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(endpoint, null, ALICE)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + + @AcpAgent + public static class SecuredAgent { + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpTransportTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpTransportTest.java new file mode 100644 index 00000000..ca0ac73e --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/HttpTransportTest.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Level; +import java.util.logging.LogRecord; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpHttpAgentHost; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The agent over Streamable HTTP and WebSocket at a configured path on the Quarkus HTTP + * server. Connections a client leaves open are closed when the application stops, the + * connection threads end, and nothing logs a warning or an error on the server. + */ +class HttpTransportTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final List serverWarnings = new CopyOnWriteArrayList<>(); + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(HttpAgent.class)) + .overrideConfigKey("quarkus.acp.agent.transport.type", "http") + .overrideConfigKey("quarkus.acp.agent.transport.http.path", "/agents/acp") + .overrideConfigKey("quarkus.acp.agent.transport.http.shutdown-timeout", "2s") + .setLogRecordPredicate(record -> record.getLevel().intValue() >= Level.WARNING.intValue() + && !record.getLoggerName().startsWith("com.agentclientprotocol.sdk.client") + && !record.getLoggerName().startsWith("com.agentclientprotocol.sdk.spec.AcpClientSession") + && !record.getLoggerName().startsWith("com.agentclientprotocol.sdk.spec.PendingResponses")) + .assertLogRecords(serverWarnings::addAll) + .setAfterUndeployListener(() -> { + assertThat(serverWarnings).extracting(LogRecord::getMessage).isEmpty(); + Threads.assertNoConnectionThreadsRemain(); + }); + + @TestHTTPResource("/agents/acp") + URI endpoint; + + @Inject + AcpHttpAgentHost host; + + @Test + void streamableHttpClient() { + roundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault()), true); + } + + @Test + void webSocketClient() { + roundTrip(new WebSocketAcpClientTransport(webSocketUri(), AcpJsonMapper.createDefault()), true); + } + + @Test + void oneMegabytePromptOverBothTransports() { + String big = "x".repeat(1024 * 1024); + roundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault()), true, big); + roundTrip(new WebSocketAcpClientTransport(webSocketUri(), AcpJsonMapper.createDefault()), true, big); + } + + /** + * The extension raises the Quarkus limits to the SDK's 16 MiB: a 12 MB prompt, above + * Quarkus's 10 MB default body size, is accepted as a POST body and as a WebSocket message. + */ + @Test + void twelveMegabytePromptOverBothTransports() { + String big = "x".repeat(12 * 1024 * 1024); + roundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault()), true, big); + roundTrip(new WebSocketAcpClientTransport(webSocketUri(), AcpJsonMapper.createDefault()), true, big); + } + + @Test + void connectionsLeftOpenAreCountedAndClosedAtShutdown() { + roundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault()), false); + roundTrip(new WebSocketAcpClientTransport(webSocketUri(), AcpJsonMapper.createDefault()), false); + assertThat(host.activeConnectionCount()).isGreaterThanOrEqualTo(2); + } + + @Test + void otherPathsAreNotTheAgent() throws Exception { + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(endpoint.resolve("/acp")).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(404); + } + + private URI webSocketUri() { + return URI.create(endpoint.toString().replaceFirst("^http", "ws")); + } + + private static void roundTrip(AcpClientTransport transport, boolean close) { + roundTrip(transport, close, "ping"); + } + + private static void roundTrip(AcpClientTransport transport, boolean close, String prompt) { + List messages = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + }).build(); + client.initialize(); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(prompt)))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(messages).containsExactly("pong " + prompt.length()); + if (close) { + client.closeGracefully(); + } + } + + @AcpAgent + public static class HttpAgent { + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + AcpSchema.TextContent text = (AcpSchema.TextContent) request.prompt().get(0); + context.sendMessage("pong " + text.text().length()); + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/InMemoryAgentTransport.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/InMemoryAgentTransport.java new file mode 100644 index 00000000..ca681d37 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/InMemoryAgentTransport.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +/** + * Replaces the stdio agent transport with an in-memory one (an application bean of type + * {@link AcpAgentTransport} wins over the extension's default), and builds clients on its + * other end. + */ +@Singleton +public class InMemoryAgentTransport { + + static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final InMemoryTransportPair pair = InMemoryTransportPair.create(); + + final List messages = new CopyOnWriteArrayList<>(); + + @Produces + @Singleton + AcpAgentTransport agentTransport() { + return pair.agentTransport(); + } + + /** A client on the other end, collecting the agent's message chunks. */ + AcpSyncClient client() { + return AcpClient.sync(pair.clientTransport()).requestTimeout(TIMEOUT).sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + }).build(); + } + + static AcpSchema.PromptRequest prompt(String sessionId, String text) { + return new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text))); + } + + static AcpSchema.NewSessionRequest newSession() { + return new AcpSchema.NewSessionRequest("/tmp", List.of()); + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/MutinyReturnTypesTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/MutinyReturnTypesTest.java new file mode 100644 index 00000000..db6694dc --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/MutinyReturnTypesTest.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Handlers return Mutiny types: a {@code Uni} of the response, and from {@code @Prompt} + * a {@code Multi} that streams the reply (text, content blocks and session updates); + * a {@code Multi} from any other handler is an error the client sees. + */ +class MutinyReturnTypesTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(MutinyAgent.class, InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false"); + + @Inject + InMemoryAgentTransport transport; + + @Test + void uniAndMultiResultsAreAnswered() { + AcpSyncClient client = transport.client(); + client.initialize(); + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + assertThat(sessionId).startsWith("uni-"); + + AcpSchema.PromptResponse streamed = client.prompt(InMemoryAgentTransport.prompt(sessionId, "stream")); + assertThat(streamed.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(transport.messages).containsExactly("one", "two", "three"); + + assertThatThrownBy(() -> client.sendExtRequest("_quarkus/multi", Map.of())) + .isInstanceOf(AcpError.class) + // The handler's ReturnValueHandlingException is logged at the agent, not sent. + .hasMessage("Internal error"); + } + + @AcpAgent + public static class MutinyAgent { + + @NewSession + Uni newSession() { + return Uni.createFrom() + .item(() -> new AcpSchema.NewSessionResponse("uni-" + UUID.randomUUID(), null, null)) + .onItem() + .delayIt() + .by(Duration.ofMillis(10)); + } + + @Prompt + Multi prompt(AcpSchema.PromptRequest request) { + return Multi.createFrom().items("one", new AcpSchema.TextContent("two"), + new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("three"))); + } + + @ExtRequest("_quarkus/multi") + Multi notAPrompt() { + return Multi.createFrom().items("never"); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/OriginValidationTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/OriginValidationTest.java new file mode 100644 index 00000000..bffc3b97 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/OriginValidationTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.net.URI; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The Quarkus endpoint applies the SDK's {@code Origin} rule over HTTP and on the WebSocket + * handshake. A foreign origin is answered 403, a loopback or + * listed one is served. Before, both served any origin. + */ +class OriginValidationTest { + + private static final String LISTED = "https://app.example.com"; + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(OriginAgent.class)) + .overrideConfigKey("quarkus.acp.agent.transport.type", "http") + .overrideConfigKey("quarkus.acp.agent.transport.http.allowed-origins", LISTED); + + @TestHTTPResource("/acp") + URI endpoint; + + @Test + void aForeignOriginIsRefusedOverHttpAndOnTheWebSocketHandshake() throws Exception { + assertThat(HttpProbes.initialize(endpoint, "http://evil.example").statusCode()).isEqualTo(403); + assertThat(HttpProbes.get(endpoint, "http://evil.example")).isEqualTo(403); + assertThat(HttpProbes.webSocketHandshake(endpoint, "http://evil.example")).isEqualTo(403); + } + + @Test + void loopbackListedAndAbsentOriginsAreServed() throws Exception { + for (String origin : new String[] { "http://localhost:3000", "http://127.0.0.1", LISTED, null }) { + assertThat(HttpProbes.initialize(endpoint, origin).statusCode()).as(String.valueOf(origin)).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(endpoint, origin)).as(String.valueOf(origin)) + .isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + } + + @AcpAgent + public static class OriginAgent { + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/PromptCancellationTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/PromptCancellationTest.java new file mode 100644 index 00000000..1795ca0b --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/PromptCancellationTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Multi; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code session/cancel} reaches a streaming {@code @Prompt}: the {@code Multi}'s + * subscription is cancelled and the turn ends {@code cancelled}. + */ +class PromptCancellationTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(TickingAgent.class, InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false"); + + @Inject + InMemoryAgentTransport transport; + + @Test + void cancelStopsTheStreamAndEndsTheTurnCancelled() { + AcpSyncClient client = transport.client(); + client.initialize(); + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + + CompletableFuture response = CompletableFuture + .supplyAsync(() -> client.prompt(InMemoryAgentTransport.prompt(sessionId, "tick"))); + waitFor(() -> transport.messages.contains("tick")); + client.cancel(new AcpSchema.CancelNotification(sessionId)); + + assertThat(response.join().stopReason()).isEqualTo(AcpSchema.StopReason.CANCELLED); + int ticks = transport.messages.size(); + sleep(300); + assertThat(transport.messages).as("no tick after the cancel").hasSize(ticks); + } + + private static void waitFor(java.util.function.BooleanSupplier condition) { + long deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (!condition.getAsBoolean()) { + assertThat(System.nanoTime()).as("waited 10 s").isLessThan(deadline); + sleep(20); + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @AcpAgent + public static class TickingAgent { + + @Prompt + Multi prompt() { + return Multi.createFrom().ticks().every(Duration.ofMillis(50)).map(tick -> "tick"); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ProxiedAgentTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ProxiedAgentTest.java new file mode 100644 index 00000000..29b16b33 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/ProxiedAgentTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.QuarkusUnitTest; +import jakarta.annotation.Priority; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InterceptorBinding; +import jakarta.interceptor.InvocationContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An {@code @AcpAgent} bean that declares a normal scope (so the container hands out a + * client proxy) and whose handler carries a CDI interceptor binding (so the instance is + * a container subclass) is still served: handlers are found on the user's class and + * invoked through the container's instance, interceptor included. + */ +class ProxiedAgentTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(ScopedAgent.class, Audited.class, AuditInterceptor.class, + InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false"); + + @Inject + InMemoryAgentTransport transport; + + @Test + void proxiedAndInterceptedAgentIsServed() { + assertThat(Arc.container().instance(ScopedAgent.class).get()).isInstanceOf(ClientProxy.class); + + AcpSyncClient client = transport.client(); + client.initialize(); + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + AcpSchema.PromptResponse response = client.prompt(InMemoryAgentTransport.prompt(sessionId, "hi")); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(transport.messages).containsExactly("scoped hi"); + assertThat(AuditInterceptor.invoked).containsExactly("prompt"); + } + + @AcpAgent + @ApplicationScoped + public static class ScopedAgent { + + @Prompt + @Audited + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + context.sendMessage("scoped " + ((AcpSchema.TextContent) request.prompt().get(0)).text()); + return AcpSchema.PromptResponse.endTurn(); + } + + } + + @InterceptorBinding + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD, ElementType.TYPE }) + public @interface Audited { + + } + + @Audited + @Interceptor + @Priority(Interceptor.Priority.APPLICATION) + public static class AuditInterceptor { + + static final List invoked = new CopyOnWriteArrayList<>(); + + @AroundInvoke + Object audit(InvocationContext context) throws Exception { + invoked.add(context.getMethod().getName()); + return context.proceed(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/QuarkusTckTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/QuarkusTckTest.java new file mode 100644 index 00000000..9a25c483 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/QuarkusTckTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.quarkus.runtime.AcpHttpAgentHost; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import jakarta.inject.Inject; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * The Quarkus extension's host (the SDK endpoint on a Vert.x route of the Quarkus router) under + * the shared transport TCK. The application is configured as the TCK's standard configuration, + * with a two second WebSocket idle timeout and initialize timeout for the timeout cases, and its + * {@code @AcpAgent} behaves as the TCK's agent; the shutdown case drains the running + * application's endpoint, as the application's own shutdown does. + */ +class QuarkusTckTest extends AcpHttpTransportTck { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest().withApplicationRoot(jar -> jar.addClasses(TckAgent.class)) + .overrideConfigKey("quarkus.acp.agent.transport.type", "http") + .overrideConfigKey("quarkus.acp.agent.transport.http.keep-alive-interval", "250ms") + .overrideConfigKey("quarkus.acp.agent.transport.http.max-post-body-size", Long.toString(MAX_MESSAGE_BYTES)) + .overrideConfigKey("quarkus.acp.agent.transport.http.allowed-origins", ALLOWED_ORIGIN) + .overrideConfigKey("quarkus.acp.agent.transport.http.shutdown-timeout", "3s") + .overrideConfigKey("quarkus.acp.agent.transport.http.web-socket-idle-timeout", "2s") + .overrideConfigKey("quarkus.acp.agent.transport.http.initialize-timeout", "2s"); + + @TestHTTPResource("/acp") + URI endpoint; + + @Inject + AcpHttpAgentHost host; + + @Override + protected Host startHost(HostConfig config) { + URI uri = URI.create(endpoint.toString().replace("localhost", "127.0.0.1")); + return new Host() { + + @Override + public URI endpoint() { + return uri; + } + + @Override + public void stop() { + host.drain(); + } + + }; + } + + /** The TCK's agent, as an annotated bean. */ + @AcpAgent + public static class TckAgent { + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) throws Exception { + String text = ((AcpSchema.TextContent) request.prompt().get(0)).text(); + if (text.startsWith("updates:")) { + int count = Integer.parseInt(text.substring("updates:".length())); + ExecutorService senders = Executors.newFixedThreadPool(4); + try { + List> sent = new ArrayList<>(); + for (int i = 0; i < count; i++) { + String update = "u" + i; + sent.add(senders.submit(() -> context.sendMessage(update))); + } + for (Future future : sent) { + future.get(); + } + } + finally { + senders.shutdownNow(); + } + return AcpSchema.PromptResponse.endTurn(); + } + if (text.equals("hold")) { + Thread.sleep(60_000); + return AcpSchema.PromptResponse.endTurn(); + } + context.sendMessage("echo " + text); + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/Threads.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/Threads.java new file mode 100644 index 00000000..0ced69c7 --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/Threads.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.time.Duration; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The agent-side SDK threads that belong to one transport or endpoint, which must be gone + * once the application has stopped. The SDK's shared pools (handler and timeout threads) + * are JVM-wide and idle out on their own, and client threads belong to the test's + * clients; neither is checked. + */ +final class Threads { + + private static final Set PER_CONNECTION = Set.of("acp-agent-inbound", "acp-agent-outbound", + "acp-streamable-http-keepalive"); + + private Threads() { + } + + static List acpConnectionThreads() { + return Thread.getAllStackTraces() + .keySet() + .stream() + .filter(Thread::isAlive) + .map(Thread::getName) + .filter(name -> PER_CONNECTION.stream().anyMatch(name::startsWith)) + .toList(); + } + + /** Waits up to five seconds for the per-connection threads to end. */ + static void assertNoConnectionThreadsRemain() { + long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + List remaining = acpConnectionThreads(); + while (!remaining.isEmpty() && System.nanoTime() < deadline) { + try { + Thread.sleep(50); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + remaining = acpConnectionThreads(); + } + assertThat(remaining).as("ACP connection threads after the application stopped").isEmpty(); + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/TwoAgentsFailTheBuildTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/TwoAgentsFailTheBuildTest.java new file mode 100644 index 00000000..8784be6d --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/TwoAgentsFailTheBuildTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** A second {@code @AcpAgent} class fails the build, naming both. */ +class TwoAgentsFailTheBuildTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(FirstAgent.class, SecondAgent.class)) + .assertException(error -> { + Throwable root = error; + while (root.getCause() != null && !(root instanceof IllegalStateException)) { + root = root.getCause(); + } + assertThat(root).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Found 2 @AcpAgent classes") + .hasMessageContaining("FirstAgent") + .hasMessageContaining("SecondAgent") + .hasMessageContaining("quarkus.acp.agent.enabled=false"); + }); + + @Test + void buildFails() { + fail("The build should have failed"); + } + + @AcpAgent + public static class FirstAgent { + + @Prompt + AcpSchema.PromptResponse prompt() { + return AcpSchema.PromptResponse.endTurn(); + } + + } + + @AcpAgent + public static class SecondAgent { + + @Prompt + AcpSchema.PromptResponse prompt() { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/UniPromptTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/UniPromptTest.java new file mode 100644 index 00000000..96eeb0af --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/UniPromptTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A {@code Uni} from {@code @Prompt} is read as the {@code String} the handler + * could have returned directly: the text is sent to the client and the turn ends. + */ +class UniPromptTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(UniAgent.class, InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false"); + + @Inject + InMemoryAgentTransport transport; + + @Test + void uniStringIsTheReply() { + AcpSyncClient client = transport.client(); + client.initialize(); + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + AcpSchema.PromptResponse response = client.prompt(InMemoryAgentTransport.prompt(sessionId, "hi")); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(transport.messages).containsExactly("a Uni reply to hi"); + } + + @AcpAgent + public static class UniAgent { + + @Prompt + Uni prompt(AcpSchema.PromptRequest request) { + String text = ((AcpSchema.TextContent) request.prompt().get(0)).text(); + return Uni.createFrom().item("a Uni reply to " + text); + } + + } + +} diff --git a/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/VirtualHandlerExecutorTest.java b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/VirtualHandlerExecutorTest.java new file mode 100644 index 00000000..26e02bfc --- /dev/null +++ b/acp-quarkus-deployment/src/test/java/com/agentclientprotocol/sdk/quarkus/deployment/VirtualHandlerExecutorTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.deployment; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import io.quarkus.arc.Arc; +import io.quarkus.test.QuarkusUnitTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code quarkus.acp.handler-executor=virtual} opts the handlers in to Quarkus' virtual-thread + * executor on JDK 21 and later; before JDK 21 they stay on its worker pool, as with {@code managed}. + */ +class VirtualHandlerExecutorTest { + + @RegisterExtension + static final QuarkusUnitTest app = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(ThreadAgent.class, InMemoryAgentTransport.class)) + .overrideConfigKey("quarkus.acp.agent.shutdown-on-transport-end", "false") + .overrideConfigKey("quarkus.acp.handler-executor", "virtual"); + + @Inject + InMemoryAgentTransport transport; + + @Test + void handlersRunOnQuarkusVirtualThreads() { + AcpSyncClient client = transport.client(); + client.initialize(); + String sessionId = client.newSession(InMemoryAgentTransport.newSession()).sessionId(); + client.prompt(InMemoryAgentTransport.prompt(sessionId, "hello")); + assertThat(Arc.container().instance(ThreadAgent.class).get().threads).singleElement().satisfies(thread -> { + if (VirtualThreads.isSupported()) { + assertThat(VirtualThreads.isVirtual(thread)).as("virtual: %s", thread).isTrue(); + assertThat(thread.getName()).startsWith("quarkus-virtual-thread-"); + } + else { + assertThat(thread.getName()).startsWith("executor-thread-"); + } + }); + } + + @AcpAgent(name = "threads") + public static class ThreadAgent { + + final List threads = new CopyOnWriteArrayList<>(); + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + threads.add(Thread.currentThread()); + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus-integration-tests/pom.xml b/acp-quarkus-integration-tests/pom.xml new file mode 100644 index 00000000..d6162290 --- /dev/null +++ b/acp-quarkus-integration-tests/pom.xml @@ -0,0 +1,114 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-quarkus-integration-tests + ACP Quarkus - Integration Tests + A Quarkus application with an @AcpAgent and an ACP client, and the tests that run it as users do: packaged and launched over stdio, and over Streamable HTTP and WebSocket on the Quarkus HTTP server. Not published. + + + true + true + true + + 0.70 + 0.73 + + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-quarkus + ${project.version} + + + + com.agentclientprotocol + acp-quarkus-deployment + ${project.version} + pom + test + + + * + * + + + + + + io.quarkus + quarkus-junit + test + + + org.assertj + assertj-core + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${project.build.directory}/quarkus-app/quarkus-run.jar + org.jboss.logmanager.LogManager + + + + + + + diff --git a/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgent.java b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgent.java new file mode 100644 index 00000000..9a68727f --- /dev/null +++ b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgent.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.CloseSession; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import jakarta.inject.Inject; + +/** + * The sample agent: a bean with injected collaborators, served over the configured + * transport. It greets, and keeps a count of prompts per session. + */ +@AcpAgent(name = "quarkus-greeter", version = "1.0.0") +public class GreeterAgent { + + private final Map prompts = new ConcurrentHashMap<>(); + + private final Greeting greeting; + + @Inject + GreeterAgent(Greeting greeting) { + this.greeting = greeting; + } + + @NewSession + AcpSchema.NewSessionResponse newSession(AcpSchema.NewSessionRequest request) { + String id = UUID.randomUUID().toString(); + prompts.put(id, 0); + return new AcpSchema.NewSessionResponse(id, null, null); + } + + @Prompt + AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context) { + int count = prompts.merge(context.getSessionId(), 1, Integer::sum); + context.sendMessage(greeting.greet(text(request)) + " (prompt " + count + ")"); + return AcpSchema.PromptResponse.endTurn(); + } + + @CloseSession + AcpSchema.CloseSessionResponse close(AcpSchema.CloseSessionRequest request) { + prompts.remove(request.sessionId()); + return new AcpSchema.CloseSessionResponse(); + } + + private static String text(AcpSchema.PromptRequest request) { + StringBuilder text = new StringBuilder(); + for (AcpSchema.ContentBlock block : request.prompt()) { + if (block instanceof AcpSchema.TextContent content) { + text.append(content.text()); + } + } + return text.toString(); + } + +} diff --git a/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/Greeting.java b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/Greeting.java new file mode 100644 index 00000000..949157c7 --- /dev/null +++ b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/Greeting.java @@ -0,0 +1,24 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** A collaborator injected into the agent, configured from application.properties. */ +@ApplicationScoped +public class Greeting { + + private final String prefix; + + Greeting(@ConfigProperty(name = "greeting.prefix", defaultValue = "Hello") String prefix) { + this.prefix = prefix; + } + + String greet(String name) { + return prefix + ", " + name + "!"; + } + +} diff --git a/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/package-info.java b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/package-info.java new file mode 100644 index 00000000..95ece285 --- /dev/null +++ b/acp-quarkus-integration-tests/src/main/java/com/agentclientprotocol/sdk/quarkus/it/package-info.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * A sample Quarkus application hosting an annotated ACP agent. + */ +@NullMarked +package com.agentclientprotocol.sdk.quarkus.it; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-quarkus-integration-tests/src/main/resources/application.properties b/acp-quarkus-integration-tests/src/main/resources/application.properties new file mode 100644 index 00000000..e07089f7 --- /dev/null +++ b/acp-quarkus-integration-tests/src/main/resources/application.properties @@ -0,0 +1,6 @@ +greeting.prefix=Hello from Quarkus +# The packaged application (prod) is a stdio agent; @QuarkusTest serves the same agent over HTTP. +%test.quarkus.acp.agent.transport.type=http +# The client bean, in tests: a Quarkus ACP client of the same application's agent. The test port +# is a free one FreeTestPort picks, not Quarkus's fixed 8081, so that parallel builds do not collide. +%test.quarkus.acp.client.transport.http.uri=http://localhost:${quarkus.http.test-port}/acp diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/ClientBeanTest.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/ClientBeanTest.java new file mode 100644 index 00000000..1633759d --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/ClientBeanTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import io.quarkus.test.common.TestResourceScope; +import io.quarkus.test.common.WithTestResource; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The Quarkus client bean, configured from {@code quarkus.acp.client.transport.http.uri} + * and customized by a bean, against the application's own agent over Streamable HTTP. + */ +@QuarkusTest +@WithTestResource(value = FreeTestPort.class, scope = TestResourceScope.GLOBAL) +class ClientBeanTest { + + @Inject + AcpSyncClient client; + + @Inject + RecordingCustomizer customizer; + + @Inject + PromptThreadInterceptor interceptor; + + @Test + void injectedClientPromptsTheAgent() { + assertThat(client.initialize().protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("bean")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(customizer.messages).containsExactly("Hello from Quarkus, bean! (prompt 1)"); + + // quarkus.acp.handler-executor=managed, the default: the handler ran on the ManagedExecutor's + // worker pool on every JDK, and so does the client's transport, which created no pool of + // its own. + assertThat(interceptor.promptThreads).singleElement().satisfies(thread -> { + assertThat(VirtualThreads.isVirtual(thread)).as("virtual: %s", thread).isFalse(); + assertThat(thread.getName()).startsWith("executor-thread-"); + }); + // What remains of the SDK in this JVM is not a pool: the JDK's HttpClient keeps one selector + // thread, and the SDK's one JVM-wide timer serves every timeout. The agent is served by + // Quarkus' own HTTP server. + Set sdkThreads = Thread.getAllStackTraces() + .keySet() + .stream() + .map(Thread::getName) + .filter(name -> Stream.of("acp-", "qtp", "HttpClient-", "Scheduler-", "jetty-").anyMatch(name::startsWith)) + .collect(Collectors.toSet()); + if (VirtualThreads.isSupported()) { + assertThat(sdkThreads).allMatch(name -> name.equals("acp-timeout") + || name.matches("HttpClient-\\d+-SelectorManager"), "allowed"); + } + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/FreeTestPort.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/FreeTestPort.java new file mode 100644 index 00000000..1fba94d7 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/FreeTestPort.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ServerSocket; +import java.util.Map; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +/** + * Serves the tests on a free port instead of Quarkus's fixed test port 8081, so that two builds on + * one machine do not collide. Not {@code quarkus.http.test-port=0}: the client bean's URI, + * {@code quarkus.acp.client.transport.http.uri}, is read when the application starts, before the + * server has bound a random port, so it would name port 0. A port picked here is known to both. + */ +public class FreeTestPort implements QuarkusTestResourceLifecycleManager { + + @Override + public Map start() { + try (ServerSocket socket = new ServerSocket(0)) { + return Map.of("quarkus.http.test-port", String.valueOf(socket.getLocalPort())); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + @Override + public void stop() { + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgentAdvertisement.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgentAdvertisement.java new file mode 100644 index 00000000..35755f42 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/GreeterAgentAdvertisement.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import com.agentclientprotocol.sdk.spec.AcpSchema; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * What the sample agent's annotations advertise on the wire: {@code agentInfo} from + * {@code @AcpAgent(name, version)}, and {@code sessionCapabilities.close} from its + * {@code @CloseSession} handler, and nothing it does not handle. + */ +final class GreeterAgentAdvertisement { + + private GreeterAgentAdvertisement() { + } + + static void assertAdvertised(AcpSchema.InitializeResponse initialized) { + assertThat(initialized.agentInfo()).isNotNull(); + assertThat(initialized.agentInfo().name()).isEqualTo("quarkus-greeter"); + assertThat(initialized.agentInfo().version()).isEqualTo("1.0.0"); + AcpSchema.AgentCapabilities capabilities = initialized.agentCapabilities(); + assertThat(capabilities).isNotNull(); + assertThat(capabilities.loadSession()).isNotEqualTo(Boolean.TRUE); + assertThat(capabilities.sessionCapabilities()).isNotNull(); + assertThat(capabilities.sessionCapabilities().close()).isNotNull(); + assertThat(capabilities.sessionCapabilities().list()).isNull(); + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/HttpAgentTest.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/HttpAgentTest.java new file mode 100644 index 00000000..a0b88691 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/HttpAgentTest.java @@ -0,0 +1,74 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The sample agent on the Quarkus HTTP server: the SDK's Streamable HTTP client and its + * WebSocket client reach it at {@code /acp} on the application's own port. + */ +@QuarkusTest +class HttpAgentTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + @TestHTTPResource("/acp") + URI endpoint; + + @Test + void streamableHttpClientPromptsTheAgent() { + assertPromptRoundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault())); + } + + @Test + void webSocketClientPromptsTheAgent() { + URI ws = URI.create(endpoint.toString().replaceFirst("^http", "ws")); + assertPromptRoundTrip(new WebSocketAcpClientTransport(ws, AcpJsonMapper.createDefault())); + } + + private static void assertPromptRoundTrip(AcpClientTransport transport) { + List messages = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(transport) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + }) + .build(); + try { + AcpSchema.InitializeResponse initialized = client.initialize(); + assertThat(initialized.protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + GreeterAgentAdvertisement.assertAdvertised(initialized); + String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/tmp", List.of())).sessionId(); + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("http")))); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(messages).containsExactly("Hello from Quarkus, http! (prompt 1)"); + } + finally { + client.closeGracefully(); + } + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/PromptThreadInterceptor.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/PromptThreadInterceptor.java new file mode 100644 index 00000000..7cd5cea6 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/PromptThreadInterceptor.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import jakarta.inject.Singleton; + +/** Records the threads the agent's prompt handler runs on. */ +@Singleton +public class PromptThreadInterceptor implements AcpInterceptor { + + final List promptThreads = new CopyOnWriteArrayList<>(); + + @Override + public boolean preInvoke(AcpInvocationContext context) { + if ("session/prompt".equals(context.getAcpMethod())) { + promptThreads.add(Thread.currentThread()); + } + return true; + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/RecordingCustomizer.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/RecordingCustomizer.java new file mode 100644 index 00000000..924ea000 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/RecordingCustomizer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import jakarta.annotation.Priority; +import jakarta.inject.Singleton; +import reactor.core.publisher.Mono; + +/** Customizes the client bean: records the agent's message chunks. */ +@Singleton +@Priority(10) +public class RecordingCustomizer implements AcpClientCustomizer { + + final List messages = new CopyOnWriteArrayList<>(); + + @Override + public void customize(AcpClient.AsyncSpec spec) { + spec.clientInfo(new AcpSchema.Implementation("quarkus-it-client", "1.0.0", null)) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + return Mono.empty(); + }); + } + +} diff --git a/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/StdioAgentIT.java b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/StdioAgentIT.java new file mode 100644 index 00000000..75965875 --- /dev/null +++ b/acp-quarkus-integration-tests/src/test/java/com/agentclientprotocol/sdk/quarkus/it/StdioAgentIT.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.it; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.AgentParameters; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The packaged application, launched as an editor launches an agent: the SDK's stdio + * client starts {@code java -jar quarkus-run.jar}, initializes, opens a session and + * prompts; when the client closes the agent's standard input the application exits by + * itself, with code 0 and no ERROR log. + */ +class StdioAgentIT { + + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + @TempDir + Path tempDir; + + @Test + void packagedAgentAnswersOverStdioAndExitsCleanlyWhenInputEnds() throws Exception { + Path exitCode = tempDir.resolve("exit-code"); + Path log = tempDir.resolve("stderr.log"); + List messages = new CopyOnWriteArrayList<>(); + + StdioAcpClientTransport transport = new StdioAcpClientTransport(launch(exitCode, log)); + AcpSyncClient client = AcpClient.sync(transport) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + messages.add(text.text()); + } + }) + .build(); + + AcpSchema.InitializeResponse initialized = client.initialize(); + assertThat(initialized.protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + GreeterAgentAdvertisement.assertAdvertised(initialized); + + String sessionId = client.newSession(new AcpSchema.NewSessionRequest(tempDir.toString(), List.of())) + .sessionId(); + AcpSchema.PromptResponse first = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("stdio")))); + AcpSchema.PromptResponse second = client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("again")))); + + assertThat(first.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(second.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(messages).containsExactly("Hello from Quarkus, stdio! (prompt 1)", + "Hello from Quarkus, again! (prompt 2)"); + + long closing = System.nanoTime(); + client.closeGracefully(); + Duration closed = Duration.ofNanos(System.nanoTime() - closing); + + // The agent exits on the end of its input, before the client would send TERM. + assertThat(closed).isLessThan(Duration.ofMillis(StdioAcpClientTransport.END_OF_INPUT_WAIT_MILLIS)); + assertThat(Files.readString(exitCode).strip()).isEqualTo("0"); + List stderr = Files.readAllLines(log); + assertThat(stderr).noneMatch(line -> line.contains("ERROR") || line.contains("WARN")); + assertThat(stderr).anyMatch(line -> line.contains("ACP agent transport ended; stopping the application")); + assertThat(stderr).anyMatch(line -> line.contains("stopped in")); + } + + /** + * {@code java -jar} the packaged application, its standard error and exit code in files + * (the client stops reading standard error once it closes). + */ + private static AgentParameters launch(Path exitCode, Path log) { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + String app = System.getProperty("acp.quarkus.app"); + assertThat(app).as("acp.quarkus.app (set by failsafe)").isNotNull(); + return AgentParameters.builder("sh") + .arg("-c") + .arg("'" + java + "' -jar '" + app + "' 2> '" + log + "'; echo $? > '" + exitCode + "'") + .build(); + } + +} diff --git a/acp-quarkus/README.md b/acp-quarkus/README.md new file mode 100644 index 00000000..ddf54d62 --- /dev/null +++ b/acp-quarkus/README.md @@ -0,0 +1,192 @@ +# ACP Quarkus + +A Quarkus extension for the ACP Java SDK. Annotate one class with `@AcpAgent` and Quarkus serves it +over stdio, or over Streamable HTTP and WebSocket on the application's own HTTP server. Inject +`AcpSyncClient` or `AcpAsyncClient` to talk to an agent configured in `application.properties`. +JVM mode; native images are not supported in 0.80.0. + +## Installation + +```xml + + + + + com.fasterxml.jackson + jackson-bom + 2.22.3 + pom + import + + + io.quarkus.platform + quarkus-bom + 3.40.1 + pom + import + + + + + + + com.agentclientprotocol + acp-quarkus + ${acp.version} + + +``` + +## An agent + +```java +@AcpAgent +public class GreeterAgent { + + private final Greeting greeting; // any bean: constructor injection works + + GreeterAgent(Greeting greeting) { + this.greeting = greeting; + } + + @Prompt + PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + context.sendMessage(greeting.greet(request)); + return PromptResponse.endTurn(); + } +} +``` + +- `@AcpAgent` alone makes the class a bean, a singleton unless it declares a scope. The class is + found at build time; a second `@AcpAgent` class fails the build with both names. +- `AcpInterceptor`, `ArgumentResolver` and `ReturnValueHandler` beans are added to the agent. +- Handlers may return Mutiny types: a `Uni` of what the handler could return directly, and from + `@Prompt` a `Multi` of `String`, `ContentBlock` or `SessionUpdate` items, each sent as it is + emitted, ending the turn when the stream completes. The handler's thread waits for them. +- Handlers block, so they run off the event loop: by default on Quarkus' `ManagedExecutor` worker + pool, which carries the contexts MicroProfile Context Propagation propagates from the submitting + thread (the CDI request context, the security identity, a transaction, the OpenTelemetry + context). `quarkus.acp.handler-executor=virtual` opts in to Quarkus' virtual-thread executor on + JDK 21 and later, a virtual thread per call (as `managed` before JDK 21). It propagates none of + those contexts: a `@RequestScoped` bean needs `@ActivateRequestContext` on the handler method. + Virtual threads on JDK 21 to 23 pin their carrier when they block inside `synchronized`; JDK 24 + removed that (JEP 491). The client's WebSocket and Streamable HTTP transports run on the same + executor. +- Package the application as usual (`quarkus-run.jar`) and point the editor at + `java -jar target/quarkus-app/quarkus-run.jar`. + +### Over stdio (the default) + +Standard output carries the protocol, so a stdio build sets these defaults (your own settings win): +`quarkus.log.console.stderr=true`, `quarkus.banner.enabled=false`, `quarkus.http.host-enabled=false`. +Never print to `System.out`. The application exits when the client closes standard input +(`quarkus.acp.agent.shutdown-on-transport-end=false` keeps it running). Dev mode (`quarkus dev`) +owns the terminal's standard input, so develop a stdio agent in tests (an application bean of type +`AcpAgentTransport`, such as an in-memory one from `acp-test`, replaces stdio) or over HTTP. + +### Over HTTP and WebSocket + +```properties +quarkus.acp.agent.transport.type=http +quarkus.acp.agent.transport.http.path=/acp +``` + +The agent is served at `/acp` on the Quarkus HTTP server (`quarkus.http.port`, under +`quarkus.http.root-path`): Streamable HTTP, SSE and WebSocket upgrades on one Vert.x route of the +Quarkus router, with no servlet container and no second server. The route sits behind Quarkus's +authentication and permission checks, so HTTP security policies apply to `/acp`, the WebSocket +handshake included: + +```properties +quarkus.http.auth.permission.acp.paths=/acp +quarkus.http.auth.permission.acp.policy=authenticated +``` + +Each connection runs its own agent runtime over the one bean, so the bean's handlers must be +thread-safe. On shutdown the endpoint drains first, within +`quarkus.acp.agent.transport.http.shutdown-timeout`: SSE streams get a closing comment and +complete, WebSockets close with 1001. + +An HTTP build raises these Quarkus defaults to what the SDK's own listener admits (your own settings +win): `quarkus.http.limits.max-body-size=16M`, +`quarkus.http.websocket-server.max-message-size` and `max-frame-size` to 16 MB, and +`quarkus.http.limits.max-concurrent-streams=1024` (each SSE stream is one HTTP/2 stream). Raise them +too if you raise `max-post-body-size` past 16 MB. + +## A client + +```properties +quarkus.acp.client.transport.stdio.command=my-agent +quarkus.acp.client.transport.stdio.args=--acp +``` + +```java +@Inject AcpSyncClient client; + +@Singleton +class Updates implements AcpClientCustomizer { // optional; @Priority orders several + public void customize(AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(notification -> { /* ... */ return Mono.empty(); }); + } +} +``` + +`AcpClientCustomizer` is `com.agentclientprotocol.sdk.integration.AcpClientCustomizer`, the same +type in every framework integration. The client beans are created when first injected and closed when the application stops; a bean of +your own of the same type replaces each. Client callbacks run on SDK threads, not on a Vert.x +context: from a `Uni` built over a client call, hop back with `emitOn` when you need the context. + +Without a session-update handler of your own, session updates are logged at DEBUG, content +included. DEBUG and TRACE on `com.agentclientprotocol.sdk` log message payloads in full; do not +enable them where logs are shipped. A stdio agent's standard error is logged at INFO, unfiltered, +on `com.agentclientprotocol.sdk.client.transport.agent-stderr`, and can carry sensitive data: +`quarkus.log.category."com.agentclientprotocol.sdk.client.transport.agent-stderr".level=WARN` +silences it. + +## Configuration + +Build time (fixed when the application is built): + +| Property | Default | | +|---|---|---| +| `quarkus.acp.agent.enabled` | `true` | serve the `@AcpAgent` bean | +| `quarkus.acp.agent.transport.type` | `stdio` | `stdio` or `http` (`websocket` means the same as `http`) | +| `quarkus.acp.agent.transport.http.path` | `/acp` | endpoint path (Streamable HTTP and WebSocket) | + +Run time: + +| Property | Default | | +|---|---|---| +| `quarkus.acp.handler-executor` | `managed` | `managed` or `virtual` (JDK 21+, else as `managed`): where handlers and client transports run | +| `quarkus.acp.agent.request-timeout` | SDK default | agent requests to the client | +| `quarkus.acp.agent.cancel-grace-period` | SDK default | | +| `quarkus.acp.agent.max-prompt-duration` | none | | +| `quarkus.acp.agent.shutdown-on-transport-end` | `true` | stdio: exit when input ends | +| `quarkus.acp.agent.transport.http.max-post-body-size` | 16M | POST body and WebSocket message | +| `quarkus.acp.agent.transport.http.keep-alive-interval` | 15s | | +| `quarkus.acp.agent.transport.http.mailbox-capacity` | 1024 | | +| `quarkus.acp.agent.transport.http.max-pending-sse-events` | 1024 | | +| `quarkus.acp.agent.transport.http.max-web-socket-pending-frames` | 1024 | | +| `quarkus.acp.agent.transport.http.max-provisional-sessions` | 64 | | +| `quarkus.acp.agent.transport.http.shutdown-timeout` | 5s | | +| `quarkus.acp.agent.transport.http.web-socket-idle-timeout` | 30m | a WebSocket connection that passes no frame for this long is closed (1001) | +| `quarkus.acp.agent.transport.http.initialize-timeout` | 30s | a WebSocket that has not sent `initialize` by then is closed (1008); a POST `initialize` not answered by then is answered 500 | +| `quarkus.acp.agent.transport.http.allowed-origins` | none | browser origins accepted besides `localhost`, `127.0.0.1` and `[::1]`; any other `Origin` is answered 403 (HTTP and WebSocket handshake); `*` for any. The bind address is Quarkus's own, `quarkus.http.host` | +| `quarkus.acp.client.request-timeout` | SDK default | | +| `quarkus.acp.client.transport.type` | inferred | `stdio`, `websocket` or `http`; unset: the one of `stdio.command`, `websocket.uri` and `http.uri` that is set; with several set, it is required | +| `quarkus.acp.client.transport.stdio.command`, `.args`, `.env.` | | the agent process | +| `quarkus.acp.client.transport.websocket.uri`, `.connect-timeout` | `10s` | | +| `quarkus.acp.client.transport.http.uri` | | | +| `quarkus.acp.client.capabilities.read-text-file`, `.write-text-file`, `.terminal`, `.elicitation-form`, `.elicitation-url`, `.boolean-config-options` | `false` | advertise only what a customizer registers handlers for | + +Compared with the Spring Boot starter's `spring.acp.*`: the HTTP port is `quarkus.http.port`, and the +listener's `max-concurrent-streams-per-connection` is `quarkus.http.limits.max-concurrent-streams`. + +## Versions + +Quarkus 3.40.1 manages Jackson 2.21.7; the SDK is built with Jackson 2.22.3. Both lines carry the +2026 Jackson fixes (OSV lists no advisory for either, 2026-10-03), and the extension's integration +tests and the cross-SDK cells run on the Quarkus-managed 2.21.7. To run exactly the SDK's version, +import `jackson-bom` before the Quarkus BOM, as above; without it, Quarkus's version applies, which +is also supported. Reactor (3.8.7) is the same in both BOMs, and Netty is Quarkus's. The extension +excludes the Jetty jars of `acp-streamable-http-jetty`, whose servlet does not use Jetty. diff --git a/acp-quarkus/pom.xml b/acp-quarkus/pom.xml new file mode 100644 index 00000000..e9ec1ddf --- /dev/null +++ b/acp-quarkus/pom.xml @@ -0,0 +1,149 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-quarkus + ACP Quarkus + Quarkus extension for ACP agents and clients: an @AcpAgent bean served over stdio, Streamable HTTP or WebSocket on the Quarkus HTTP server, and ACP clients configured from quarkus.acp.* + + + + 0.80 + 0.89 + + + + + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + org.jspecify + jspecify + + + + com.agentclientprotocol + acp-integration + + + com.agentclientprotocol + acp-agent-support + ${project.version} + + + + com.agentclientprotocol + acp-http-core + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-vertx-http + + + io.smallrye.reactive + mutiny-reactor + + + org.slf4j + slf4j-api + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + com.tngtech.archunit + archunit-junit5 + test + + + org.mockito + mockito-core + test + + + com.agentclientprotocol + acp-test + ${project.version} + test + + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + + + diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpBuildTimeConfig.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpBuildTimeConfig.java new file mode 100644 index 00000000..0c2d80d7 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpBuildTimeConfig.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus; + +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * The {@code quarkus.acp.*} configuration fixed when the application is built: whether the + * application's {@code @AcpAgent} class is served, over which transport, and at which HTTP path. + * Set these in {@code application.properties} before building; changing them needs a rebuild. + * Everything read when the application starts (timeouts, endpoint limits, the client) is in + * {@link AcpRuntimeConfig}. + * + *

    The extension reads this configuration itself; an application rarely injects it. The + * properties map onto {@code AcpAgentSettings} of {@code acp-integration}, the framework-neutral + * settings the Spring Boot and Micronaut integrations fill from their own keys. The module's README + * lists every property and walks through an agent. + * + * @author Mark Pollack + */ +@ConfigMapping(prefix = "quarkus.acp") +@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public interface AcpBuildTimeConfig { + + /** + * Returns the agent's build-time properties, {@code quarkus.acp.agent.*}. + * @return the agent settings + */ + Agent agent(); + + /** The {@code quarkus.acp.agent.*} properties fixed at build time. */ + interface Agent { + + /** + * Whether the application's {@code @AcpAgent} bean is served. Off, the bean is an ordinary + * bean and no agent transport starts: the application is a client only, and a second + * {@code @AcpAgent} class is no longer an error. Maps to {@code AcpAgentSettings.enabled}. + * @return whether the agent is served + */ + @WithDefault("true") + boolean enabled(); + + /** + * Returns the agent transport's build-time properties, + * {@code quarkus.acp.agent.transport.*}. + * @return the transport settings + */ + Transport transport(); + + } + + /** The {@code quarkus.acp.agent.transport.*} properties fixed at build time. */ + interface Transport { + + /** + * The transport the agent is served over: {@code stdio}, or {@code http} for Streamable + * HTTP and WebSocket on the Quarkus HTTP server ({@code websocket} means the same). With + * {@code stdio}, the console log goes to standard error, the banner is off and the HTTP + * listener is disabled unless set otherwise, because standard output carries the protocol; + * the agent reads standard input, and an application bean of type {@code AcpAgentTransport} + * replaces stdio. With {@code http}, the agent is served on the Quarkus HTTP server's port + * ({@code quarkus.http.port}), not a port of its own. Maps to + * {@code AcpAgentSettings.transport}. + * @return the transport type + */ + @WithDefault("stdio") + AcpTransportType type(); + + /** + * Returns the Streamable HTTP endpoint's build-time properties, + * {@code quarkus.acp.agent.transport.http.*}. + * @return the HTTP settings + */ + Http http(); + + } + + /** The {@code quarkus.acp.agent.transport.http.*} properties fixed at build time. */ + interface Http { + + /** + * The path of the Streamable HTTP endpoint on the Quarkus HTTP server's router, under + * {@code quarkus.http.root-path}. WebSocket upgrades are accepted on the same path. Maps + * to {@code AcpAgentSettings.Http.path}. + * @return the endpoint path + */ + @WithDefault("/acp") + String path(); + + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpRuntimeConfig.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpRuntimeConfig.java new file mode 100644 index 00000000..cb130e69 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/AcpRuntimeConfig.java @@ -0,0 +1,440 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalInt; + +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.quarkus.runtime.configuration.MemorySize; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * The {@code quarkus.acp.*} configuration read when the application starts: the agent's timeouts + * and HTTP endpoint limits, and the ACP client's transport, timeouts and capabilities. Set these in + * {@code application.properties} or any Quarkus configuration source; they can change without a + * rebuild. What is fixed at build time (whether the agent is served, its transport and path) is in + * {@link AcpBuildTimeConfig}. + * + *

    An unset optional value keeps the SDK's default, which each property names. Durations take + * Quarkus' forms ({@code 30s}, {@code 500ms}, {@code PT1M}); sizes take {@code MemorySize} forms + * ({@code 16M}). The properties map onto {@code AcpAgentSettings} and {@code AcpClientSettings} of + * {@code acp-integration}, the framework-neutral settings the Spring Boot and Micronaut + * integrations fill from their own keys, so each one means the same there. The module's README + * lists every property. + * + * @author Mark Pollack + */ +@ConfigMapping(prefix = "quarkus.acp") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface AcpRuntimeConfig { + + /** + * Returns the agent's run-time properties, {@code quarkus.acp.agent.*}. + * @return the agent settings + */ + Agent agent(); + + /** + * Returns the client's properties, {@code quarkus.acp.client.*}. + * @return the client settings + */ + Client client(); + + /** + * The executor the agent's handler methods and the client's WebSocket and Streamable HTTP + * transports run on, {@code quarkus.acp.handler-executor}: + *

      + *
    • {@code managed} (the default): Quarkus' {@code ManagedExecutor}, the worker pool, which + * propagates the MicroProfile Context Propagation contexts of the submitting thread, as + * Quarkus' own blocking work does.
    • + *
    • {@code virtual}: on JDK 21 and later, Quarkus' virtual-thread executor + * ({@code @VirtualThreads}), a virtual thread per handler call; before JDK 21, as + * {@code managed}. It keeps the Vert.x context of the thread that submits the call, but + * propagates no MicroProfile Context Propagation context: a handler has no CDI request + * context of the caller's (a {@code @RequestScoped} bean needs + * {@code @ActivateRequestContext} on the handler method), and no security identity, + * transaction or OpenTelemetry context captured from the submitting thread. On JDK 21 to 23 + * a virtual thread that blocks inside a {@code synchronized} block pins its carrier thread + * (JDK 24 removed that, JEP 491). Opt in, as with Quarkus' {@code @RunOnVirtualThread}.
    • + *
    + * @return the handler executor + */ + @WithDefault("managed") + HandlerExecutor handlerExecutor(); + + /** The values of {@code quarkus.acp.handler-executor}. */ + enum HandlerExecutor { + + /** Quarkus' virtual-thread executor on JDK 21 and later, else as {@link #MANAGED}. */ + VIRTUAL, + + /** Quarkus' {@code ManagedExecutor}, its worker pool. */ + MANAGED + + } + + /** The {@code quarkus.acp.agent.*} properties read at startup. */ + interface Agent { + + /** + * How long the agent waits for the client to answer a request it sent, such as a permission + * request or a file read. Unset keeps the SDK default, 60 seconds. Maps to + * {@code AcpAgentSupport.Builder.requestTimeout}. + * @return the request timeout + */ + Optional requestTimeout(); + + /** + * How long a cancelled prompt may run before the SDK answers it {@code cancelled} itself: + * the time a {@code @Prompt} method has to return after the client sends + * {@code session/cancel}, after which its thread is interrupted. Unset keeps the SDK + * default, 60 seconds; zero for no limit. Maps to + * {@code AcpAgentSupport.Builder.cancelGracePeriod}. + * @return the cancel grace period + */ + Optional cancelGracePeriod(); + + /** + * The longest a prompt turn may run before the agent answers it with error {@code -32800} + * (request cancelled). Unset keeps the SDK default, no limit; zero also means no limit. + * Maps to {@code AcpAgentSupport.Builder.maxPromptDuration}. + * @return the maximum prompt duration + */ + Optional maxPromptDuration(); + + /** + * Whether the application exits when the stdio transport ends: the client closed standard + * input and every answer has been written. It applies to the single transport (stdio, or an + * {@code AcpAgentTransport} bean of the application's own), not to HTTP. Maps to + * {@code AcpAgentSettings.shutdownOnTransportEnd}. + * @return whether to exit on transport end + */ + @WithDefault("true") + boolean shutdownOnTransportEnd(); + + /** + * Returns the agent transport's run-time properties, {@code quarkus.acp.agent.transport.*}. + * @return the transport settings + */ + AgentTransport transport(); + + } + + /** The {@code quarkus.acp.agent.transport.*} properties read at startup. */ + interface AgentTransport { + + /** + * Returns the Streamable HTTP endpoint's limits, + * {@code quarkus.acp.agent.transport.http.*}. The port is the Quarkus HTTP port + * ({@code quarkus.http.port}). + * @return the HTTP settings + */ + AgentHttp http(); + + } + + /** + * The {@code quarkus.acp.agent.transport.http.*} properties: the limits of the Streamable HTTP + * and WebSocket endpoint served when {@code quarkus.acp.agent.transport.type} is {@code http}. + * They bound every buffer the endpoint keeps for a client; each unset value keeps the SDK + * default, and together they map to the SDK's {@code StreamableHttpAcpAgentTransportOptions}. + * The HTTP/2 stream limit is the Quarkus server's own, + * {@code quarkus.http.limits.max-concurrent-streams}. + */ + interface AgentHttp { + + /** + * The largest POST body and WebSocket text message accepted from a client. A larger POST + * body is answered 413, and a larger WebSocket message closes the connection. Unset keeps + * the SDK default, 16M. The Quarkus server has limits of its own, which an HTTP build + * raises to 16 MB ({@code quarkus.http.limits.max-body-size}, + * {@code quarkus.http.websocket-server.max-message-size} and {@code max-frame-size}): raise + * them too when you raise this past 16 MB. + * @return the inbound size limit + */ + Optional maxPostBodySize(); + + /** + * How often an open SSE stream gets a keep-alive comment, which stops proxies from cutting + * idle streams; zero turns them off. Unset keeps the SDK default, 15 seconds. + * @return the keep-alive interval + */ + Optional keepAliveInterval(); + + /** + * How many events one SSE stream keeps unsent while no client is reading it, to send when + * the client reconnects; one more closes the connection. Unset keeps the SDK default, 1024. + * @return the mailbox capacity + */ + OptionalInt mailboxCapacity(); + + /** + * How many events are queued for a client that is reading an SSE stream; one more detaches + * that client and keeps the events for its next GET. Unset keeps the SDK default, 1024. + * @return the pending SSE event limit + */ + OptionalInt maxPendingSseEvents(); + + /** + * How many WebSocket frames a connection queues for sending; one more closes the + * connection. Unset keeps the SDK default, 1024. + * @return the pending WebSocket frame limit + */ + OptionalInt maxWebSocketPendingFrames(); + + /** + * How many session streams one connection may open before the agent knows the session, as a + * client does before {@code session/load}; a further one is refused. Unset keeps the SDK + * default, 64. + * @return the provisional session limit + */ + OptionalInt maxProvisionalSessions(); + + /** + * How long a graceful shutdown waits for the connections to close before it closes them at + * once. When the application stops, every HTTP and WebSocket connection is closed first, + * within this timeout. Unset keeps the SDK default, 5 seconds. + * @return the shutdown timeout + */ + Optional shutdownTimeout(); + + /** + * How long a WebSocket connection may pass no frame in either direction before the + * endpoint closes it (1001, going away). Unset keeps the SDK default, 30 minutes. + * @return the WebSocket idle timeout + */ + Optional webSocketIdleTimeout(); + + /** + * How long a connection has to complete {@code initialize}: a WebSocket that has not sent + * it by then is closed (1008, policy violation), and a POST {@code initialize} the agent + * has not answered by then is answered 500. Unset keeps the SDK default, 30 seconds. + * @return the initialize timeout + */ + Optional initializeTimeout(); + + /** + * Browser origins accepted besides the loopback ones, such as + * {@code https://app.example.com}, or {@code *} for any. A request without an + * {@code Origin} header, or from {@code http(s)://localhost}, {@code 127.0.0.1} or + * {@code [::1]} on any port, is always accepted; any other origin is answered 403, over + * HTTP and on the WebSocket handshake. Unset accepts none besides the loopback ones. The + * Quarkus HTTP server keeps its own bind address ({@code quarkus.http.host}). + * @return the allowed origins + */ + Optional> allowedOrigins(); + + } + + /** + * The {@code quarkus.acp.client.*} properties: the ACP client beans ({@code AcpAsyncClient}, + * {@code AcpSyncClient}) the application can inject. They are used when a client bean is first + * injected; an application that injects none needs no client properties. + */ + interface Client { + + /** + * How long the client waits for the agent to answer a request other than a prompt. Unset + * keeps the SDK default, 60 seconds. Closing the client when the application stops also + * waits at most this long plus 10 seconds. Maps to + * {@code AcpClient.AsyncSpec.requestTimeout}. + * @return the request timeout + */ + Optional requestTimeout(); + + /** + * How long a prompt turn may take before the client cancels it: {@code prompt} then fails + * with a {@code TimeoutException} and the client sends the agent a + * {@code $/cancel_request}. Unset (the default) for no limit, and zero also means none: + * prompts are not bound by the request timeout. Maps to + * {@code AcpClient.AsyncSpec.promptTimeout}. + * @return the prompt timeout + */ + Optional promptTimeout(); + + /** + * Returns the client transport's properties, {@code quarkus.acp.client.transport.*}. + * @return the transport settings + */ + ClientTransport transport(); + + /** + * Returns the advertised capabilities, {@code quarkus.acp.client.capabilities.*}. + * @return the capabilities + */ + Capabilities capabilities(); + + } + + /** + * The {@code quarkus.acp.client.transport.*} properties: which transport the client uses, and + * each transport's own settings. Set the command or URI of one transport and it is chosen. + */ + interface ClientTransport { + + /** + * The transport: {@code stdio}, {@code websocket} or {@code http}. Unset, it is the one of + * {@code stdio.command}, {@code websocket.uri} and {@code http.uri} that is set; with + * several set, it must be given. A type that is set wins, and creating the client fails + * when its own command or URI is missing, naming that property. + * @return the transport type + */ + Optional type(); + + /** + * Returns the stdio transport's properties, {@code quarkus.acp.client.transport.stdio.*}. + * @return the stdio settings + */ + Stdio stdio(); + + /** + * Returns the WebSocket transport's properties, + * {@code quarkus.acp.client.transport.websocket.*}. + * @return the WebSocket settings + */ + WebSocket websocket(); + + /** + * Returns the Streamable HTTP transport's properties, + * {@code quarkus.acp.client.transport.http.*}. + * @return the HTTP settings + */ + Http http(); + + } + + /** + * The {@code quarkus.acp.client.transport.stdio.*} properties: the agent process a stdio client + * launches when the client is created, then talks to over the process's standard input and + * output. + */ + interface Stdio { + + /** + * The command that starts the agent, such as an executable on the PATH. Setting it selects + * the stdio transport. Maps to {@code AcpClientSettings.Stdio.command}. + * @return the command + */ + Optional command(); + + /** + * The command's arguments, in order, such as {@code --acp}; a comma separates several. + * Default none. + * @return the arguments + */ + Optional> args(); + + /** + * Environment variables added to the agent process, one property per variable + * ({@code quarkus.acp.client.transport.stdio.env.}). The process inherits the + * application's whole environment, and these add to it or replace a variable of the same + * name. Default none. + * @return the environment + */ + Map env(); + + } + + /** The {@code quarkus.acp.client.transport.websocket.*} properties: the agent's WebSocket endpoint. */ + interface WebSocket { + + /** + * The endpoint, a {@code ws://} or {@code wss://} URI such as + * {@code ws://localhost:8080/acp}. Setting it selects the WebSocket transport. + * @return the URI + */ + Optional uri(); + + /** + * How long the client waits for the WebSocket to connect, the handshake. Default 10 + * seconds, which replaces the transport's own default. Maps to + * {@code WebSocketAcpClientTransport.connectTimeout}. + * @return the connect timeout + */ + @WithDefault("10s") + Duration connectTimeout(); + + } + + /** The {@code quarkus.acp.client.transport.http.*} properties: the agent's Streamable HTTP endpoint. */ + interface Http { + + /** + * The endpoint, an {@code http://} or {@code https://} URI such as + * {@code http://localhost:8080/acp}. Setting it selects the Streamable HTTP transport. + * @return the URI + */ + Optional uri(); + + } + + /** + * The {@code quarkus.acp.client.capabilities.*} properties: the client capabilities advertised + * in {@code initialize}, all off by default. Advertise only what the application registers + * handlers for, through an {@code AcpClientCustomizer} bean; a capability turned on without its + * handler makes creating the client fail, naming the property. Together they map to + * {@code AcpClientSettings.Capabilities}. + */ + interface Capabilities { + + /** + * Whether the client advertises that it serves {@code fs/read_text_file}. Needs a read-file + * handler. + * @return the capability + */ + @WithDefault("false") + boolean readTextFile(); + + /** + * Whether the client advertises that it serves {@code fs/write_text_file}. Needs a + * write-file handler. + * @return the capability + */ + @WithDefault("false") + boolean writeTextFile(); + + /** + * Whether the client advertises that it serves the {@code terminal/*} methods. Needs the + * terminal handlers. + * @return the capability + */ + @WithDefault("false") + boolean terminal(); + + /** + * Whether the client advertises that it serves form-mode {@code elicitation/create}. Needs + * an elicitation handler. + * @return the capability + */ + @WithDefault("false") + boolean elicitationForm(); + + /** + * Whether the client advertises that it serves URL-mode {@code elicitation/create}. Needs + * an elicitation handler. + * @return the capability + */ + @WithDefault("false") + boolean elicitationUrl(); + + /** + * Whether the client advertises that it accepts boolean session config options. Needs no + * handler. + * @return the capability + */ + @WithDefault("false") + boolean booleanConfigOptions(); + + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/package-info.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/package-info.java new file mode 100644 index 00000000..c814b4e9 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/package-info.java @@ -0,0 +1,12 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Quarkus extension for the ACP Java SDK: its configuration and the contracts an + * application implements. + */ +@NullMarked +package com.agentclientprotocol.sdk.quarkus; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentAssembly.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentAssembly.java new file mode 100644 index 00000000..081f6217 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentAssembly.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.ArrayList; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpAgents; +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import io.quarkus.arc.All; +import io.quarkus.arc.Arc; +import jakarta.enterprise.inject.Any; +import jakarta.inject.Singleton; + +/** + * Assembles the {@link AcpAgentSupport} builder for the application's {@code @AcpAgent} bean + * ({@link AcpAgents}): the bean instance from the container, the configured timeouts, and the + * interceptor, argument resolver and return value handler beans, then the Mutiny return types + * ({@code Uni}, and {@code Multi} from a prompt handler). The stdio host and the HTTP endpoint + * build their agents from it; it exists only when an agent is served. Part of the extension's + * wiring; an application does not use it directly. + *

    Handler methods block, so they run on the executor {@code quarkus.acp.handler-executor} + * selects, never the Vert.x event loop and never a second pool of the SDK's: Quarkus' + * {@code ManagedExecutor} worker pool by default, which propagates the application's contexts, or + * with {@code virtual} its virtual-thread executor on JDK 21 and later. + *

    Handlers are discovered on the user's class (from the build-time index), not on the instance's + * class, so a container subclass of the bean does not hide them.

    + * + * @author Mark Pollack + */ +@Singleton +public class AcpAgentAssembly { + + private final AcpAgentClass agentClass; + + private final AcpAgentSettings settings; + + private final List interceptors; + + private final List argumentResolvers; + + private final List returnValueHandlers; + + AcpAgentAssembly(AcpAgentClass agentClass, AcpBuildTimeConfig buildTime, AcpRuntimeConfig runtime, + @All List interceptors, @All List argumentResolvers, + @All List returnValueHandlers) { + this.agentClass = agentClass; + this.settings = AcpSettings.agent(buildTime, runtime); + this.interceptors = interceptors; + this.argumentResolvers = argumentResolvers; + List handlers = new ArrayList<>(returnValueHandlers); + handlers.add(new UniReturnValueHandler()); + handlers.add(new MultiReturnValueHandler()); + this.returnValueHandlers = handlers; + } + + /** + * Returns the agent settings, read from the configuration when this bean was created. + * @return the settings + */ + public AcpAgentSettings settings() { + return settings; + } + + /** + * Returns a new builder holding the agent bean and every setting except the transport. Each + * call looks the bean up in the container again. + * @return a new builder + */ + public AcpAgentSupport.Builder builder() { + return builder(agentClass.type()); + } + + /** + * Returns a new factory serving one agent per remote connection, all on the one bean, for the + * HTTP endpoint. Every connection's agent calls the same bean, so its handler methods must be + * thread-safe. + * @return the agent factory + */ + public AcpAgentFactory factory() { + return builder().buildFactory(); + } + + private AcpAgentSupport.Builder builder(Class type) { + T bean = Arc.container().select(type, Any.Literal.INSTANCE).get(); + return AcpAgents.builder(new AgentCandidate<>(type.getName(), type, () -> bean), settings, interceptors, + argumentResolvers, returnValueHandlers, Arc.container().select(AcpExecutors.class).get().handlerExecutor()); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentClass.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentClass.java new file mode 100644 index 00000000..34e4a541 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpAgentClass.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +/** + * The application's {@code @AcpAgent} class, found in the index at build time and handed to the + * running application as a synthetic bean. {@link AcpAgentAssembly} reads the handler methods from + * this class. The bean exists only when the agent is enabled and the application has an + * {@code @AcpAgent} class. Part of the extension's wiring; an application does not use it directly. + * + * @param type the annotated class (the user's class, never a proxy or subclass) + * @author Mark Pollack + */ +public record AcpAgentClass(Class type) { +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpClientProducers.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpClientProducers.java new file mode 100644 index 00000000..2edc8e7a --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpClientProducers.java @@ -0,0 +1,108 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.List; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.integration.AcpClientHost; +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpClientTransports; +import com.agentclientprotocol.sdk.integration.AcpClients; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import io.quarkus.arc.All; +import io.quarkus.arc.DefaultBean; +import jakarta.enterprise.inject.Disposes; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +/** + * Produces the ACP client beans an application injects: a transport from + * {@code quarkus.acp.client.transport.*}, one {@link AcpAsyncClient} on it with the configured + * timeouts and capabilities, then every {@link AcpClientCustomizer}, and an {@link AcpSyncClient} + * over that same client ({@link AcpClients}), so both share one connection. Inject + * {@code AcpSyncClient} or {@code AcpAsyncClient}; the producer itself is part of the extension's + * wiring. + * + *

    Each bean is created when first injected, so an application that injects none needs no client + * configuration, and an application bean of any of these types replaces the default. Creating the + * client connects its transport (for stdio, starts the agent process); the application then calls + * {@code initialize()}. The client (and with it its transport) is closed once, gracefully, when the + * application stops, waiting at most its request timeout plus 10 seconds. + * + * @author Mark Pollack + */ +@Singleton +public class AcpClientProducers { + + private final AcpRuntimeConfig config; + + private final AcpExecutors executors; + + AcpClientProducers(AcpRuntimeConfig config, AcpExecutors executors) { + this.config = config; + this.executors = executors; + } + + /** + * Produces the transport the configuration selects, by the SDK's rule + * ({@link AcpClientTransports}): a {@code type} that is set wins; otherwise the one transport + * whose command or URI is set. Not connected yet; the client connects it. The WebSocket and HTTP + * transports run on the executor {@code quarkus.acp.handler-executor} selects (the + * {@code ManagedExecutor} by default) and create no pool of their own. + * @return the client transport + * @throws IllegalStateException when no transport is configured, several are without a type, or + * the type lacks its command or URI; the message names the properties + */ + @Produces + @Singleton + @DefaultBean + public AcpClientTransport acpClientTransport() { + String prefix = AcpSettings.CLIENT_PREFIX + ".transport."; + return AcpClientTransports.create(settings(), AcpSettings.CLIENT_PREFIX, executors.transportThreads()) + .orElseThrow(() -> new IllegalStateException("No ACP client transport is configured: set " + prefix + + "stdio.command, " + prefix + "websocket.uri or " + prefix + "http.uri")); + } + + /** + * Produces the client, customized by every {@link AcpClientCustomizer} bean, highest priority + * first. Session updates are logged at DEBUG unless a customizer consumes them. + * @param transport the client transport + * @param customizers the customizer beans + * @return the async client + * @throws IllegalStateException if a capability property is true but no customizer registers + * its handler; the message names the property + */ + @Produces + @Singleton + @DefaultBean + public AcpAsyncClient acpAsyncClient(AcpClientTransport transport, @All List customizers) { + return AcpClients.async(transport, settings(), customizers, AcpSettings.CLIENT_PREFIX); + } + + /** + * Produces the blocking facade over the async client bean, on the same connection. + * @param client the async client + * @return the sync client + */ + @Produces + @Singleton + @DefaultBean + public AcpSyncClient acpSyncClient(AcpAsyncClient client) { + return AcpClients.sync(client); + } + + void close(@Disposes AcpAsyncClient client) { + new AcpClientHost(client).close(settings().closeTimeout()); + } + + private AcpClientSettings settings() { + return AcpSettings.client(config.client()); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutors.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutors.java new file mode 100644 index 00000000..a10bf8c8 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutors.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.eclipse.microprofile.context.ManagedExecutor; + +/** + * The executor {@code quarkus.acp.handler-executor} selects: Quarkus' {@code ManagedExecutor} by + * default, or with {@code virtual} its virtual-thread executor ({@code @VirtualThreads}) on JDK 21 + * and later. The + * agent's handler methods and the client's network transports run on it; Quarkus owns it and + * shuts it down. Part of the extension's wiring. + */ +@Singleton +public class AcpExecutors { + + private final ExecutorService executor; + + @Inject + AcpExecutors(AcpRuntimeConfig config, + @io.quarkus.virtual.threads.VirtualThreads Instance virtualThreads, + Instance managed) { + this(config, virtualThreads, managed, VirtualThreads.isSupported()); + } + + /** The choice with whether the JDK has virtual threads given, so tests make it on any JDK. */ + AcpExecutors(AcpRuntimeConfig config, Instance virtualThreads, Instance managed, + boolean jdkHasVirtualThreads) { + this.executor = (config.handlerExecutor() == AcpRuntimeConfig.HandlerExecutor.VIRTUAL && jdkHasVirtualThreads + && virtualThreads.isResolvable()) ? virtualThreads.get() : managed.get(); + } + + /** For tests: everything on {@code executor}. */ + AcpExecutors(ExecutorService executor) { + this.executor = executor; + } + + /** + * Returns the executor the handler methods run on. + * @return the executor + */ + public ExecutorService handlerExecutor() { + return executor; + } + + /** + * Returns the threads the client's WebSocket and Streamable HTTP transports run on: the same + * executor. + * @return the transport threads + */ + public AcpTransportThreads transportThreads() { + return AcpTransportThreads.executor(executor); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpAgentHost.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpAgentHost.java new file mode 100644 index 00000000..b5e7b4d4 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpAgentHost.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.time.Duration; + +import io.quarkus.runtime.ShutdownEvent; +import jakarta.annotation.Priority; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Drains the ACP endpoint first when the application stops: every open SSE stream and WebSocket is + * an in-flight request, so a graceful HTTP shutdown would otherwise wait for clients that never + * finish. SSE streams get a closing comment and complete, WebSockets close with 1001, and + * in-flight prompts are cancelled, within the endpoint's shutdown timeout + * ({@code quarkus.acp.agent.transport.http.shutdown-timeout}). The extension adds it for an HTTP + * agent. An application does not call it; a test can inject it to count the open connections. + * + * @author Mark Pollack + */ +@Singleton +public class AcpHttpAgentHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpHttpAgentHost.class); + + private final AcpVertxHost host; + + AcpHttpAgentHost(AcpVertxHost host) { + this.host = host; + } + + void stop(@Observes @Priority(0) ShutdownEvent event) { + drain(); + } + + /** + * Drains the endpoint now, as the application's shutdown does, waiting at most the endpoint's + * shutdown timeout plus a second. + */ + public void drain() { + Duration timeout = host.endpoint().options().shutdownTimeout().plusSeconds(1); + try { + host.closeGracefully().block(timeout); + } + catch (RuntimeException e) { + logger.warn("ACP connections did not close within {}: {}", timeout, e.toString()); + } + } + + /** + * Returns how many ACP connections are open: Streamable HTTP and WebSocket together. + * @return the connection count + */ + public int activeConnectionCount() { + return host.activeConnectionCount(); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpConfigBuilder.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpConfigBuilder.java new file mode 100644 index 00000000..fb2aeca1 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpConfigBuilder.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.Map; + +import io.quarkus.runtime.configuration.ConfigBuilder; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; + +/** + * Configuration defaults for an application that serves its agent over HTTP, so the Quarkus HTTP + * server admits what the SDK's own listener admits: a 16 MB POST body + * ({@code quarkus.http.limits.max-body-size=16M}) and WebSocket message, sent as one frame or many + * ({@code quarkus.http.websocket-server.max-message-size} and {@code max-frame-size}), and 1024 + * concurrent HTTP/2 streams per connection ({@code quarkus.http.limits.max-concurrent-streams}; + * every SSE stream is one, and a client holds one per session). They are a low-ordinal source, so + * the application's own configuration still wins. The extension adds it for an HTTP agent. Part of + * the extension's wiring; an application does not use it directly. + * + * @author Mark Pollack + */ +public class AcpHttpConfigBuilder implements ConfigBuilder { + + static final Map DEFAULTS = Map.of("quarkus.http.limits.max-body-size", "16M", + "quarkus.http.websocket-server.max-message-size", "16777216", + "quarkus.http.websocket-server.max-frame-size", "16777216", + "quarkus.http.limits.max-concurrent-streams", "1024"); + + @Override + public SmallRyeConfigBuilder configBuilder(SmallRyeConfigBuilder builder) { + return builder + .withSources(new PropertiesConfigSource(DEFAULTS, "ACP HTTP agent defaults", AcpStdioConfigBuilder.ORDINAL)); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpRecorder.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpRecorder.java new file mode 100644 index 00000000..23880679 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpRecorder.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.function.Supplier; + +import io.quarkus.runtime.annotations.Recorder; + +/** + * Carries what the build found into the running application: Quarkus records the calls made on it + * at build time and replays them at startup. It hands over the {@code @AcpAgent} class as an + * {@link AcpAgentClass} bean. Part of the extension's wiring; an application does not use it. + * + * @author Mark Pollack + */ +@Recorder +public class AcpRecorder { + + /** + * Returns a supplier of the {@link AcpAgentClass} bean, which loads the {@code @AcpAgent} class + * with the application's class loader when the bean is created. + * @param className the class's binary name + * @return a supplier of the agent class bean; it throws {@link IllegalStateException} if the + * class cannot be loaded + */ + public Supplier agentClass(String className) { + return () -> { + try { + return new AcpAgentClass( + Class.forName(className, false, Thread.currentThread().getContextClassLoader())); + } + catch (ClassNotFoundException e) { + throw new IllegalStateException("The @AcpAgent class " + className + " is not loadable", e); + } + }; + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettings.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettings.java new file mode 100644 index 00000000..45e47753 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettings.java @@ -0,0 +1,106 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.List; + +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; + +/** + * Turns the {@code quarkus.acp.*} configuration ({@link AcpBuildTimeConfig}, + * {@link AcpRuntimeConfig}) into the SDK's framework-neutral settings, {@link AcpAgentSettings} and + * {@link AcpClientSettings}, which the extension's beans hand to {@code acp-integration}. An unset + * optional property stays unset, so the SDK applies its default. The Quarkus HTTP server serves the + * endpoint, so the SDK listener's own settings (its port and HTTP/2 stream limit) keep their + * defaults and are not used. Part of the extension's wiring; an application does not use it + * directly. + * + * @author Mark Pollack + */ +public final class AcpSettings { + + /** The prefix of the client's configuration, named in errors. */ + public static final String CLIENT_PREFIX = "quarkus.acp.client"; + + private AcpSettings() { + } + + /** + * Returns the agent settings: the build-time ones (enabled, transport, path) with the run-time + * ones (timeouts, shutdown on transport end, endpoint limits). + * @param buildTime the settings fixed at build time + * @param runtime the settings read at startup + * @return the settings + */ + public static AcpAgentSettings agent(AcpBuildTimeConfig buildTime, AcpRuntimeConfig runtime) { + AcpRuntimeConfig.Agent agent = runtime.agent(); + AcpAgentSettings.Builder builder = limits(AcpAgentSettings.builder(), agent.transport().http()) + .enabled(buildTime.agent().enabled()) + .requestTimeout(agent.requestTimeout().orElse(null)) + .cancelGracePeriod(agent.cancelGracePeriod().orElse(null)) + .maxPromptDuration(agent.maxPromptDuration().orElse(null)) + .shutdownOnTransportEnd(agent.shutdownOnTransportEnd()) + .transport(buildTime.agent().transport().type()) + .path(buildTime.agent().transport().http().path()); + return builder.build(); + } + + /** + * The endpoint limits onto a builder; each unset one keeps the SDK default. + * @param builder the builder + * @param http the configured limits + * @return the builder + */ + static AcpAgentSettings.Builder limits(AcpAgentSettings.Builder builder, AcpRuntimeConfig.AgentHttp http) { + return builder.maxPostBodyBytes(http.maxPostBodySize().map(size -> size.asLongValue()).orElse(null)) + .keepAliveInterval(http.keepAliveInterval().orElse(null)) + .mailboxCapacity(http.mailboxCapacity().isPresent() ? http.mailboxCapacity().getAsInt() : null) + .maxPendingSseEvents(http.maxPendingSseEvents().isPresent() ? http.maxPendingSseEvents().getAsInt() : null) + .maxWebSocketPendingFrames( + http.maxWebSocketPendingFrames().isPresent() ? http.maxWebSocketPendingFrames().getAsInt() : null) + .maxProvisionalSessions( + http.maxProvisionalSessions().isPresent() ? http.maxProvisionalSessions().getAsInt() : null) + .shutdownTimeout(http.shutdownTimeout().orElse(null)) + .webSocketIdleTimeout(http.webSocketIdleTimeout().orElse(null)) + .initializeTimeout(http.initializeTimeout().orElse(null)) + .allowedOrigins(http.allowedOrigins().orElse(List.of())); + } + + /** + * Returns the client settings: timeouts, transport and capabilities. + * @param client the client configuration + * @return the settings + */ + public static AcpClientSettings client(AcpRuntimeConfig.Client client) { + AcpRuntimeConfig.ClientTransport transport = client.transport(); + return AcpClientSettings.builder() + .requestTimeout(client.requestTimeout().orElse(null)) + .promptTimeout(client.promptTimeout().orElse(null)) + .transport(transport.type().orElse(null)) + .stdioCommand(transport.stdio().command().orElse(null)) + .stdioArgs(transport.stdio().args().orElse(List.of())) + .stdioEnv(transport.stdio().env()) + .websocketUri(transport.websocket().uri().orElse(null)) + .websocketConnectTimeout(transport.websocket().connectTimeout()) + .httpUri(transport.http().uri().orElse(null)) + .capabilities(capabilities(client.capabilities())) + .build(); + } + + /** + * The capabilities to advertise. + * @param capabilities the configured capabilities + * @return the capabilities + */ + static AcpClientSettings.Capabilities capabilities(AcpRuntimeConfig.Capabilities capabilities) { + return new AcpClientSettings.Capabilities(capabilities.readTextFile(), capabilities.writeTextFile(), + capabilities.terminal(), capabilities.elicitationForm(), capabilities.elicitationUrl(), + capabilities.booleanConfigOptions()); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioAgentHost.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioAgentHost.java new file mode 100644 index 00000000..34e883b1 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioAgentHost.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.integration.AcpAgentHost; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Serves the {@code @AcpAgent} bean over one agent transport, stdio unless the application provides + * its own {@link AcpAgentTransport} bean, on an {@link AcpAgentHost}. The extension adds it when + * {@code quarkus.acp.agent.transport.type} is {@code stdio} and the application has an + * {@code @AcpAgent} class. The agent starts with the application; when the transport ends (the + * client closed standard input and every answer has been written) the application exits, unless + * {@code quarkus.acp.agent.shutdown-on-transport-end} is off; when the application stops, the agent + * closes gracefully, waiting at most 30 seconds. An application does not call it; a test can inject + * it to reach the running agent. + * + * @author Mark Pollack + */ +@Singleton +public class AcpStdioAgentHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpStdioAgentHost.class); + + /** How long stopping waits for the agent's graceful close. */ + private static final Duration STOP_TIMEOUT = Duration.ofSeconds(30); + + private final AcpAgentAssembly assembly; + + private final AcpAgentTransport transport; + + private volatile @Nullable AcpAgentSupport agent; + + private volatile @Nullable AcpAgentHost host; + + AcpStdioAgentHost(AcpAgentAssembly assembly, AcpAgentTransport transport) { + this.assembly = assembly; + this.transport = transport; + } + + void start(@Observes StartupEvent event) { + AcpAgentSupport support = assembly.builder().transport(transport).build(); + Runnable onTransportEnd = assembly.settings().shutdownOnTransportEnd() ? AcpStdioAgentHost::exit : () -> { + }; + AcpAgentHost started = new AcpAgentHost(support, onTransportEnd); + this.agent = support; + this.host = started; + started.start(); + } + + void stop(@Observes ShutdownEvent event) { + AcpAgentHost current = this.host; + if (current != null) { + current.stop(STOP_TIMEOUT); + } + } + + private static void exit() { + logger.info("ACP agent transport ended; stopping the application"); + Quarkus.asyncExit(); + } + + /** + * Returns the running agent, once the application has started. + * @return the agent, or {@code null} before startup + */ + public @Nullable AcpAgentSupport agent() { + return agent; + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioConfigBuilder.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioConfigBuilder.java new file mode 100644 index 00000000..de942079 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioConfigBuilder.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.Map; + +import io.quarkus.runtime.configuration.ConfigBuilder; +import io.smallrye.config.PropertiesConfigSource; +import io.smallrye.config.SmallRyeConfigBuilder; + +/** + * Configuration defaults for an application that serves its agent over stdio, where standard output + * carries the protocol: {@code quarkus.log.console.stderr=true} (the console log goes to standard + * error), {@code quarkus.banner.enabled=false} and {@code quarkus.http.host-enabled=false} (no HTTP + * listener). They are a low-ordinal source (50), so the application's own configuration still wins. + * The extension adds it for a stdio agent, for static and runtime initialization alike, because the + * console log handler is created during static initialization. Part of the extension's wiring; an + * application does not use it directly. + * + * @author Mark Pollack + */ +public class AcpStdioConfigBuilder implements ConfigBuilder { + + /** + * Below every application source (application.properties is 250), above the defaults + * the configuration roots declare, which a default-values source would not override. + */ + static final int ORDINAL = 50; + + static final Map DEFAULTS = Map.of("quarkus.log.console.stderr", "true", "quarkus.banner.enabled", + "false", "quarkus.http.host-enabled", "false"); + + @Override + public SmallRyeConfigBuilder configBuilder(SmallRyeConfigBuilder builder) { + return builder.withSources(new PropertiesConfigSource(DEFAULTS, "ACP stdio agent defaults", ORDINAL)); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioTransportProducer.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioTransportProducer.java new file mode 100644 index 00000000..b995a413 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpStdioTransportProducer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import com.agentclientprotocol.sdk.integration.AcpAgentTransports; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import io.quarkus.arc.DefaultBean; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; + +/** + * Produces the stdio agent transport that {@link AcpStdioAgentHost} serves the agent on. It is a + * default bean, so an {@link AcpAgentTransport} bean of the application's own replaces it (a test, + * for one, provides an in-memory transport). The extension adds it only for a stdio agent. Part of + * the extension's wiring; an application does not use it directly. + * + * @author Mark Pollack + */ +@Singleton +public class AcpStdioTransportProducer { + + /** + * Returns the transport over the process's standard input and output, which must then carry + * nothing else. The agent host closes it when it closes the agent. + * @return the stdio transport + */ + @Produces + @Singleton + @DefaultBean + public AcpAgentTransport stdioAgentTransport() { + return AcpAgentTransports.stdio(); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpVertxHost.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpVertxHost.java new file mode 100644 index 00000000..f3ef19af --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpVertxHost.java @@ -0,0 +1,153 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.AcpWsHandler; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.http.ServerWebSocket; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.RoutingContext; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * The ACP endpoint on the Quarkus HTTP server: a Vert.x route at + * {@code quarkus.acp.agent.transport.http.path} on Quarkus's own router, so the application's + * port, TLS, HTTP security policies and observability apply to it. A host of the SDK's + * framework-neutral {@link AcpHttpEndpoint}: every request on the path goes to the endpoint, a + * WebSocket upgrade to its handshake, and the route only adapts Vert.x I/O. No servlet container + * is involved. The extension adds it for an HTTP agent. Part of the extension's wiring; an + * application does not use it directly. + * + * @author Mark Pollack + */ +@Singleton +public class AcpVertxHost { + + private static final Logger logger = LoggerFactory.getLogger(AcpVertxHost.class); + + private final AcpHttpEndpoint endpoint; + + private final String path; + + AcpVertxHost(AcpAgentAssembly assembly, AcpRuntimeConfig config, AcpBuildTimeConfig buildConfig) { + this.endpoint = AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), assembly.factory(), + options(config.agent().transport().http())); + String configured = buildConfig.agent().transport().http().path(); + this.path = configured.startsWith("/") ? configured : "/" + configured; + } + + /** + * The SDK options for the configured limits and allowed origins; an unset one keeps the SDK + * default. The bind address is Quarkus's own. + * @param http the configured limits + * @return the transport options + */ + static StreamableHttpAcpAgentTransportOptions options(AcpRuntimeConfig.AgentHttp http) { + return AcpSettings.limits(AcpAgentSettings.builder(), http).build().toOptions(false); + } + + /** + * Mounts the endpoint at its path, in the router's ordinary order: after Quarkus's own + * authentication and authorization handlers, so HTTP security policies on the path apply to + * every request and to the WebSocket handshake. + */ + void register(@Observes Router router) { + endpoint.start(); + router.route(path).handler(this::handle); + } + + /** + * Returns the endpoint this route serves. + * @return the endpoint + */ + public AcpHttpEndpoint endpoint() { + return endpoint; + } + + /** + * Drains the endpoint: SSE streams end with a closing comment, WebSockets close with 1001. + * @return completes when every connection has closed, within the shutdown timeout + */ + Mono closeGracefully() { + return endpoint.closeGracefully(); + } + + int activeConnectionCount() { + return endpoint.activeConnectionCount(); + } + + private void handle(RoutingContext context) { + VertxExchange exchange = new VertxExchange(context); + if (exchange.isWebSocketUpgrade()) { + upgrade(context, exchange); + return; + } + endpoint.handle(exchange).subscribe(reply -> write(context, reply), error -> { + logger.warn("ACP request failed: {}", error.toString()); + if (!context.response().ended()) { + context.response().setStatusCode(500).end(); + } + }); + } + + private void upgrade(RoutingContext context, VertxExchange exchange) { + AcpWsHandshake handshake = endpoint.webSocketHandshake(exchange); + if (handshake instanceof AcpWsHandshake.Refused refused) { + write(context, refused.reply()); + return; + } + AcpWsHandshake.Accepted accepted = (AcpWsHandshake.Accepted) handshake; + accepted.headers().forEach(context.response()::putHeader); + context.request().toWebSocket().onSuccess(socket -> open(socket, accepted)).onFailure(error -> { + // No connection exists before open: nothing to close. + logger.warn("ACP WebSocket upgrade failed: {}", error.getMessage()); + }); + } + + private static void open(ServerWebSocket socket, AcpWsHandshake.Accepted accepted) { + AcpWsHandler handler = accepted.open(new VertxWsOutbound(socket)); + socket.textMessageHandler(handler::onText); + socket.closeHandler(ignored -> { + Short code = socket.closeStatusCode(); + handler.onClose(code != null ? code : 1006, socket.closeReason()); + }); + socket.exceptionHandler(handler::onError); + } + + static void write(RoutingContext context, AcpHttpReply reply) { + HttpServerResponse response = context.response(); + if (response.ended() || response.closed()) { + return; + } + response.setStatusCode(reply.status()); + reply.headers().forEach(response::putHeader); + if (reply instanceof AcpHttpReply.EventStream stream) { + response.putHeader("Content-Type", AcpHttpReply.EVENT_STREAM); + response.setChunked(true); + stream.frames().subscribe(new VertxSseWriter(response)); + return; + } + if (reply instanceof AcpHttpReply.Body body) { + response.putHeader("Content-Type", body.contentType() + "; charset=utf-8"); + response.end(Buffer.buffer(body.bodyBytes())); + return; + } + response.end(); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/MultiReturnValueHandler.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/MultiReturnValueHandler.java new file mode 100644 index 00000000..61e11cdd --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/MultiReturnValueHandler.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlingException; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.subscription.Cancellable; +import org.jspecify.annotations.Nullable; + +/** + * Lets an {@code @Prompt} handler stream its reply as a Mutiny {@link Multi}: each + * {@code String} item is sent as an agent message chunk, each + * {@link AcpSchema.SessionUpdate} item as that update, each {@link AcpSchema.ContentBlock} + * as a message chunk holding it, and the turn ends ({@code end_turn}) when the stream + * completes. A failed stream fails the prompt. When the prompt is cancelled the stream's + * subscription is cancelled and the turn ends {@code cancelled}. Only {@code @Prompt} may + * return a {@code Multi}. + * + * @author Mark Pollack + */ +final class MultiReturnValueHandler implements ReturnValueHandler { + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + return Multi.class.isAssignableFrom(returnType.getParameterType()); + } + + @Override + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + SyncPromptContext prompt = context.getSyncPromptContext() + .orElseThrow(() -> new ReturnValueHandlingException("Only an @Prompt handler may return a Multi; " + + returnType.getMethod().getName() + " handles " + context.getAcpMethod())); + if (returnValue != null) { + stream((Multi) returnValue, prompt); + } + return prompt.isCancelled() ? AcpSchema.PromptResponse.cancelled() : AcpSchema.PromptResponse.endTurn(); + } + + /** Sends every item until the stream ends or the prompt is cancelled. */ + private static void stream(Multi multi, SyncPromptContext prompt) { + CompletableFuture<@Nullable Void> done = new CompletableFuture<>(); + AtomicReference<@Nullable Cancellable> subscription = new AtomicReference<>(); + Runnable cancel = () -> { + Cancellable current = subscription.get(); + if (current != null) { + current.cancel(); + } + }; + subscription.set(multi.subscribe().with(item -> { + try { + send(prompt, item); + } + catch (RuntimeException e) { + cancel.run(); + done.completeExceptionally(e); + } + }, done::completeExceptionally, () -> done.complete(null))); + prompt.onCancel(() -> { + cancel.run(); + done.complete(null); + }); + try { + done.join(); + } + catch (CompletionException e) { + Throwable cause = e.getCause(); + throw (cause instanceof RuntimeException runtime) ? runtime + : new ReturnValueHandlingException("The @Prompt Multi failed", e); + } + } + + private static void send(SyncPromptContext prompt, Object item) { + if (item instanceof String text) { + prompt.sendMessage(text); + } + else if (item instanceof AcpSchema.SessionUpdate update) { + prompt.sendSessionUpdate(update); + } + else if (item instanceof AcpSchema.ContentBlock block) { + prompt.sendSessionUpdate(new AcpSchema.AgentMessageChunk(block)); + } + else { + throw new ReturnValueHandlingException("A Multi from @Prompt emits String, SessionUpdate or " + + "ContentBlock items, not " + item.getClass().getName()); + } + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/UniReturnValueHandler.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/UniReturnValueHandler.java new file mode 100644 index 00000000..edf051f7 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/UniReturnValueHandler.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.handler.StringToPromptResponseHandler; +import com.agentclientprotocol.sdk.agent.support.handler.VoidHandler; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.converters.uni.UniReactorConverters; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * Lets a handler return a Mutiny {@link Uni}: its item is the handler's result, read as + * the SDK reads the same value returned directly. A {@code Uni} from + * {@code @Prompt} sends the text to the client and ends the turn, and a {@code Uni} + * from {@code @Prompt} ends the turn. + *

    + * The annotation runtime dispatches on a sync agent, so the handler's thread waits for + * the item, as it does for a {@code Mono}; the prompt and request timeouts bound the wait. + * When an {@code @Prompt}'s prompt is cancelled, the {@code Uni}'s subscription is + * cancelled and the turn ends {@code cancelled}. + *

    + * + * @author Mark Pollack + */ +final class UniReturnValueHandler implements ReturnValueHandler { + + private final StringToPromptResponseHandler text = new StringToPromptResponseHandler(); + + private final VoidHandler none = new VoidHandler(); + + @Override + public boolean supportsReturnType(AcpMethodParameter returnType) { + return Uni.class.isAssignableFrom(returnType.getParameterType()); + } + + @Override + public @Nullable Object handleReturnValue(@Nullable Object returnValue, AcpMethodParameter returnType, + AcpInvocationContext context) { + if (returnValue == null) { + return null; + } + Uni uni = (Uni) returnValue; + Mono mono = uni.convert().with(UniReactorConverters.toMono()); + SyncPromptContext prompt = context.getSyncPromptContext().orElse(null); + if (prompt != null) { + // A cancelled prompt cancels the Uni's subscription and ends the turn cancelled. + mono = mono.takeUntilOther(prompt.async().whenCancelled()); + } + Object item = mono.block(); + if (prompt != null && prompt.isCancelled()) { + return AcpSchema.PromptResponse.cancelled(); + } + if (item == null) { + return none.handleReturnValue(null, returnType, context); + } + if (item instanceof String) { + return text.handleReturnValue(item, returnType, context); + } + return item; + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxExchange.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxExchange.java new file mode 100644 index 00000000..3a3b57ae --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxExchange.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.security.Principal; + +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.core.buffer.Buffer; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; +import org.jspecify.annotations.Nullable; +import reactor.core.publisher.Mono; + +/** + * A Vert.x request as the endpoint sees it. The body is aggregated without blocking the event + * loop (or taken from a body handler that already ran); Quarkus's + * {@code quarkus.http.limits.max-body-size} bounds what the server reads, and the endpoint checks + * its own limit. The principal is the one Quarkus security authenticated. + * + * @author Mark Pollack + */ +final class VertxExchange implements AcpHttpExchange { + + private final RoutingContext context; + + VertxExchange(RoutingContext context) { + this.context = context; + } + + @Override + public String method() { + return context.request().method().name(); + } + + @Override + public @Nullable String header(String name) { + return context.request().getHeader(name); + } + + @Override + public Mono body(long maxBytes) { + RequestBody handled = context.body(); + if (handled != null && handled.available()) { + Buffer buffer = handled.buffer(); + return Mono.just(buffer != null ? buffer.getBytes() : new byte[0]); + } + return Mono.fromCompletionStage(() -> { + // Quarkus pauses a request until a handler wants its body. + var body = context.request().body(); + context.request().resume(); + return body.toCompletionStage(); + }).map(Buffer::getBytes); + } + + @Override + public @Nullable Principal principal() { + if (context.user() instanceof QuarkusHttpUser user && !user.getSecurityIdentity().isAnonymous()) { + return user.getSecurityIdentity().getPrincipal(); + } + return null; + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxSseWriter.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxSseWriter.java new file mode 100644 index 00000000..9baf764b --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxSseWriter.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.concurrent.atomic.AtomicBoolean; + +import com.agentclientprotocol.sdk.http.server.SseFrame; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerResponse; +import org.reactivestreams.Subscription; +import reactor.core.CoreSubscriber; +import reactor.core.publisher.Operators; + +/** + * Writes one SSE stream's frames to a chunked Vert.x response: it asks the endpoint for a frame, + * writes it, and asks for the next once Vert.x reports the write done; it cancels when the client + * goes away or a write fails, so the endpoint keeps what was not written for the client's next + * GET. Vert.x responses take no timeout, so the stream lives as long as the connection. + * + * @author Mark Pollack + */ +final class VertxSseWriter implements CoreSubscriber { + + private final HttpServerResponse response; + + private final AtomicBoolean done = new AtomicBoolean(false); + + private volatile Subscription subscription = Operators.emptySubscription(); + + VertxSseWriter(HttpServerResponse response) { + this.response = response; + } + + @Override + public void onSubscribe(Subscription s) { + this.subscription = s; + response.closeHandler(ignored -> cancel()); + s.request(1); + } + + @Override + public void onNext(SseFrame frame) { + if (done.get()) { + return; + } + response.write(Buffer.buffer(frame.encode())).onComplete(written -> { + if (written.succeeded()) { + subscription.request(1); + } + else { + cancel(); + } + }); + } + + @Override + public void onError(Throwable error) { + onComplete(); + } + + @Override + public void onComplete() { + if (done.compareAndSet(false, true)) { + end(); + } + } + + /** The client went away or a write failed: the endpoint keeps what was not written. */ + void cancel() { + if (done.compareAndSet(false, true)) { + subscription.cancel(); + end(); + } + } + + private void end() { + if (!response.ended() && !response.closed()) { + response.end(); + } + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxWsOutbound.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxWsOutbound.java new file mode 100644 index 00000000..f4e2ed94 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxWsOutbound.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import com.agentclientprotocol.sdk.http.server.AcpWsOutbound; +import io.vertx.core.http.ServerWebSocket; + +/** + * Sends through a Vert.x server WebSocket; the endpoint sends one frame at a time. + * + * @author Mark Pollack + */ +final class VertxWsOutbound implements AcpWsOutbound { + + private final ServerWebSocket socket; + + VertxWsOutbound(ServerWebSocket socket) { + this.socket = socket; + } + + @Override + public CompletionStage sendText(String text) { + return socket.writeTextMessage(text).toCompletionStage(); + } + + @Override + public CompletionStage close(int code, String reason) { + if (socket.isClosed()) { + return CompletableFuture.completedFuture(null); + } + return socket.close((short) code, reason).toCompletionStage(); + } + +} diff --git a/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/package-info.java b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/package-info.java new file mode 100644 index 00000000..5411d3e8 --- /dev/null +++ b/acp-quarkus/src/main/java/com/agentclientprotocol/sdk/quarkus/runtime/package-info.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Quarkus extension's runtime beans: the agent assembled from the {@code @AcpAgent} + * bean, its stdio and HTTP hosts, the client beans, and the Mutiny return types. Internal: + * an application uses the configuration and the contracts in the parent package. + */ +@NullMarked +package com.agentclientprotocol.sdk.quarkus.runtime; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/QuarkusArchitectureTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/QuarkusArchitectureTest.java new file mode 100644 index 00000000..8e56d49f --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/QuarkusArchitectureTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.quarkus.runtime.AcpAgentAssembly; +import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-quarkus: the extension's runtime half. + * + *
    + *   quarkus (configuration)  <--  quarkus.runtime (beans, hosts)  -->  acp-integration
    + * 
    + * + *

    + * The public package is what an application touches and knows nothing of the beans + * behind it. The beans use the SDK's public API only, like any application, and never + * Jetty or a servlet container: the HTTP endpoint is the SDK's framework-neutral endpoint on a + * Vert.x route of the Quarkus router. The route is a host: it holds no protocol rule. + *

    + */ +@AnalyzeClasses(locations = QuarkusArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class QuarkusArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule publicPackageDoesNotReachTheBeans = noClasses().that() + .resideInAPackage("com.agentclientprotocol.sdk.quarkus") + .should() + .dependOnClassesThat() + .resideInAPackage("com.agentclientprotocol.sdk.quarkus.runtime..") + .because("the configuration and contracts an application uses do not depend on the extension's beans"); + + static final DescribedPredicate SDK_INTERNALS = resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(belongToAnyOf(AcpAgentSession.class, AcpClientSession.class).or(not(modifier(JavaModifier.PUBLIC)))) + .as("SDK internals (session implementations, non-public classes)"); + + @ArchTest + static final ArchRule usesOnlyThePublicSdkApi = noClasses().should() + .dependOnClassesThat(SDK_INTERNALS) + .because("the extension is a client of the SDK's public API, like any application"); + + @ArchTest + static final ArchRule theRouteIsOnlyAHost = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("jakarta.servlet..", "jakarta.websocket..", "io.undertow..") + .or(belongToAnyOf(com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection.class, + com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage.class))) + .because("all protocol semantics live in acp-http-core; the Vert.x route only adapts I/O through the host contract"); + + @ArchTest + static final ArchRule neverUsesJetty = noClasses().should() + .dependOnClassesThat() + .resideInAPackage("org.eclipse.jetty..") + .because("Quarkus serves the agent on its own HTTP server, and acp-quarkus excludes the Jetty jars"); + + /** Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}. */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = AcpAgentAssembly.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutorsTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutorsTest.java new file mode 100644 index 00000000..6f6b4b81 --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpExecutorsTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import jakarta.enterprise.inject.Instance; +import org.eclipse.microprofile.context.ManagedExecutor; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@code quarkus.acp.handler-executor}: Quarkus' virtual-thread executor where the JDK has virtual + * threads and the bean exists, else the {@code ManagedExecutor}; {@code managed} always the latter. + */ +class AcpExecutorsTest { + + private final ExecutorService virtual = mock(ExecutorService.class); + + private final ManagedExecutor managed = mock(ManagedExecutor.class); + + @Test + void virtualIsQuarkusVirtualThreadExecutorOnJdk21() { + AcpExecutors executors = executors(AcpRuntimeConfig.HandlerExecutor.VIRTUAL, true, true); + assertThat(executors.handlerExecutor()).isSameAs(virtual); + assertThat(executors.transportThreads().executor()).isSameAs(virtual); + } + + @Test + void virtualBeforeJdk21IsTheManagedExecutor() { + assertThat(executors(AcpRuntimeConfig.HandlerExecutor.VIRTUAL, true, false).handlerExecutor()) + .isSameAs(managed); + } + + @Test + void theInjectedChoiceAsksTheJdk() { + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class); + when(config.handlerExecutor()).thenReturn(AcpRuntimeConfig.HandlerExecutor.VIRTUAL); + ExecutorService chosen = new AcpExecutors(config, instance(virtual, true), instance(managed, true)) + .handlerExecutor(); + assertThat(chosen).isSameAs(VirtualThreads.isSupported() ? virtual : managed); + } + + @Test + void virtualWithoutTheBeanIsTheManagedExecutor() { + assertThat(executors(AcpRuntimeConfig.HandlerExecutor.VIRTUAL, false, true).handlerExecutor()) + .isSameAs(managed); + } + + @Test + void managedIsTheManagedExecutorAndCarriesToTheTransports() { + AcpExecutors executors = executors(AcpRuntimeConfig.HandlerExecutor.MANAGED, true, true); + assertThat(executors.handlerExecutor()).isSameAs(managed); + assertThat(executors.transportThreads().executor()).isSameAs(managed); + } + + private AcpExecutors executors(AcpRuntimeConfig.HandlerExecutor choice, boolean virtualBean, + boolean jdkHasVirtualThreads) { + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class); + when(config.handlerExecutor()).thenReturn(choice); + return new AcpExecutors(config, instance(virtual, virtualBean), instance(managed, true), jdkHasVirtualThreads); + } + + @SuppressWarnings("unchecked") + private static Instance instance(T bean, boolean resolvable) { + Instance instance = mock(Instance.class); + when(instance.isResolvable()).thenReturn(resolvable); + when(instance.get()).thenReturn(bean); + return instance; + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpEndpointTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpEndpointTest.java new file mode 100644 index 00000000..f1f3efec --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpHttpEndpointTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.time.Duration; +import java.util.Optional; +import java.util.OptionalInt; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import io.quarkus.runtime.configuration.MemorySize; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpHttpEndpointTest { + + @Test + void unsetLimitsKeepTheSdkDefaults() { + AgentHttp none = new AgentHttp(Optional.empty(), Optional.empty(), OptionalInt.empty(), OptionalInt.empty(), + OptionalInt.empty(), OptionalInt.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty()); + assertThat(AcpVertxHost.options(none)).isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults()); + } + + @Test + void setLimitsReachTheOptions() { + AgentHttp all = new AgentHttp(Optional.of(new MemorySize(java.math.BigInteger.valueOf(1024))), + Optional.of(Duration.ofSeconds(2)), OptionalInt.of(3), OptionalInt.of(4), OptionalInt.of(5), + OptionalInt.of(6), Optional.of(Duration.ofSeconds(7)), Optional.of(Duration.ofMinutes(8)), + Optional.of(Duration.ofSeconds(9)), Optional.of(java.util.List.of("https://app.example.com"))); + StreamableHttpAcpAgentTransportOptions options = AcpVertxHost.options(all); + assertThat(options.maxPostBodyBytes()).isEqualTo(1024); + assertThat(options.keepAliveInterval()).isEqualTo(Duration.ofSeconds(2)); + assertThat(options.mailboxCapacity()).isEqualTo(3); + assertThat(options.maxPendingSseEvents()).isEqualTo(4); + assertThat(options.maxWebSocketPendingFrames()).isEqualTo(5); + assertThat(options.maxProvisionalSessions()).isEqualTo(6); + assertThat(options.shutdownTimeout()).isEqualTo(Duration.ofSeconds(7)); + assertThat(options.webSocketIdleTimeout()).isEqualTo(Duration.ofMinutes(8)); + assertThat(options.initializeTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(options.allowedOrigins()).containsExactly("https://app.example.com"); + assertThat(options.host()).as("the Quarkus server keeps its own bind address").isNull(); + } + + @Test + void configBuildersAddLowOrdinalDefaults() { + var stdio = new AcpStdioConfigBuilder().configBuilder(new io.smallrye.config.SmallRyeConfigBuilder()).build(); + assertThat(stdio.getValue("quarkus.log.console.stderr", Boolean.class)).isTrue(); + assertThat(stdio.getValue("quarkus.http.host-enabled", Boolean.class)).isFalse(); + var http = new AcpHttpConfigBuilder().configBuilder(new io.smallrye.config.SmallRyeConfigBuilder()).build(); + assertThat(http.getValue("quarkus.http.limits.max-body-size", String.class)).isEqualTo("16M"); + assertThat(http.getConfigSources()).anyMatch(source -> source.getOrdinal() == AcpStdioConfigBuilder.ORDINAL); + } + + record AgentHttp(Optional maxPostBodySize, Optional keepAliveInterval, + OptionalInt mailboxCapacity, OptionalInt maxPendingSseEvents, OptionalInt maxWebSocketPendingFrames, + OptionalInt maxProvisionalSessions, Optional shutdownTimeout, + Optional webSocketIdleTimeout, Optional initializeTimeout, + Optional> allowedOrigins) implements AcpRuntimeConfig.AgentHttp { + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettingsTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettingsTest.java new file mode 100644 index 00000000..566aa931 --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/AcpSettingsTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** {@code quarkus.acp.client.*} onto the SDK's settings, and the SDK's transport rule over them. */ +class AcpSettingsTest { + + private static final URI WS = URI.create("ws://localhost:9/acp"); + + private static final URI HTTP = URI.create("http://localhost:9/acp"); + + @Test + void everyClientSettingIsBound() { + AcpClientSettings settings = AcpSettings.client(client(AcpTransportType.STDIO, "agent", WS, HTTP)); + assertThat(settings.requestTimeout()).isEqualTo(Duration.ofSeconds(4)); + assertThat(settings.promptTimeout()).isEqualTo(Duration.ofMinutes(5)); + assertThat(settings.transport()).isEqualTo(AcpTransportType.STDIO); + assertThat(settings.stdio().command()).isEqualTo("agent"); + assertThat(settings.stdio().args()).containsExactly("--acp"); + assertThat(settings.stdio().env()).isEqualTo(Map.of("MODE", "test")); + assertThat(settings.websocket().uri()).isEqualTo(WS); + assertThat(settings.websocket().connectTimeout()).isEqualTo(Duration.ofSeconds(1)); + assertThat(settings.http().uri()).isEqualTo(HTTP); + } + + @Test + void anExplicitTypeWins() { + assertThat(producers(client(AcpTransportType.HTTP, "agent", WS, HTTP)).acpClientTransport()) + .isInstanceOf(StreamableHttpAcpClientTransport.class); + } + + @Test + void theOneConfiguredIsInferred() { + assertThat(producers(client(null, null, WS, null)).acpClientTransport()) + .isInstanceOf(WebSocketAcpClientTransport.class); + assertThat(producers(client(null, null, null, HTTP)).acpClientTransport()) + .isInstanceOf(StreamableHttpAcpClientTransport.class); + assertThat(producers(client(null, "agent", null, null)).acpClientTransport()) + .isInstanceOf(StdioAcpClientTransport.class); + } + + @Test + void severalWithoutATypeFailNamingThem() { + assertThatThrownBy(() -> producers(client(null, "agent", WS, HTTP)).acpClientTransport()) + .isInstanceOf(IllegalStateException.class) + .hasMessage("Several ACP client transports are configured [STDIO, WEBSOCKET, HTTP]; " + + "choose one with quarkus.acp.client.transport.type"); + } + + @Test + void nothingConfiguredNamesTheProperties() { + assertThatThrownBy(() -> producers(client(null, null, null, null)).acpClientTransport()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("quarkus.acp.client.transport.stdio.command") + .hasMessageContaining("quarkus.acp.client.transport.websocket.uri") + .hasMessageContaining("quarkus.acp.client.transport.http.uri"); + } + + @Test + void anExplicitTypeWithoutItsSettingNamesIt() { + assertThatThrownBy(() -> producers(client(AcpTransportType.WEBSOCKET, null, null, HTTP)).acpClientTransport()) + .hasMessage("quarkus.acp.client.transport.type=websocket requires quarkus.acp.client.transport.websocket.uri"); + } + + private static AcpClientProducers producers(AcpRuntimeConfig.Client client) { + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class); + when(config.client()).thenReturn(client); + return new AcpClientProducers(config, new AcpExecutors(java.util.concurrent.ForkJoinPool.commonPool())); + } + + private static AcpRuntimeConfig.Client client(AcpTransportType type, String command, URI ws, URI http) { + AcpRuntimeConfig.Client client = mock(AcpRuntimeConfig.Client.class, Answers.RETURNS_DEEP_STUBS); + when(client.requestTimeout()).thenReturn(Optional.of(Duration.ofSeconds(4))); + when(client.promptTimeout()).thenReturn(Optional.of(Duration.ofMinutes(5))); + when(client.transport()).thenReturn(new Transport(Optional.ofNullable(type), + new Stdio(Optional.ofNullable(command), Optional.of(List.of("--acp")), Map.of("MODE", "test")), + new WebSocket(Optional.ofNullable(ws)), new Http(Optional.ofNullable(http)))); + return client; + } + + record Transport(Optional type, AcpRuntimeConfig.Stdio stdio, + AcpRuntimeConfig.WebSocket websocket, AcpRuntimeConfig.Http http) implements AcpRuntimeConfig.ClientTransport { + } + + record Stdio(Optional command, Optional> args, + Map env) implements AcpRuntimeConfig.Stdio { + } + + record WebSocket(Optional uri) implements AcpRuntimeConfig.WebSocket { + + @Override + public Duration connectTimeout() { + return Duration.ofSeconds(1); + } + + } + + record Http(Optional uri) implements AcpRuntimeConfig.Http { + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/HostsAndProducersTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/HostsAndProducersTest.java new file mode 100644 index 00000000..1df63900 --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/HostsAndProducersTest.java @@ -0,0 +1,231 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; +import org.mockito.Answers; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** The hosts and producers on their own, with the container's part played by the test. */ +class HostsAndProducersTest { + + @Test + void stdioHostStartsTheAgentOnStartupAndClosesItOnceOnShutdown() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAgentAssembly assembly = mock(AcpAgentAssembly.class); + when(assembly.builder()).thenReturn(AcpAgentSupport.create(new PingAgent())); + when(assembly.settings()).thenReturn(AcpAgentSettings.builder().shutdownOnTransportEnd(false).build()); + + AcpStdioAgentHost host = new AcpStdioAgentHost(assembly, pair.agentTransport()); + assertThat(host.agent()).isNull(); + host.start(null); + assertThat(host.agent()).isNotNull(); + + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(Duration.ofSeconds(5)).build(); + client.initialize(); + String session = client.newSession(new AcpSchema.NewSessionRequest("/", List.of())).sessionId(); + assertThat(client.prompt(new AcpSchema.PromptRequest(session, List.of(new AcpSchema.TextContent("x")))) + .stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + + host.stop(null); + host.stop(null); + client.close(); + } + + @Test + void stdioTransportProducerMakesTheStdioTransport() { + assertThat(new AcpStdioTransportProducer().stdioAgentTransport()).isInstanceOf(StdioAcpAgentTransport.class); + } + + @Test + void httpHostDrainsTheEndpointOnShutdown() { + AcpVertxHost route = mock(AcpVertxHost.class); + com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint endpoint = mock( + com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint.class); + when(route.endpoint()).thenReturn(endpoint); + when(endpoint.options()).thenReturn(StreamableHttpAcpAgentTransportOptions.builder() + .shutdownTimeout(Duration.ofMillis(10)) + .build()); + when(route.activeConnectionCount()).thenReturn(5); + // A close that never finishes is given up after the shutdown timeout. + when(route.closeGracefully()).thenReturn(Mono.never()); + + AcpHttpAgentHost host = new AcpHttpAgentHost(route); + assertThat(host.activeConnectionCount()).isEqualTo(5); + host.stop(null); + verify(route).closeGracefully(); + } + + @Test + void clientProducersBuildCustomizeAndCloseTheClient() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class, Answers.RETURNS_DEEP_STUBS); + when(config.client().requestTimeout()).thenReturn(Optional.of(Duration.ofSeconds(1))); + when(config.client().capabilities().readTextFile()).thenReturn(true); + when(config.client().transport().type()).thenReturn(Optional.of(AcpTransportType.HTTP)); + when(config.client().transport().websocket().connectTimeout()).thenReturn(Duration.ofSeconds(10)); + when(config.client().transport().http().uri()).thenReturn(Optional.of(java.net.URI.create("http://localhost:9/acp"))); + AcpClientProducers producers = new AcpClientProducers(config, new AcpExecutors(java.util.concurrent.ForkJoinPool.commonPool())); + assertThat(producers.acpClientTransport()).isNotNull(); + + List customized = new java.util.ArrayList<>(); + AcpAsyncClient client = producers.acpAsyncClient(pair.clientTransport(), + List.of(spec -> customized.add("first"), spec -> customized.add("second"), + // serves the file reads the configuration advertises + spec -> spec.readTextFileHandler(request -> Mono.empty()))); + assertThat(customized).containsExactly("first", "second"); + assertThat(producers.acpSyncClient(client)).isNotNull(); + producers.close(client); + } + + @Test + void aCapabilitySettingWithoutItsHandlerFailsNamingTheSetting() { + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class, Answers.RETURNS_DEEP_STUBS); + when(config.client().capabilities().readTextFile()).thenReturn(true); + when(config.client().capabilities().elicitationForm()).thenReturn(true); + AcpClientProducers producers = new AcpClientProducers(config, new AcpExecutors(java.util.concurrent.ForkJoinPool.commonPool())); + + assertThatThrownBy( + () -> producers.acpAsyncClient(InMemoryTransportPair.create().clientTransport(), List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("readTextFileHandler") + .hasMessageContaining("createElicitationHandler") + .hasMessageContaining("quarkus.acp.client.capabilities.read-text-file=true") + .hasMessageContaining("quarkus.acp.client.capabilities.elicitation-form=true") + .hasMessageNotContaining("elicitation-url"); + } + + @Test + void anApplicationConsumerReplacesTheDebugLoggingDefault() { + org.jboss.logmanager.Logger logger = org.jboss.logmanager.LogContext.getLogContext() + .getLogger(com.agentclientprotocol.sdk.integration.AcpClients.class.getName()); + List logged = new java.util.concurrent.CopyOnWriteArrayList<>(); + java.util.logging.Handler handler = new java.util.logging.Handler() { + @Override + public void publish(java.util.logging.LogRecord record) { + logged.add(String.valueOf(record.getMessage())); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + handler.setLevel(java.util.logging.Level.ALL); + java.util.logging.Level level = logger.getLevel(); + logger.setLevel(java.util.logging.Level.FINE); + logger.addHandler(handler); + try { + // Without an application update handler, the default logs each update at DEBUG. + promptWith(List.of()); + assertThat(logged).anySatisfy(message -> assertThat(message).startsWith("Session update for")); + + // With one, the default is replaced, not run beside it. + logged.clear(); + List received = new java.util.concurrent.CopyOnWriteArrayList<>(); + promptWith(List.of(spec -> spec.sessionUpdateHandler(notification -> { + received.add(notification); + return Mono.empty(); + }))); + assertThat(received).singleElement(); + assertThat(logged).noneSatisfy(message -> assertThat(message).startsWith("Session update for")); + } + finally { + logger.removeHandler(handler); + logger.setLevel(level); + } + } + + /** The client bean, customized, prompts an agent that sends one session update. */ + private static void promptWith( + List customizers) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + com.agentclientprotocol.sdk.agent.AcpSyncAgent agent = com.agentclientprotocol.sdk.agent.AcpAgent + .sync(pair.agentTransport()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null)) + .promptHandler((request, context) -> { + context.sendMessage("hello"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + agent.start(); + AcpRuntimeConfig config = mock(AcpRuntimeConfig.class, Answers.RETURNS_DEEP_STUBS); + AcpClientProducers producers = new AcpClientProducers(config, new AcpExecutors(java.util.concurrent.ForkJoinPool.commonPool())); + AcpSyncClient client = producers.acpSyncClient(producers.acpAsyncClient(pair.clientTransport(), customizers)); + try { + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())); + client.prompt(new AcpSchema.PromptRequest("session-1", List.of(new AcpSchema.TextContent("hi")))); + } + finally { + client.close(); + agent.closeGracefully(); + } + } + + @Test + void capabilitiesFollowTheConfiguration() { + AcpRuntimeConfig.Capabilities none = mock(AcpRuntimeConfig.Capabilities.class); + AcpSchema.ClientCapabilities nothing = AcpSettings.capabilities(none).toClientCapabilities(); + assertThat(nothing.fs().readTextFile()).isFalse(); + assertThat(nothing.terminal()).isFalse(); + assertThat(nothing.elicitation()).isNull(); + assertThat(nothing.session()).isNull(); + + AcpRuntimeConfig.Capabilities all = mock(AcpRuntimeConfig.Capabilities.class); + when(all.readTextFile()).thenReturn(true); + when(all.writeTextFile()).thenReturn(true); + when(all.terminal()).thenReturn(true); + when(all.elicitationForm()).thenReturn(true); + when(all.booleanConfigOptions()).thenReturn(true); + AcpSchema.ClientCapabilities everything = AcpSettings.capabilities(all).toClientCapabilities(); + assertThat(everything.fs().writeTextFile()).isTrue(); + assertThat(everything.terminal()).isTrue(); + assertThat(everything.elicitation().form()).isNotNull(); + assertThat(everything.elicitation().url()).isNull(); + assertThat(everything.session()).isEqualTo(AcpSchema.ClientSessionCapabilities.withBooleanConfigOptions()); + + when(all.elicitationForm()).thenReturn(false); + when(all.elicitationUrl()).thenReturn(true); + assertThat(AcpSettings.capabilities(all).toClientCapabilities().elicitation().url()).isNotNull(); + } + + @AcpAgent + public static class PingAgent { + + @Prompt + AcpSchema.PromptResponse prompt() { + return AcpSchema.PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/MutinyReturnValueHandlersTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/MutinyReturnValueHandlersTest.java new file mode 100644 index 00000000..ddb4e293 --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/MutinyReturnValueHandlersTest.java @@ -0,0 +1,187 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.lang.reflect.Method; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandlingException; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpInvocationContext; +import com.agentclientprotocol.sdk.agent.support.invocation.AcpMethodParameter; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class MutinyReturnValueHandlersTest { + + private final UniReturnValueHandler uni = new UniReturnValueHandler(); + + private final MultiReturnValueHandler multi = new MultiReturnValueHandler(); + + @Test + void supportsOnlyItsType() throws Exception { + assertThat(uni.supportsReturnType(returnType("uni"))).isTrue(); + assertThat(uni.supportsReturnType(returnType("multi"))).isFalse(); + assertThat(multi.supportsReturnType(returnType("multi"))).isTrue(); + assertThat(multi.supportsReturnType(returnType("plain"))).isFalse(); + } + + @Test + void uniItemIsTheResult() throws Exception { + AcpSchema.NewSessionResponse response = new AcpSchema.NewSessionResponse("s", null, null); + assertThat(uni.handleReturnValue(Uni.createFrom().item(response), returnType("uni"), context("session/new"))) + .isSameAs(response); + assertThat(uni.handleReturnValue(null, returnType("uni"), context("session/new"))).isNull(); + } + + @Test + void uniStringAndVoidFromPromptAreReadAsTheSdkReadsThem() throws Exception { + SyncPromptContext prompt = livePrompt(); + assertThat(uni.handleReturnValue(Uni.createFrom().item("hi"), returnType("uni"), promptContext(prompt))) + .isEqualTo(AcpSchema.PromptResponse.endTurn()); + verify(prompt).sendMessage("hi"); + assertThat(uni.handleReturnValue(Uni.createFrom().voidItem(), returnType("uni"), context("session/prompt"))) + .isEqualTo(AcpSchema.PromptResponse.endTurn()); + assertThat(uni.handleReturnValue(Uni.createFrom().voidItem(), returnType("uni"), context("session/new"))) + .isNull(); + } + + @Test + void uniFailureFailsTheHandler() throws Exception { + Uni failed = Uni.createFrom().failure(new IllegalStateException("boom")); + assertThatThrownBy(() -> uni.handleReturnValue(failed, returnType("uni"), context("session/new"))) + .hasMessageContaining("boom"); + } + + @Test + void multiItemsAreStreamedAndTheTurnEnds() throws Exception { + SyncPromptContext prompt = mock(SyncPromptContext.class); + when(prompt.getSessionId()).thenReturn("s1"); + AcpSchema.SessionUpdate thought = new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent("hmm")); + Object result = multi.handleReturnValue( + Multi.createFrom().items("text", new AcpSchema.TextContent("block"), thought), returnType("multi"), + promptContext(prompt)); + assertThat(result).isEqualTo(AcpSchema.PromptResponse.endTurn()); + verify(prompt).sendMessage("text"); + verify(prompt).sendSessionUpdate(any(AcpSchema.AgentMessageChunk.class)); + verify(prompt).sendSessionUpdate(thought); + } + + @Test + void multiOfAnythingElseFails() throws Exception { + SyncPromptContext prompt = mock(SyncPromptContext.class); + assertThatThrownBy(() -> multi.handleReturnValue(Multi.createFrom().items(42), returnType("multi"), + promptContext(prompt))) + .isInstanceOf(ReturnValueHandlingException.class) + .hasMessageContaining("java.lang.Integer"); + } + + @Test + void aCancelledPromptCancelsTheUniAndEndsTheTurnCancelled() throws Exception { + SyncPromptContext prompt = cancelledPrompt(); + assertThat(uni.handleReturnValue(Uni.createFrom().nothing(), returnType("uni"), promptContext(prompt))) + .isEqualTo(AcpSchema.PromptResponse.cancelled()); + } + + @Test + void aCancelledPromptCancelsTheMultiAndEndsTheTurnCancelled() throws Exception { + SyncPromptContext prompt = cancelledPrompt(); + assertThat(multi.handleReturnValue(Multi.createFrom().nothing(), returnType("multi"), promptContext(prompt))) + .isEqualTo(AcpSchema.PromptResponse.cancelled()); + } + + @Test + void aFailedMultiFailsThePrompt() throws Exception { + Multi failed = Multi.createFrom().failure(new IllegalStateException("boom")); + assertThatThrownBy(() -> multi.handleReturnValue(failed, returnType("multi"), + promptContext(mock(SyncPromptContext.class)))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("boom"); + Multi checked = Multi.createFrom().failure(new java.io.IOException("io")); + assertThatThrownBy(() -> multi.handleReturnValue(checked, returnType("multi"), + promptContext(mock(SyncPromptContext.class)))) + .isInstanceOf(ReturnValueHandlingException.class) + .hasRootCauseMessage("io"); + } + + /** A prompt that is not cancelled. */ + private static SyncPromptContext livePrompt() { + SyncPromptContext prompt = mock(SyncPromptContext.class); + com.agentclientprotocol.sdk.agent.PromptContext async = mock(com.agentclientprotocol.sdk.agent.PromptContext.class); + when(async.whenCancelled()).thenReturn(reactor.core.publisher.Mono.never()); + when(prompt.async()).thenReturn(async); + return prompt; + } + + /** A prompt already cancelled: onCancel runs its action at once, as the SDK's does. */ + private static SyncPromptContext cancelledPrompt() { + SyncPromptContext prompt = mock(SyncPromptContext.class); + com.agentclientprotocol.sdk.agent.PromptContext async = mock(com.agentclientprotocol.sdk.agent.PromptContext.class); + when(async.whenCancelled()).thenReturn(reactor.core.publisher.Mono.empty()); + when(prompt.async()).thenReturn(async); + when(prompt.isCancelled()).thenReturn(true); + org.mockito.Mockito.doAnswer(invocation -> { + invocation.getArgument(0).run(); + return null; + }).when(prompt).onCancel(any()); + return prompt; + } + + @Test + void nullMultiEndsTheTurn() throws Exception { + assertThat(multi.handleReturnValue(null, returnType("multi"), promptContext(mock(SyncPromptContext.class)))) + .isEqualTo(AcpSchema.PromptResponse.endTurn()); + } + + @Test + void multiOutsideAPromptFails() throws Exception { + assertThatThrownBy(() -> multi.handleReturnValue(Multi.createFrom().empty(), returnType("multi"), + context("session/new"))) + .isInstanceOf(ReturnValueHandlingException.class) + .hasMessageContaining("Only an @Prompt handler may return a Multi"); + } + + private static AcpMethodParameter returnType(String name) throws NoSuchMethodException { + Method method = Handlers.class.getDeclaredMethod(name); + return AcpMethodParameter.forReturnType(method); + } + + private static AcpInvocationContext context(String acpMethod) { + return AcpInvocationContext.builder().acpMethod(acpMethod).request(new Object()).build(); + } + + private static AcpInvocationContext promptContext(SyncPromptContext prompt) { + return AcpInvocationContext.builder() + .acpMethod("session/prompt") + .request(new Object()) + .syncPromptContext(prompt) + .build(); + } + + static class Handlers { + + Uni uni() { + return Uni.createFrom().nullItem(); + } + + Multi multi() { + return Multi.createFrom().empty(); + } + + Object plain() { + return new Object(); + } + + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxAdaptersTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxAdaptersTest.java new file mode 100644 index 00000000..ae47276b --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxAdaptersTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.security.Principal; +import java.util.Map; + +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.SseFrame; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.http.ServerWebSocket; +import io.vertx.ext.auth.User; +import io.vertx.ext.web.RequestBody; +import io.vertx.ext.web.RoutingContext; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscription; +import reactor.core.publisher.Flux; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * The edges of the Vert.x adapters that a running server does not reach on demand: a body a + * handler already read, the principal of each kind of user, a client gone before the reply, a + * failed SSE write, a closed socket. + */ +class VertxAdaptersTest { + + @Test + void aBodyAHandlerAlreadyReadIsUsed() { + RoutingContext context = mock(RoutingContext.class); + RequestBody body = mock(RequestBody.class); + when(context.body()).thenReturn(body); + when(body.available()).thenReturn(true); + when(body.buffer()).thenReturn(Buffer.buffer("{}")); + assertThat(new VertxExchange(context).body(10).block()).isEqualTo("{}".getBytes()); + when(body.buffer()).thenReturn(null); + assertThat(new VertxExchange(context).body(10).block()).isEmpty(); + } + + @Test + void onlyAnAuthenticatedQuarkusUserIsAPrincipal() { + RoutingContext context = mock(RoutingContext.class); + VertxExchange exchange = new VertxExchange(context); + assertThat(exchange.principal()).isNull(); + + when(context.user()).thenReturn(mock(User.class)); + assertThat(exchange.principal()).isNull(); + + SecurityIdentity identity = mock(SecurityIdentity.class); + QuarkusHttpUser user = mock(QuarkusHttpUser.class); + when(user.getSecurityIdentity()).thenReturn(identity); + when(context.user()).thenReturn(user); + when(identity.isAnonymous()).thenReturn(true); + assertThat(exchange.principal()).isNull(); + + Principal alice = () -> "alice"; + when(identity.isAnonymous()).thenReturn(false); + when(identity.getPrincipal()).thenReturn(alice); + assertThat(exchange.principal()).isSameAs(alice); + } + + @Test + void aReplyToAClientThatLeftIsDropped() { + RoutingContext context = mock(RoutingContext.class); + HttpServerResponse response = mock(HttpServerResponse.class); + when(context.response()).thenReturn(response); + when(response.closed()).thenReturn(true); + AcpVertxHost.write(context, new AcpHttpReply.Empty(202, Map.of())); + verify(response, never()).setStatusCode(any(Integer.class)); + } + + @Test + void aFailedSseWriteCancelsTheStreamAndEndsTheResponse() { + HttpServerResponse response = mock(HttpServerResponse.class); + when(response.write(any(Buffer.class))).thenReturn(Future.failedFuture("reset")); + Subscription subscription = mock(Subscription.class); + VertxSseWriter writer = new VertxSseWriter(response); + writer.onSubscribe(subscription); + writer.onNext(SseFrame.comment("connected")); + verify(subscription).cancel(); + verify(response).end(); + writer.onNext(SseFrame.comment("ignored")); + writer.onError(new IllegalStateException("late")); + writer.cancel(); + } + + @Test + void aCompletedStreamToAClosedResponseIsNotEnded() { + HttpServerResponse response = mock(HttpServerResponse.class); + when(response.closed()).thenReturn(true); + VertxSseWriter writer = new VertxSseWriter(response); + Flux.empty().subscribe(writer); + verify(response, never()).end(); + } + + @Test + void aClosedSocketIsNotClosedAgain() { + ServerWebSocket socket = mock(ServerWebSocket.class); + when(socket.isClosed()).thenReturn(true); + new VertxWsOutbound(socket).close(1001, "bye"); + verify(socket, never()).close(any(Short.class), any(String.class)); + } + +} diff --git a/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxHostTckTest.java b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxHostTckTest.java new file mode 100644 index 00000000..e22c939c --- /dev/null +++ b/acp-quarkus/src/test/java/com/agentclientprotocol/sdk/quarkus/runtime/VertxHostTckTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.quarkus.runtime; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.quarkus.AcpBuildTimeConfig; +import com.agentclientprotocol.sdk.quarkus.AcpRuntimeConfig; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import io.quarkus.runtime.configuration.MemorySize; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.ext.web.Router; +import org.mockito.Answers; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The extension's Vert.x host on a plain Vert.x server and router, under the shared transport + * TCK: the same route the extension mounts on the Quarkus router, without Quarkus around it. + * {@code QuarkusTckTest} runs the same suite inside a Quarkus application. + */ +class VertxHostTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) throws Exception { + Vertx vertx = Vertx.vertx(); + var options = config.options(); + AcpAgentAssembly assembly = mock(AcpAgentAssembly.class); + when(assembly.factory()).thenReturn(config.agents()); + AcpRuntimeConfig runtime = mock(AcpRuntimeConfig.class, Answers.RETURNS_DEEP_STUBS); + when(runtime.agent().transport().http()).thenReturn(new AcpHttpEndpointTest.AgentHttp( + Optional.of(new MemorySize(java.math.BigInteger.valueOf(options.maxPostBodyBytes()))), + Optional.of(options.keepAliveInterval()), OptionalInt.empty(), OptionalInt.empty(), OptionalInt.empty(), + OptionalInt.empty(), Optional.of(options.shutdownTimeout()), Optional.of(options.webSocketIdleTimeout()), + Optional.of(options.initializeTimeout()), Optional.of(List.copyOf(options.allowedOrigins())))); + AcpBuildTimeConfig buildTime = mock(AcpBuildTimeConfig.class, Answers.RETURNS_DEEP_STUBS); + when(buildTime.agent().transport().http().path()).thenReturn("acp"); + AcpVertxHost host = new AcpVertxHost(assembly, runtime, buildTime); + AcpHttpAgentHost agentHost = new AcpHttpAgentHost(host); + Router router = Router.router(vertx); + host.register(router); + HttpServer server = vertx.createHttpServer(new HttpServerOptions().setHost("127.0.0.1").setPort(0)) + .requestHandler(router) + .listen() + .toCompletionStage() + .toCompletableFuture() + .get(10, TimeUnit.SECONDS); + URI endpoint = URI.create("http://127.0.0.1:" + server.actualPort() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() throws Exception { + // As the extension does at shutdown: drain, then the server stops. + agentHost.drain(); + server.close().toCompletionStage().toCompletableFuture().get(10, TimeUnit.SECONDS); + vertx.close().toCompletionStage().toCompletableFuture().get(10, TimeUnit.SECONDS); + } + + }; + } + + +} diff --git a/acp-spring-boot-autoconfigure/README.md b/acp-spring-boot-autoconfigure/README.md new file mode 100644 index 00000000..e1a90d13 --- /dev/null +++ b/acp-spring-boot-autoconfigure/README.md @@ -0,0 +1,373 @@ +# ACP Spring Boot + +Spring Boot 4.1 autoconfiguration for the ACP Java SDK. It serves your `@AcpAgent` bean over stdio, +Streamable HTTP or WebSocket, and it builds an ACP client from `spring.acp.client.*`. Two modules: + +- `acp-spring-boot-starter`, the one to depend on. It brings this module, `acp-agent-support` and + `spring-boot-starter`. +- `acp-spring-boot-autoconfigure`, this module: the autoconfiguration classes and the + `spring.acp.*` properties, in package `com.agentclientprotocol.sdk.spring.boot.autoconfigure` + (`.agent`, `.client`). + +```xml + + com.agentclientprotocol + acp-spring-boot-starter + ${acp.version} + + + + com.agentclientprotocol + acp-streamable-http-jetty + ${acp.version} + +``` + +The starter has the SDK's version. Do not pin the other SDK modules to a version of their own: the +starter brings the matching ones. The client's stdio, WebSocket and Streamable HTTP transports are +in `acp-core`, so a client needs nothing more. Only an agent served over HTTP needs +`acp-streamable-http-jetty`. The SDK's Jackson 3 module is on the classpath, and the SDK chooses it +over Jackson 2 (see the root [README](../README.md#installation)). + +## An agent + +Annotate one bean `@AcpAgent`. Its handler methods, their parameters and their return types are +those of [`acp-agent-support`](../acp-agent-support/README.md). + +```java +@Component +@AcpAgent(name = "echo", version = "1.0.0") +public class EchoAgent { + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + context.sendMessage("Echo: " + request.text()); + return PromptResponse.endTurn(); + } +} + +@SpringBootApplication +public class AgentApplication { + public static void main(String[] args) { + SpringApplication.run(AgentApplication.class, args); + } +} +``` + +Over stdio (the default), standard output carries the protocol, so nothing else may write to it. +The transport's threads are daemon threads, so keep the application alive: + +```properties +spring.main.keep-alive=true +spring.main.banner-mode=off +# Spring Boot's console logging writes to standard output: turn it off, log to a file, +# or configure a logback-spring.xml whose console appender has System.err +logging.console.enabled=false +logging.file.name=agent.log +``` + +When the client closes the agent's standard input and every answer has been written, the agent +closes the application context, and the process exits 0 +(`spring.acp.agent.shutdown-on-transport-end`). + +- **Finding the bean.** The autoconfiguration finds the bean by its `@AcpAgent` annotation. Make + the class a bean (`@Component`, or a `@Bean` method). The handler methods are read from the + bean's own class, so a bean proxied by Spring AOP (CGLIB) is served with its advice. More than + one `@AcpAgent` bean fails the startup with "Found 2 @AcpAgent beans [...]", naming them. An + application without one, such as a client, gets no agent and no agent transport. +- **One bean for every session.** Over HTTP each connection gets its own agent, and every one of + them calls the same bean. Make its handler methods thread-safe, and keep per-session state keyed + by session ID. +- **Extension beans.** `AcpInterceptor`, `ArgumentResolver` and `ReturnValueHandler` beans are + added to the agent in bean order. +- **Your own beans win.** An `AcpAgentTransport` bean of your own replaces stdio, for example the + agent side of `acp-test`'s `InMemoryTransportPair` in a test. An `AcpAgentFactory`, a + `StreamableHttpAcpAgentTransport`, or a bean named `acpServletRegistration` of your own replaces + the one the autoconfiguration creates. +- **Lifecycle.** The agent starts with the context and stops with it, waiting at most 30 seconds + for a graceful stop. + +## A client + +Set one transport's command or URI, and the application gets an `AcpAsyncClient` and an +`AcpSyncClient`. The sync client is a facade over the async one, so both share one session on one +connection. Inject either. + +```properties +spring.acp.client.transport.stdio.command=my-agent +spring.acp.client.transport.stdio.args=--acp +``` + +```java +@Component +class SessionUpdates implements AcpClientCustomizer { + @Override + public void customize(AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(notification -> { + if (notification.update() instanceof AgentMessageChunk chunk + && chunk.content() instanceof TextContent text) { + System.out.print(text.text()); + } + return Mono.empty(); + }); + } +} + +@Component +class Ask implements CommandLineRunner { + private final AcpSyncClient client; + + Ask(AcpSyncClient client) { + this.client = client; + } + + @Override + public void run(String... args) { + client.initialize(); + String sessionId = client.newSession(new NewSessionRequest("/workspace", List.of())).sessionId(); + client.prompt(new PromptRequest(sessionId, List.of(new TextContent("Hello")))); + } +} +``` + +- **Connecting.** Creating the client bean connects the transport, and for stdio starts the agent + process. `initialize()` then performs the ACP handshake. +- **Customizers.** `AcpClientCustomizer` beans (`com.agentclientprotocol.sdk.integration`, the same + type in the Micronaut and Quarkus integrations) are applied to the client's builder in bean order. + Register the session-update handler, the permission handler, and the file system, terminal and + elicitation handlers there. Without a session-update handler of your own, session updates are + logged at DEBUG, content included. +- **Logging.** DEBUG and TRACE on `com.agentclientprotocol.sdk` log message payloads in full; do not + enable them where logs are shipped. A stdio agent's standard error is logged at INFO, unfiltered, + on `com.agentclientprotocol.sdk.client.transport.agent-stderr`, and can carry sensitive data: + set `logging.level.com.agentclientprotocol.sdk.client.transport.agent-stderr=WARN` to silence it. +- **Capabilities.** The client advertises none by default. Turn one on with its + `spring.acp.client.capabilities.*` property and register its handler in a customizer. A + capability turned on without its handler fails the startup, naming the property. +- **Closing.** When the context closes, the client is closed once. Pending notifications are + delivered, waiting at most the request timeout plus 10 seconds, then the transport is closed. +- **Your own beans win.** An `AcpClientTransport`, `AcpAsyncClient` or `AcpSyncClient` bean of your + own replaces the one the autoconfiguration creates. + +## Transports + +### Agent: `spring.acp.agent.transport.type` + +| Value | Where the agent is served | +|---|---| +| `stdio`, or unset | Standard input and output. A web application serves the agent on stdio too, unless you set `http`. | +| `http` | ACP Streamable HTTP, with WebSocket upgrades on the same path. It needs `acp-http-servlet` in a servlet web application, `acp-http-webflux` in a reactive (WebFlux) one, or `acp-streamable-http-jetty` for the SDK's own listener; without any of them the startup fails, naming them. See the next table for where it is served. | +| `websocket` | The same as `http`. | + +The value is read in any case. With `http` or `websocket`, the kind of application decides where +the endpoint runs: + +| Application | Endpoint | +|---|---| +| Servlet web application (Spring MVC) | `StreamableHttpAcpServlet` (`acp-http-servlet`), mounted on the application's own server at `spring.acp.agent.transport.http.path` (`server.port`, `server.address`, its TLS): Streamable HTTP, SSE and WebSocket upgrades on that one path, with no second server. Requests go through the application's filter chain, so Spring Security, observations and access logs apply to `/acp`, the WebSocket handshake included, and the authenticated principal reaches the endpoint. The endpoint is an `AcpHttpEndpoint` bean the application may wrap or replace. The `listener.*` properties are ignored. | +| Not a web application | The SDK's Jetty listener (`StreamableHttpAcpAgentTransport`) on `spring.acp.agent.transport.http.listener.port`: HTTP/1.1, cleartext HTTP/2 and WebSocket upgrades on one path. It starts with the context and stops with it, waiting at most 30 seconds. | +| Reactive web application (WebFlux) | `AcpWebFluxHost` (`acp-http-webflux`): a `RouterFunction` bean named `acpRouterFunction` that routes `spring.acp.agent.transport.http.path` on the application's own server (`server.port`, `server.address`, its TLS; Reactor Netty by default) to the endpoint: Streamable HTTP, SSE and WebSocket upgrades on that one path, with no second server. Requests go through the application's `WebFilter`s, so a `SecurityWebFilterChain`, observations and access logs apply to `/acp`, the WebSocket handshake included, and the authenticated principal reaches the endpoint. The endpoint is an `AcpHttpEndpoint` bean the application may wrap or replace; a `RouterFunction` bean named `acpRouterFunction` of its own replaces the route. Without `acp-http-webflux` on the classpath the startup fails, naming it. The `listener.*` properties are ignored. | + +**One HTTP module per application type.** A servlet web application adds `acp-http-servlet`; a +reactive (WebFlux) web application adds `acp-http-webflux`; an application without a web server +adds `acp-streamable-http-jetty` (the SDK's own listener). The starter brings none of them. A servlet web application on Tomcat 11 whose classpath puts a Jakarta +WebSocket API older than 2.2 ahead of Tomcat's fails at startup naming the fix, since Tomcat would +otherwise fail every WebSocket send. + +**Securing `/acp`.** The endpoint has no authentication of its own; protect its path in your +`SecurityFilterChain` as any other. ACP clients are not browsers, so exempt the path from CSRF; the +endpoint itself refuses browser requests from foreign origins: + +```java +@Bean +SecurityFilterChain acpSecurity(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(requests -> requests.requestMatchers("/acp").authenticated()) + .httpBasic(Customizer.withDefaults()) + .csrf(csrf -> csrf.ignoringRequestMatchers("/acp")) + .build(); +} +``` + +In a reactive application the same goes into a `SecurityWebFilterChain`; the principal Spring +Security authenticated reaches the endpoint (`AcpHttpExchange.principal()`) there too: + +```java +@Bean +SecurityWebFilterChain acpSecurity(ServerHttpSecurity http) { + return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/acp").authenticated() + .anyExchange().permitAll()) + .httpBasic(Customizer.withDefaults()) + .csrf(csrf -> csrf.requireCsrfProtectionMatcher( + new NegatedServerWebExchangeMatcher(ServerWebExchangeMatchers.pathMatchers("/acp")))) + .build(); +} +``` + +**Shutdown.** Each client connection holds an open SSE response or WebSocket, which Spring +Boot's graceful shutdown counts as an active request and would wait for, up to +`spring.lifecycle.timeout-per-shutdown-phase` (30 seconds by default). A `SmartLifecycle` in the +default phase therefore drains the endpoint first: SSE streams get a closing comment and complete, +WebSockets close with 1001 (going away). It stops before graceful shutdown and the web server do. + +**Compression.** With `server.compression.enabled`, `text/event-stream` is taken out of the +compressed MIME types, on a servlet and a reactive server alike: a compressed SSE stream is held +back until the compressor's buffer fills. + +**A reactive application.** Add `acp-http-webflux` beside the starter and +`spring-boot-starter-webflux`; the properties are the same `spring.acp.agent.transport.*` keys: + +```xml + + com.agentclientprotocol + acp-http-webflux + ${acp.version} + +``` + +On Reactor Netty the route raises Netty's WebSocket frame limit above +`spring.acp.agent.transport.http.max-post-body-size`, so an oversized message is closed with 1009 +by the endpoint. On Tomcat or Jetty as a WebFlux server, the container's own WebSocket message +limit applies first (Tomcat 8 KB, Jetty 64 KB), so larger messages close with 1009 there; use +Reactor Netty, or a servlet application, for large WebSocket messages. WebFlux reports no +completion per WebSocket frame, so the route sends a frame only when the server asks for one: +a client that stops reading fills the endpoint's bounded send queue, and the socket closes with +1011, as on every host. + +### Client: `spring.acp.client.transport.*` + +| Transport | Set | Client transport | +|---|---|---| +| `stdio` | `stdio.command`, with `stdio.args` and `stdio.env.*` | `StdioAcpClientTransport`: starts the agent process | +| `websocket` | `websocket.uri`, such as `ws://localhost:8080/acp` | `WebSocketAcpClientTransport` | +| `http` | `http.uri`, such as `http://localhost:8080/acp` | `StreamableHttpAcpClientTransport` | + +The rules for choosing one, the same in every framework integration: + +- With none of `type`, `stdio.command`, `websocket.uri` and `http.uri` set, the application gets no + transport and no client. +- `type` unset: the transport is the one whose command or URI is set. With more than one set, the + startup fails, naming them. +- `type` set: it wins. If its own command or URI is missing, the startup fails, naming that + property. + +The SDK's listener and an agent mounted as a servlet both take `websocket` and `http` clients. + +## Properties + +Durations take Spring's forms (`30s`, `500ms`, `PT1M`), and sizes take `DataSize` forms (`16MB`). +Most properties have no default of their own: unset, they keep the SDK's default, shown here. A +value the SDK refuses, such as a negative duration, fails the startup. + +### `spring.acp.agent.*` + +| Property | Default | Meaning, and what it maps to | +|---|---|---| +| `enabled` | `true` | Serve the `@AcpAgent` bean. `false`: see [Switching off](#switching-off). | +| `request-timeout` | `60s` (SDK) | How long a request the agent sends to the client, such as a permission request or a file read, waits for its answer. `AcpAgentSupport.Builder.requestTimeout`. | +| `cancel-grace-period` | `60s` (SDK) | How long a `@Prompt` method has to return after `session/cancel`. When it passes, the agent answers the prompt `cancelled` itself and interrupts the method's thread. `0` for no limit. `AcpAgentSupport.Builder.cancelGracePeriod`. | +| `max-prompt-duration` | none (SDK) | How long a prompt turn may run before the agent answers it with error `-32800`. `0` for no limit. `AcpAgentSupport.Builder.maxPromptDuration`. | +| `shutdown-on-transport-end` | `true` | Close the application context when a single transport (stdio, or an `AcpAgentTransport` bean of your own) ends on its own. An HTTP endpoint never ends because one client leaves. | +| `handler-executor` | unset | The executor the handler methods run on; see [Handler executor](#handler-executor-and-virtual-threads). `AcpAgentSupport.Builder.handlerExecutor`. | +| `transport.type` | `stdio` | `stdio`, `http` or `websocket`; see [Transports](#transports). | +| `transport.http.path` | `/acp` | The endpoint's path, on the application's server or on the listener. | +| `transport.http.max-post-body-size` | `16MB` (SDK) | The largest inbound message. A larger POST body is answered 413, and a larger WebSocket text message closes the connection. Transport option `maxPostBodyBytes`. | +| `transport.http.keep-alive-interval` | `15s` (SDK) | Interval between keep-alive comments on open SSE streams. `0` turns them off. Transport option `keepAliveInterval`. | +| `transport.http.mailbox-capacity` | `1024` (SDK) | Events one SSE stream keeps while no client is reading it, for the client's reconnect. One more closes the connection. Transport option `mailboxCapacity`. | +| `transport.http.max-pending-sse-events` | `1024` (SDK) | Events queued for a client reading an SSE stream. One more detaches that client and keeps the events for its next GET. Transport option `maxPendingSseEvents`. | +| `transport.http.max-web-socket-pending-frames` | `1024` (SDK) | Frames queued for one WebSocket connection. One more closes it. Transport option `maxWebSocketPendingFrames`. | +| `transport.http.max-provisional-sessions` | `64` (SDK) | Session streams a connection may open before the agent knows the session, as before `session/load`. A further one is refused. Transport option `maxProvisionalSessions`. | +| `transport.http.shutdown-timeout` | `5s` (SDK) | How long closing the endpoint waits for its connections to close gracefully before closing the rest at once. Transport option `shutdownTimeout`. | +| `transport.http.web-socket-idle-timeout` | `30m` (SDK) | How long a WebSocket connection may pass no frame in either direction before the endpoint closes it (1001). Transport option `webSocketIdleTimeout`. | +| `transport.http.initialize-timeout` | `30s` (SDK) | How long a connection has to complete `initialize`: a WebSocket that has not sent it is closed (1008), and a POST `initialize` the agent has not answered is answered 500. Transport option `initializeTimeout`. | +| `transport.http.allowed-origins` | none | Browser origins accepted besides `http(s)://localhost`, `127.0.0.1` and `[::1]` (any port), such as `https://app.example.com`; `*` for any. Any other `Origin` is answered 403, over HTTP and on the WebSocket handshake; a request without one is served. Transport option `allowedOrigins`. | +| `transport.http.listener.host` | loopback | The address the SDK listener binds. Unset binds `127.0.0.1` and `::1` only; `0.0.0.0` exposes the agent on every interface, an explicit opt-in, since the endpoint has no authentication of its own. Ignored in a servlet web application, which uses `server.address`. Transport option `host`. | +| `transport.http.listener.port` | `8080` | The SDK listener's port. `0` picks a free one. Ignored in a servlet web application, which uses `server.port`. | +| `transport.http.listener.max-concurrent-streams-per-connection` | `1024` (SDK) | HTTP/2 streams one client connection may hold open on the listener; each open SSE stream holds one. Transport option `maxConcurrentStreamsPerConnection`. | + +The agent properties map onto `AcpAgentSettings` in `acp-integration`, and the `transport.http.*` +limits onto `StreamableHttpAcpAgentTransportOptions`, which documents each range. + +### `spring.acp.client.*` + +| Property | Default | Meaning, and what it maps to | +|---|---|---| +| `request-timeout` | `60s` (SDK) | How long the client waits for the agent to answer a request other than a prompt. `AcpClient.AsyncSpec.requestTimeout`. | +| `prompt-timeout` | none (SDK) | How long a prompt turn may take. When it passes, `prompt` fails with a `TimeoutException` and the client sends the agent `$/cancel_request`. `0` for no limit. `AcpClient.AsyncSpec.promptTimeout`. | +| `transport.type` | inferred | `stdio`, `websocket` or `http`, in any case; see [Transports](#client-springacpclienttransport). | +| `transport.stdio.command` | | The command that starts the agent process. Selects the stdio transport. | +| `transport.stdio.args` | none | The command's arguments, in order. | +| `transport.stdio.env.*` | none | Variables added to the agent process's environment. The process inherits the application's whole environment, and these add to it or replace a variable of the same name. | +| `transport.websocket.uri` | | The agent's WebSocket endpoint. Selects the WebSocket transport. | +| `transport.websocket.connect-timeout` | `10s` | How long the WebSocket handshake may take. This replaces the transport's own default. | +| `transport.http.uri` | | The agent's Streamable HTTP endpoint. Selects the HTTP transport. | +| `capabilities.read-text-file` | `false` | Advertise `fs/read_text_file`. Needs a read-file handler. | +| `capabilities.write-text-file` | `false` | Advertise `fs/write_text_file`. Needs a write-file handler. | +| `capabilities.terminal` | `false` | Advertise the `terminal/*` methods. Needs the terminal handlers. | +| `capabilities.elicitation-form` | `false` | Advertise form-mode `elicitation/create`. Needs an elicitation handler. | +| `capabilities.elicitation-url` | `false` | Advertise URL-mode `elicitation/create`. Needs an elicitation handler. | +| `capabilities.boolean-config-options` | `false` | Advertise that the client accepts boolean session config options. | + +The client properties map onto `AcpClientSettings` in `acp-integration`. The Micronaut +(`acp.*`) and Quarkus (`quarkus.acp.*`) integrations map their own keys onto the same settings, so +each property means the same there. + +## Switching off + +- **Agent.** `spring.acp.agent.enabled=false` creates no agent beans: no agent factory, no stdio + transport, no HTTP endpoint, and no agent on an `AcpAgentTransport` bean of your own. Any number + of `@AcpAgent` beans is then allowed. +- **Client.** The client has no `enabled` property. It exists only when a + `spring.acp.client.transport.*` property is set: leave them all unset for no client. + +## Handler executor and virtual threads + +`spring.acp.agent.handler-executor` chooses the threads the agent's handler methods run on: + +- **Unset (the default).** With `spring.threads.virtual.enabled=true` (JDK 21 and later), the + context's `applicationTaskExecutor`, which starts a virtual thread per handler call. Otherwise the + SDK's pool of platform threads, on every JDK: the starter follows Spring Boot's opt-in, not the + SDK's own default of virtual threads on JDK 21. Without virtual threads, Spring Boot's + `applicationTaskExecutor` is a pool of 8 threads by default, which would cap the prompts served at + once. +- **The name of an `Executor` bean.** That bean. A plain `Executor`, such as a `TaskExecutor`, is + adapted to an `ExecutorService`, and cancelling a handler still interrupts its thread. Handler + methods block, so the executor must allow blocking. A name that matches no bean, or a bean that + is no `Executor`, fails the startup. +- **`none`, in any case.** The SDK's pool of platform threads. + +The same opt-in decides the threads of the SDK's listener and of the WebSocket and Streamable HTTP +client transports: with virtual threads on, they run on the `applicationTaskExecutor` and create no +pool of their own (a JDK `HttpClient` keeps its one selector thread, Jetty's virtual-thread pool one +parked platform thread, and the SDK one JVM-wide timer thread, `acp-timeout`); otherwise they keep +their pools of platform threads. On JDK 21 to 23 a virtual thread that blocks inside a +`synchronized` block pins its carrier thread; JDK 24 removed that (JEP 491). + +## Limitations + +- **One agent per application.** A second `@AcpAgent` bean fails the startup. + +## Migrating from `org.springaicommunity:acp-spring-boot-starter` 0.12.0 + +The former `spring-ai-community/acp-autoconfig` project is now these two modules. In short: + +- Coordinates: `org.springaicommunity:acp-spring-boot-starter` becomes + `com.agentclientprotocol:acp-spring-boot-starter`, with the SDK's version. Drop any separate ACP + SDK version pin. +- Imports: `com.agentclientprotocol.autoconfigure.*` becomes + `com.agentclientprotocol.sdk.spring.boot.autoconfigure.*`. `AcpClientCustomizer` moves to + `com.agentclientprotocol.sdk.integration`. +- The `spring.acp.*` keys are unchanged, except that the listener-only agent properties move under + `spring.acp.agent.transport.http.listener.*` and the `spring.acp.agent.transport.websocket.*` + properties are removed. + +The full list of behaviour changes is in [CHANGELOG.md](../CHANGELOG.md), in the entry "Spring Boot +autoconfiguration and starter, now part of the SDK" under Added. The SDK's other breaking changes +in that release apply too. + +## See also + +- The root [README](../README.md): the SDK, its modules and its transports. +- [`acp-agent-support`](../acp-agent-support/README.md): the annotation programming model. +- [`acp-micronaut`](../acp-micronaut/README.md) and [`acp-quarkus`](../acp-quarkus/README.md): the + same integration for Micronaut and Quarkus. +- [`acp-integration`](../acp-integration/README.md): the framework-neutral half these build on. diff --git a/acp-spring-boot-autoconfigure/pom.xml b/acp-spring-boot-autoconfigure/pom.xml new file mode 100644 index 00000000..66234772 --- /dev/null +++ b/acp-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,278 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-spring-boot-autoconfigure + jar + + ACP Spring Boot Autoconfigure + Spring Boot autoconfiguration for ACP clients, agents and transports + + + 4.1.0 + + 1.5.34 + 6.0.3 + + 0.95 + 0.78 + + + + + + + + + + org.eclipse.jetty + jetty-bom + ${jetty.version} + pom + import + + + org.eclipse.jetty.ee10 + jetty-ee10-bom + ${jetty.version} + pom + import + + + + io.projectreactor + reactor-bom + ${reactor.version} + pom + import + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + org.jspecify + jspecify + + + com.agentclientprotocol + acp-core + + + + com.agentclientprotocol + acp-json-jackson3 + ${project.version} + + + com.agentclientprotocol + acp-annotations + ${project.version} + + + + com.agentclientprotocol + acp-integration + + + com.agentclientprotocol + acp-agent-support + ${project.version} + true + + + + com.agentclientprotocol + acp-streamable-http-jetty + ${project.version} + true + + + + com.agentclientprotocol + acp-http-webflux + true + + + org.springframework + spring-webflux + true + + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework.boot + spring-boot-web-server + true + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-web + test + + + + org.springframework.boot + spring-boot-micrometer-observation + test + + + io.micrometer + micrometer-observation-test + test + + + + org.springframework.boot + spring-boot-starter-security + test + + + + org.springframework.boot + spring-boot-starter-webflux + test + + + com.agentclientprotocol + acp-test + ${project.version} + test + + + com.tngtech.archunit + archunit-junit5 + test + + + + org.junit.platform + junit-platform-launcher + test + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + websocket-api-2.1 + process-test-resources + + copy + + + ${project.build.directory}/websocket-api-2.1 + + + jakarta.websocket + jakarta.websocket-api + 2.1.1 + + + jakarta.websocket + jakarta.websocket-client-api + 2.1.1 + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + org.springframework.boot + spring-boot-autoconfigure-processor + ${spring-boot.version} + + + + + + + + + diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfiguration.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfiguration.java new file mode 100644 index 00000000..bcb90eae --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfiguration.java @@ -0,0 +1,186 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.support.handler.ReturnValueHandler; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.support.resolver.ArgumentResolver; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery; +import com.agentclientprotocol.sdk.integration.AcpAgentDiscovery.AgentCandidate; +import com.agentclientprotocol.sdk.integration.AcpAgentHost; +import com.agentclientprotocol.sdk.integration.AcpAgents; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.ClassUtils; + +/** + * Serves the application's {@code @AcpAgent} bean: builds the agent from it, and starts and stops + * the agent with the application context. With {@code acp-spring-boot-starter} on the classpath, + * annotate one bean with {@code @AcpAgent} and this autoconfiguration does the rest. A client-only + * application, which has no such bean, gets nothing from it. + * + *

    It creates an {@link AcpAgentFactory} bean, which builds one agent per connection for the + * Streamable HTTP endpoint ({@link AcpAgentHttpAutoConfiguration}); an {@code AcpAgentFactory} bean + * of the application's own replaces it. When there is an {@code AcpAgentTransport} bean (the stdio + * transport from {@link AcpAgentTransportAutoConfiguration}, or the application's own), it also + * serves one agent on that transport: started with the context, and stopped with it, waiting at + * most 30 seconds for a graceful stop. When that transport ends on its own, the context is closed, + * unless {@code spring.acp.agent.shutdown-on-transport-end=false}. + * + *

    The handler methods are found on the bean's own class, so a bean proxied by Spring AOP (CGLIB) + * is served with its advice. {@code AcpInterceptor}, {@code ArgumentResolver} and + * {@code ReturnValueHandler} beans are added to the agent in their bean order, and the timeouts and + * the handler executor come from {@link AcpAgentProperties}. More than one {@code @AcpAgent} bean + * fails the startup with an error naming them. On the HTTP endpoint every connection's agent calls + * the same bean, so its handler methods must be thread-safe. + * + *

    With {@code spring.acp.agent.enabled=false} it creates nothing: no factory and no agent, on + * any transport, however many {@code @AcpAgent} beans there are. + */ +@AutoConfiguration(after = AcpAgentTransportAutoConfiguration.class) +@ConditionalOnClass(AcpAgentSupport.class) +@ConditionalOnProperty(prefix = "spring.acp.agent", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(AcpAgentProperties.class) +public class AcpAgentAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AcpAgentAutoConfiguration.class); + + // Back off for client-only applications: only start an agent lifecycle when the + // application actually defines an @AcpAgent bean. The acp-spring-boot-starter serves + // both clients and agents, so a client app legitimately has no @AcpAgent bean. + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(AcpAgentTransport.class) + static class SingleTransportAgentConfiguration { + + @Bean + @ConditionalOnBean(annotation = AcpAgent.class) + AcpAgentLifecycle acpAgentLifecycle(ApplicationContext applicationContext, AcpAgentTransport transport, + AcpAgentProperties properties, ObjectProvider interceptors, + ObjectProvider resolvers, ObjectProvider returnValueHandlers) { + AcpAgentSupport agent = builder(applicationContext, properties, interceptors, resolvers, + returnValueHandlers) + .transport(transport) + .build(); + Runnable onTransportEnd = () -> { + }; + if (properties.isShutdownOnTransportEnd() + && applicationContext instanceof ConfigurableApplicationContext configurable) { + onTransportEnd = () -> closeContext(configurable); + } + return new AcpAgentLifecycle(new AcpAgentHost(agent, onTransportEnd)); + } + + } + + // Listener-backed transports (Streamable HTTP) host one agent runtime per remote + // connection, each dispatching to the same @AcpAgent bean, so its handlers must be + // thread-safe. + @Bean + @ConditionalOnBean(annotation = AcpAgent.class) + @ConditionalOnMissingBean + AcpAgentFactory acpAgentFactory(ApplicationContext applicationContext, AcpAgentProperties properties, + ObjectProvider interceptors, ObjectProvider resolvers, + ObjectProvider returnValueHandlers) { + return builder(applicationContext, properties, interceptors, resolvers, returnValueHandlers).buildFactory(); + } + + private static AcpAgentSupport.Builder builder(ApplicationContext context, AcpAgentProperties properties, + ObjectProvider interceptors, ObjectProvider resolvers, + ObjectProvider returnValueHandlers) { + AgentCandidate agent = findAgent(context); + logger.info("Discovered @AcpAgent bean: {}", agent.userClass().getName()); + return AcpAgents.builder(agent, properties.toSettings(), interceptors.orderedStream().toList(), + resolvers.orderedStream().toList(), returnValueHandlers.orderedStream().toList(), + HandlerExecutors.resolve(context, properties)); + } + + private static AgentCandidate findAgent(ApplicationContext context) { + List> candidates = Arrays.stream(context.getBeanNamesForAnnotation(AcpAgent.class)) + .>map(name -> candidate(context, name)) + .toList(); + try { + return AcpAgentDiscovery.requireSingle(candidates, "spring.acp.agent.enabled") + .orElseThrow(() -> new IllegalStateException("No @AcpAgent bean")); + } + catch (IllegalStateException ex) { + throw new BeanCreationException(String.valueOf(ex.getMessage())); + } + } + + private static AgentCandidate candidate(ApplicationContext context, String name) { + Object bean = context.getBean(name); + // The user class, not a CGLIB proxy's: the proxy's methods carry no annotations. + @SuppressWarnings("unchecked") + Class userClass = (Class) ClassUtils.getUserClass(bean); + return new AgentCandidate<>(name, userClass, () -> bean); + } + + private static void closeContext(ConfigurableApplicationContext context) { + if (context.isActive()) { + logger.info("ACP agent transport ended; closing the application context"); + context.close(); + } + } + + /** + * Starts and stops the agent with the context. When the transport ends on its own + * (for stdio: the client closed the agent's input and every reply has been written), + * the agent has no one left to serve, so the host closes the application context, + * which lets a {@code spring.main.keep-alive} application exit. + */ + static class AcpAgentLifecycle implements SmartLifecycle { + + private static final Duration STOP_TIMEOUT = Duration.ofSeconds(30); + + private final AcpAgentHost host; + + private volatile boolean running = false; + + AcpAgentLifecycle(AcpAgentHost host) { + this.host = host; + } + + @Override + public void start() { + host.start(); + running = true; + } + + @Override + public void stop() { + host.stop(STOP_TIMEOUT); + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfiguration.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfiguration.java new file mode 100644 index 00000000..13b40419 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfiguration.java @@ -0,0 +1,357 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; +import java.util.Arrays; +import java.util.Locale; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpListenerHost; +import com.agentclientprotocol.sdk.integration.AcpListeners; +import com.agentclientprotocol.sdk.integration.AcpServletHost; +import com.agentclientprotocol.sdk.http.webflux.AcpWebFluxHost; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnNotWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory; +import org.springframework.boot.web.server.Compression; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.core.Ordered; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Serves the application's {@code @AcpAgent} bean over ACP Streamable HTTP and WebSocket when + * {@code spring.acp.agent.transport.type} is {@code http} (or {@code websocket}, the same) and an + * SDK host for the kind of application is on the classpath. The starter brings none, so add the + * one that fits: {@code acp-http-servlet} for a servlet (Spring MVC) web application, + * {@code acp-http-webflux} for a reactive (WebFlux) one, or {@code acp-streamable-http-jetty} + * for the SDK's own listener in a non-web application. Each client connection gets its own agent + * from the {@link AcpAgentFactory} that {@link AcpAgentAutoConfiguration} creates, and every one + * of them calls the same bean. + * + *

    Where the endpoint is served depends on the kind of application: + *

      + *
    • A servlet web application mounts the {@link StreamableHttpAcpServlet} on its own server + * ({@code server.port}) at {@code spring.acp.agent.transport.http.path}: Streamable HTTP, SSE and + * WebSocket upgrades on that one path, through the application's filter chain, so Spring + * Security, observations and access logs apply to ACP as to any other endpoint. A bean named + * {@code acpServletRegistration} of the application's own replaces the registration.
    • + *
    • A reactive web application routes the path to the endpoint with a {@code RouterFunction} + * bean named {@code acpRouterFunction} ({@link AcpWebFluxHost}) on its own server + * ({@code server.port}, Reactor Netty by default), so the application's {@code WebFilter}s, its + * {@code SecurityWebFilterChain} included, apply to it over HTTP and on the WebSocket handshake. + * A bean of that name of the application's own replaces the route. Without + * {@code acp-http-webflux} on the classpath the startup fails, naming it.
    • + *
    • A non-web application gets the SDK's {@link StreamableHttpAcpAgentTransport}, listening on + * {@code spring.acp.agent.transport.http.listener.port} (default 8080) with HTTP/1.1, cleartext + * HTTP/2 and WebSocket upgrades on the same path. It starts with the context and stops with it, + * waiting at most 30 seconds. A {@code StreamableHttpAcpAgentTransport} bean of the application's + * own replaces it.
    • + *
    + * In a web application the endpoint itself is an {@link AcpHttpEndpoint} bean, which an + * application may wrap or replace. Before the server's graceful shutdown, in an earlier + * {@code SmartLifecycle} phase, it drains: SSE streams get a closing comment and complete, + * WebSockets close with 1001, so the shutdown never waits for them. With + * {@code server.compression} on, {@code text/event-stream} is taken out of the compressed types, + * since a compressed SSE stream is held back until the buffer fills. The endpoint's limits come + * from {@code spring.acp.agent.transport.http.*} ({@link AcpAgentProperties.AgentHttpProperties}). + * Nothing here applies when {@code spring.acp.agent.enabled=false}. + */ +@AutoConfiguration(after = AcpAgentAutoConfiguration.class) +@ConditionalOnClass(AcpHttpEndpoint.class) +@ConditionalOnProperty(prefix = "spring.acp.agent", name = "enabled", havingValue = "true", matchIfMissing = true) +@Conditional(OnHttpAgentTransportCondition.class) +@ConditionalOnBean(AcpAgentFactory.class) +@EnableConfigurationProperties(AcpAgentProperties.class) +public class AcpAgentHttpAutoConfiguration { + + private static final Logger logger = LoggerFactory.getLogger(AcpAgentHttpAutoConfiguration.class); + + /** How long closing the endpoint may take, beyond its own shutdown timeout. */ + private static final Duration STOP_TIMEOUT = Duration.ofSeconds(30); + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) + @ConditionalOnClass(StreamableHttpAcpServlet.class) + static class ServletConfiguration { + + @Bean + @ConditionalOnMissingBean + AcpHttpEndpoint acpHttpEndpoint(AcpAgentFactory agentFactory, AcpAgentProperties properties, + ApplicationContext context) { + WebSocketApiCheck.requireCompatible(context.getClassLoader()); + return AcpListeners.endpoint(properties.toSettings(), agentFactory); + } + + @Bean + @ConditionalOnMissingBean(name = "acpServletRegistration") + ServletRegistrationBean acpServletRegistration(AcpHttpEndpoint acpHttpEndpoint, + AcpAgentProperties properties) { + AcpAgentSettings settings = properties.toSettings(); + ServletRegistrationBean registration = new ServletRegistrationBean<>( + new StreamableHttpAcpServlet(acpHttpEndpoint), settings.http().path()); + registration.setName("acp"); + registration.setAsyncSupported(true); + return registration; + } + + @Bean + @ConditionalOnClass(name = "org.springframework.boot.web.server.AbstractConfigurableWebServerFactory") + static SseCompressionExclusion acpSseCompressionExclusion() { + return new SseCompressionExclusion(); + } + + @Bean + AcpServletLifecycle acpServletLifecycle( + @Qualifier("acpServletRegistration") ServletRegistrationBean acpServletRegistration) { + return new AcpServletLifecycle(acpServletRegistration); + } + + } + + /** + * Takes {@code text/event-stream} out of the server's compressed types when + * {@code server.compression} is on: a compressing output stream holds SSE events back until + * its buffer fills, and proxies then cut the stream. Runs after Boot's own customizer, which + * applies the properties. + */ + static class SseCompressionExclusion + implements WebServerFactoryCustomizer, Ordered { + + @Override + public void customize(AbstractConfigurableWebServerFactory factory) { + Compression compression = factory.getCompression(); + if (compression == null || !compression.getEnabled()) { + return; + } + compression.setMimeTypes(withoutEventStream(compression.getMimeTypes())); + compression.setAdditionalMimeTypes(withoutEventStream(compression.getAdditionalMimeTypes())); + } + + private static String[] withoutEventStream(String @Nullable [] types) { + if (types == null) { + return new String[0]; + } + return Arrays.stream(types) + .filter(type -> !type.trim().toLowerCase(Locale.ROOT).startsWith(AcpHttpReply.EVENT_STREAM)) + .toArray(String[]::new); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + @ConditionalOnClass(AcpWebFluxHost.class) + static class ReactiveConfiguration { + + @Bean + @ConditionalOnMissingBean + AcpHttpEndpoint acpHttpEndpoint(AcpAgentFactory agentFactory, AcpAgentProperties properties) { + return AcpHttpEndpoint.create(AcpJsonMapper.createDefault(), agentFactory, + properties.toSettings().toOptions(false)); + } + + @Bean + @ConditionalOnMissingBean(name = "acpRouterFunction") + RouterFunction acpRouterFunction(AcpHttpEndpoint acpHttpEndpoint, AcpAgentProperties properties) { + return new AcpWebFluxHost(acpHttpEndpoint).routerFunction(properties.toSettings().http().path()); + } + + @Bean + @ConditionalOnClass(name = "org.springframework.boot.web.server.AbstractConfigurableWebServerFactory") + static SseCompressionExclusion acpSseCompressionExclusion() { + return new SseCompressionExclusion(); + } + + @Bean + AcpEndpointLifecycle acpEndpointLifecycle(AcpHttpEndpoint acpHttpEndpoint) { + return new AcpEndpointLifecycle(acpHttpEndpoint); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) + @ConditionalOnMissingClass("com.agentclientprotocol.sdk.http.webflux.AcpWebFluxHost") + static class MissingWebFluxHostConfiguration { + + MissingWebFluxHostConfiguration() { + throw new IllegalStateException("The ACP HTTP transport in a reactive (WebFlux) web application needs " + + "acp-http-webflux on the classpath"); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnNotWebApplication + @ConditionalOnClass(StreamableHttpAcpAgentTransport.class) + static class ListenerConfiguration { + + @Bean + @ConditionalOnMissingBean + StreamableHttpAcpAgentTransport streamableHttpAcpAgentTransport(AcpAgentFactory agentFactory, + AcpAgentProperties properties, ApplicationContext context) { + // Virtual threads only when the application opted in, on the same executor as the + // handlers by default; otherwise Jetty's platform pool. + return AcpListeners.listener(properties.toSettings(), agentFactory, HandlerExecutors.listenerThreads(context)); + } + + @Bean + AcpAgentHttpListenerLifecycle acpAgentHttpListenerLifecycle(StreamableHttpAcpAgentTransport transport) { + return new AcpAgentHttpListenerLifecycle(new AcpListenerHost(transport)); + } + + } + + /** + * Closes the servlet's ACP connections before the web server shuts down. Each holds an + * open SSE response, which Boot's graceful shutdown counts as an active request and + * waits for, up to {@code spring.lifecycle.timeout-per-shutdown-phase} (30 seconds by + * default). The servlet API gives the SDK no hook before that wait, so the application + * closes them ({@link AcpServletHost}). + */ + static class AcpServletLifecycle implements SmartLifecycle { + + private final ServletRegistrationBean registration; + + private volatile boolean running = false; + + AcpServletLifecycle(ServletRegistrationBean registration) { + this.registration = registration; + } + + @Override + public void start() { + running = true; + } + + @Override + public void stop() { + if (registration.getServlet() instanceof StreamableHttpAcpServlet servlet) { + AcpServletHost.closeBeforeShutdown(servlet, STOP_TIMEOUT); + } + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public int getPhase() { + // Stops before graceful shutdown (DEFAULT_PHASE - 1024) and the web server + // stop (DEFAULT_PHASE - 2048). + return SmartLifecycle.DEFAULT_PHASE; + } + + } + + /** + * Starts the endpoint with the context, and closes its connections before the web server + * shuts down: each holds an open SSE response or WebSocket, which the server's graceful + * shutdown would wait for, up to {@code spring.lifecycle.timeout-per-shutdown-phase}. + */ + static class AcpEndpointLifecycle implements SmartLifecycle { + + private final AcpHttpEndpoint endpoint; + + private volatile boolean running = false; + + AcpEndpointLifecycle(AcpHttpEndpoint endpoint) { + this.endpoint = endpoint; + } + + @Override + public void start() { + endpoint.start(); + running = true; + } + + @Override + public void stop() { + try { + endpoint.closeGracefully().block(STOP_TIMEOUT); + } + catch (RuntimeException ex) { + logger.warn("ACP HTTP connections did not close within {}: {}", STOP_TIMEOUT, ex.getMessage()); + } + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public int getPhase() { + // Stops before graceful shutdown (DEFAULT_PHASE - 1024) and the web server + // stop (DEFAULT_PHASE - 2048). + return SmartLifecycle.DEFAULT_PHASE; + } + + } + + static class AcpAgentHttpListenerLifecycle implements SmartLifecycle { + + private final AcpListenerHost host; + + private volatile boolean running = false; + + AcpAgentHttpListenerLifecycle(AcpListenerHost host) { + this.host = host; + } + + @Override + public void start() { + host.start(); + running = true; + } + + @Override + public void stop() { + host.stop(STOP_TIMEOUT); + running = false; + } + + @Override + public boolean isRunning() { + return running; + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentProperties.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentProperties.java new file mode 100644 index 00000000..8a88d835 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentProperties.java @@ -0,0 +1,753 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.unit.DataSize; + +/** + * The {@code spring.acp.agent.*} configuration properties: how the application's {@code @AcpAgent} + * bean is served. They choose the transport (stdio by default, or Streamable HTTP), set the agent's + * timeouts and the executor its handler methods run on, and bound the HTTP endpoint. Set them in + * {@code application.properties} or {@code application.yaml}; the agent autoconfiguration + * ({@link AcpAgentAutoConfiguration}, {@link AcpAgentHttpAutoConfiguration}) reads them through + * {@link #toSettings()}. + * + *

    Most properties have no default of their own: a property left unset keeps the SDK's default, + * which its getter names, so the agent behaves as it would without Spring. Durations take Spring's + * forms ({@code 30s}, {@code 500ms}, {@code PT1M}) and sizes Spring's {@link DataSize} forms + * ({@code 16MB}). A value the SDK refuses, such as a negative duration or a limit that is not + * positive, fails the application's startup when the agent is built. + * + *

    The properties map onto {@link AcpAgentSettings}, the framework-neutral settings the Micronaut + * and Quarkus integrations fill from their own keys, so each property means the same there. The + * timeouts and the handler executor end up on the annotated agent's builder, + * {@link com.agentclientprotocol.sdk.agent.support.AcpAgentSupport.Builder}. + */ +@ConfigurationProperties(prefix = "spring.acp.agent") +public class AcpAgentProperties { + + /** + * Whether to serve the application's @AcpAgent bean. Default true. + */ + private boolean enabled = true; + + /** + * How long a request the agent sends to the client waits for its answer. Unset keeps the SDK + * default, 60 seconds. + */ + private @Nullable Duration requestTimeout; + + /** + * How long a @Prompt method has to return after session/cancel before the agent answers the + * prompt "cancelled" itself. Unset keeps the SDK default, 60 seconds; zero for none. + */ + private @Nullable Duration cancelGracePeriod; + + /** + * How long a prompt may run before the agent answers it with error -32800. Unset keeps the SDK + * default, no limit; zero for none. + */ + private @Nullable Duration maxPromptDuration; + + /** + * Whether to close the application context when the stdio transport ends on its own. It ends + * when the client closes the agent's input; closing lets a keep-alive application exit. Default + * true. + */ + private boolean shutdownOnTransportEnd = true; + + /** + * The executor the agent's handler methods run on: the name of an Executor bean, or + * "none" for the SDK's pool of platform threads. Unset: the context's + * applicationTaskExecutor when spring.threads.virtual.enabled=true (virtual threads, JDK 21 + * and later), else the SDK's pool of platform threads. + */ + private @Nullable String handlerExecutor; + + private AgentTransportProperties transport = new AgentTransportProperties(); + + /** + * Returns whether the application's {@code @AcpAgent} bean is served + * ({@code spring.acp.agent.enabled}). Default {@code true}; maps to + * {@link AcpAgentSettings#enabled()}. + * + *

    With {@code false} the agent autoconfiguration creates nothing: no agent factory, no stdio + * transport, no HTTP endpoint, and no agent on an {@code AcpAgentTransport} bean the application + * defines itself. More than one {@code @AcpAgent} bean is then no error. + * @return whether the agent is served + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Sets {@code spring.acp.agent.enabled}; see {@link #isEnabled()}. + * @param enabled whether the agent is served + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns how long a request the agent sends to the client, such as a permission request or a + * file read, waits for its answer ({@code spring.acp.agent.request-timeout}). Default: unset, + * which keeps the SDK's default of 60 seconds. Maps to + * {@code AcpAgentSupport.Builder.requestTimeout}. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getRequestTimeout() { + return requestTimeout; + } + + /** + * Sets {@code spring.acp.agent.request-timeout}; see {@link #getRequestTimeout()}. + * @param requestTimeout the timeout, or {@code null} for the SDK's default + */ + public void setRequestTimeout(@Nullable Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + /** + * Returns how long a {@code @Prompt} method has to return after the client sends + * {@code session/cancel} ({@code spring.acp.agent.cancel-grace-period}). When it passes, the + * agent answers the prompt with stop reason {@code cancelled} itself and interrupts the + * method's thread. Default: unset, which keeps the SDK's default of 60 seconds; zero turns the + * limit off. Maps to {@code AcpAgentSupport.Builder.cancelGracePeriod}. + * @return the grace period, or {@code null} when unset + */ + public @Nullable Duration getCancelGracePeriod() { + return cancelGracePeriod; + } + + /** + * Sets {@code spring.acp.agent.cancel-grace-period}; see {@link #getCancelGracePeriod()}. + * @param cancelGracePeriod the grace period, zero for none, or {@code null} for the SDK's + * default; not negative + */ + public void setCancelGracePeriod(@Nullable Duration cancelGracePeriod) { + this.cancelGracePeriod = cancelGracePeriod; + } + + /** + * Returns how long a prompt turn may run before the agent answers it with error {@code -32800} + * (request cancelled) ({@code spring.acp.agent.max-prompt-duration}). Default: unset, which + * keeps the SDK's default of no limit; zero also means no limit. Maps to + * {@code AcpAgentSupport.Builder.maxPromptDuration}. + * @return the longest a turn may run, or {@code null} when unset + */ + public @Nullable Duration getMaxPromptDuration() { + return maxPromptDuration; + } + + /** + * Sets {@code spring.acp.agent.max-prompt-duration}; see {@link #getMaxPromptDuration()}. + * @param maxPromptDuration the longest a turn may run, zero for no limit, or {@code null} for + * the SDK's default; not negative + */ + public void setMaxPromptDuration(@Nullable Duration maxPromptDuration) { + this.maxPromptDuration = maxPromptDuration; + } + + /** + * Returns whether the application context closes when the agent's stdio transport ends on its + * own ({@code spring.acp.agent.shutdown-on-transport-end}). Default {@code true}; maps to + * {@link AcpAgentSettings#shutdownOnTransportEnd()}. + * + *

    A stdio transport ends when the client closes the agent's standard input and every answer + * has been written. The agent then has no one left to serve, so closing the context lets an + * application kept alive by {@code spring.main.keep-alive} exit. It applies to the single + * transport only: the stdio transport, or an {@code AcpAgentTransport} bean of the + * application's own. An HTTP endpoint serves many clients and never ends because one leaves. + * @return whether the context closes at transport end + */ + public boolean isShutdownOnTransportEnd() { + return shutdownOnTransportEnd; + } + + /** + * Sets {@code spring.acp.agent.shutdown-on-transport-end}; see + * {@link #isShutdownOnTransportEnd()}. + * @param shutdownOnTransportEnd whether the context closes at transport end + */ + public void setShutdownOnTransportEnd(boolean shutdownOnTransportEnd) { + this.shutdownOnTransportEnd = shutdownOnTransportEnd; + } + + /** + * Returns the executor the agent's handler methods run on + * ({@code spring.acp.agent.handler-executor}). Maps to + * {@code AcpAgentSupport.Builder.handlerExecutor}. + *

      + *
    • The name of an {@code Executor} bean: that bean. A plain {@code Executor}, such as a + * {@code TaskExecutor}, is adapted to an {@code ExecutorService}; cancelling a handler still + * interrupts its thread. The executor must allow blocking.
    • + *
    • {@code none}, in any case: the SDK's pool of platform threads.
    • + *
    • Unset (the default): the context's {@code applicationTaskExecutor} when + * {@code spring.threads.virtual.enabled=true} on JDK 21 and later, which starts a virtual + * thread per handler call; otherwise the SDK's pool of platform threads, on every JDK: the SDK + * follows Spring Boot's opt-in rather than its own default of virtual threads. Without + * virtual threads Spring Boot's {@code applicationTaskExecutor} is a pool of 8 threads by + * default, which would cap the prompts served at once; name it here to use it anyway.
    • + *
    + * A name that matches no bean, or a bean that is no {@code Executor}, fails the application's + * startup. + * @return the executor bean's name, {@code none}, or {@code null} when unset + */ + public @Nullable String getHandlerExecutor() { + return handlerExecutor; + } + + /** + * Sets {@code spring.acp.agent.handler-executor}; see {@link #getHandlerExecutor()}. + * @param handlerExecutor the executor bean's name, {@code none}, or {@code null} for the + * default + */ + public void setHandlerExecutor(@Nullable String handlerExecutor) { + this.handlerExecutor = handlerExecutor; + } + + /** + * Returns the transport properties, {@code spring.acp.agent.transport.*}. + * @return the transport properties + */ + public AgentTransportProperties getTransport() { + return transport; + } + + /** + * Replaces the transport properties, {@code spring.acp.agent.transport.*}. + * @param transport the transport properties + */ + public void setTransport(AgentTransportProperties transport) { + this.transport = transport; + } + + /** + * Returns these properties as the SDK's framework-neutral {@link AcpAgentSettings}, which the + * autoconfiguration passes to {@code acp-integration}. An unset timeout or limit stays unset + * ({@code null}), so the SDK applies its default; an unset transport type becomes + * {@link AcpTransportType#STDIO}. Each call builds new settings from the current values. + * @return the settings + */ + public AcpAgentSettings toSettings() { + AgentHttpProperties http = transport.getHttp(); + DataSize maxPostBodySize = http.getMaxPostBodySize(); + AcpTransportType type = transport.getType(); + return AcpAgentSettings.builder() + .enabled(enabled) + .requestTimeout(requestTimeout) + .cancelGracePeriod(cancelGracePeriod) + .maxPromptDuration(maxPromptDuration) + .shutdownOnTransportEnd(shutdownOnTransportEnd) + .transport((type != null) ? type : AcpTransportType.STDIO) + .path(http.getPath()) + .maxPostBodyBytes((maxPostBodySize != null) ? maxPostBodySize.toBytes() : null) + .keepAliveInterval(http.getKeepAliveInterval()) + .mailboxCapacity(http.getMailboxCapacity()) + .maxPendingSseEvents(http.getMaxPendingSseEvents()) + .maxWebSocketPendingFrames(http.getMaxWebSocketPendingFrames()) + .maxProvisionalSessions(http.getMaxProvisionalSessions()) + .shutdownTimeout(http.getShutdownTimeout()) + .webSocketIdleTimeout(http.getWebSocketIdleTimeout()) + .initializeTimeout(http.getInitializeTimeout()) + .allowedOrigins(http.getAllowedOrigins()) + .listenerHost(http.getListener().getHost()) + .listenerPort(http.getListener().getPort()) + .maxConcurrentStreamsPerConnection(http.getListener().getMaxConcurrentStreamsPerConnection()) + .build(); + } + + /** + * The {@code spring.acp.agent.transport.*} properties: which transport serves the agent, and + * the Streamable HTTP endpoint's settings ({@link AgentHttpProperties}). + */ + public static class AgentTransportProperties { + + /** + * The transport: stdio (the default), or http for Streamable HTTP and WebSocket on one path. + * websocket means the same as http. + */ + private @Nullable AcpTransportType type; + + private AgentHttpProperties http = new AgentHttpProperties(); + + /** + * Returns the transport ({@code spring.acp.agent.transport.type}). Maps to + * {@link AcpAgentSettings#transport()}. + *
      + *
    • {@code stdio}, or unset (the default): the agent reads standard input and writes + * standard output, so the application must log to standard error.
    • + *
    • {@code http}: ACP Streamable HTTP, and WebSocket on the same path, which needs + * {@code acp-http-servlet} (a servlet web application) or {@code acp-streamable-http-jetty} + * (the SDK's own listener) on the classpath; without either the application fails at + * startup naming them. See {@link AcpAgentHttpAutoConfiguration} for where the endpoint is + * served.
    • + *
    • {@code websocket}: the same as {@code http}.
    • + *
    + * The value is read in any case. + * @return the transport, or {@code null} when unset + */ + public @Nullable AcpTransportType getType() { + return type; + } + + /** + * Sets {@code spring.acp.agent.transport.type}; see {@link #getType()}. + * @param type the transport, or {@code null} for stdio + */ + public void setType(@Nullable AcpTransportType type) { + this.type = type; + } + + /** + * Returns the Streamable HTTP endpoint's properties, + * {@code spring.acp.agent.transport.http.*}. + * @return the HTTP properties + */ + public AgentHttpProperties getHttp() { + return http; + } + + /** + * Replaces the Streamable HTTP endpoint's properties, + * {@code spring.acp.agent.transport.http.*}. + * @param http the HTTP properties + */ + public void setHttp(AgentHttpProperties http) { + this.http = http; + } + + } + + /** + * The {@code spring.acp.agent.transport.http.*} properties: the Streamable HTTP endpoint that + * serves the agent when {@code spring.acp.agent.transport.type} is {@code http} or + * {@code websocket}. In a servlet web application the endpoint is mounted on the application's + * own server at {@code path}; otherwise the SDK's listener serves it on {@code listener.port} + * ({@link ListenerProperties}), with WebSocket upgrades on the same path. + * + *

    The limits bound every buffer the endpoint keeps for a client. Each one left unset keeps + * the SDK's default, which its getter names; together they map to + * {@link com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions}. + */ + public static class AgentHttpProperties { + + /** + * Endpoint path. Default /acp. + */ + private String path = AcpAgentSettings.DEFAULT_PATH; + + /** + * Largest accepted inbound message (POST body or WebSocket text message). Unset keeps the + * SDK default, 16MB. + */ + private @Nullable DataSize maxPostBodySize; + + /** + * Interval between SSE keep-alive comments; zero disables them. Unset keeps the SDK + * default, 15 seconds. + */ + private @Nullable Duration keepAliveInterval; + + /** + * Events retained per outbound stream while no subscriber is attached. Unset keeps the SDK + * default, 1024. + */ + private @Nullable Integer mailboxCapacity; + + /** + * Events queued for one attached SSE subscriber before it is detached. Unset keeps the SDK + * default, 1024. + */ + private @Nullable Integer maxPendingSseEvents; + + /** + * Frames queued for one WebSocket connection before it is closed. Unset keeps the SDK + * default, 1024. + */ + private @Nullable Integer maxWebSocketPendingFrames; + + /** + * Session streams a connection may open before the session is known. Unset keeps the SDK + * default, 64. + */ + private @Nullable Integer maxProvisionalSessions; + + /** + * How long closing the endpoint waits for its connections to close gracefully before + * closing the rest at once. Unset keeps the SDK default, 5 seconds. + */ + private @Nullable Duration shutdownTimeout; + + /** + * How long a WebSocket connection may pass no frame in either direction before the + * endpoint closes it (1001). Unset keeps the SDK default, 30 minutes. + */ + private @Nullable Duration webSocketIdleTimeout; + + /** + * How long a connection has to complete initialize: a WebSocket that has not sent it is + * closed (1008), and a POST initialize the agent has not answered is answered 500. Unset + * keeps the SDK default, 30 seconds. + */ + private @Nullable Duration initializeTimeout; + + /** + * Browser origins accepted besides the loopback ones, such as https://app.example.com, or + * * for any. A request from any other origin is refused with 403. Default none. + */ + private List allowedOrigins = new ArrayList<>(); + + private ListenerProperties listener = new ListenerProperties(); + + /** + * Returns the browser origins the endpoint accepts besides the loopback ones + * ({@code spring.acp.agent.transport.http.allowed-origins}). A request without an + * {@code Origin} header, or from {@code http(s)://localhost}, {@code 127.0.0.1} or + * {@code [::1]} on any port, is always accepted; any other origin is answered 403, over + * HTTP and on the WebSocket handshake, unless listed here. {@code *} accepts any origin. + * Default empty. Maps to {@link AcpAgentSettings.Http#allowedOrigins()}. + * @return the origins + */ + public List getAllowedOrigins() { + return allowedOrigins; + } + + /** + * Sets {@code spring.acp.agent.transport.http.allowed-origins}; see + * {@link #getAllowedOrigins()}. + * @param allowedOrigins the origins + */ + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + /** + * Returns the endpoint's path ({@code spring.acp.agent.transport.http.path}). Default + * {@value AcpAgentSettings#DEFAULT_PATH}. In a servlet web application the servlet is + * mapped at this path on the application's server; the SDK's listener serves it on its own + * port. Maps to {@link AcpAgentSettings.Http#path()}. + * @return the path + */ + public String getPath() { + return path; + } + + /** + * Sets {@code spring.acp.agent.transport.http.path}; see {@link #getPath()}. + * @param path the path + */ + public void setPath(String path) { + this.path = path; + } + + /** + * Returns the largest message accepted from a client + * ({@code spring.acp.agent.transport.http.max-post-body-size}). A larger POST body is + * answered 413, and a larger WebSocket text message closes the connection. Default: unset, + * which keeps the SDK's default of 16 MB. Maps to the transport option + * {@code maxPostBodyBytes}. + * @return the limit, or {@code null} when unset + */ + public @Nullable DataSize getMaxPostBodySize() { + return maxPostBodySize; + } + + /** + * Sets {@code spring.acp.agent.transport.http.max-post-body-size}; see + * {@link #getMaxPostBodySize()}. + * @param maxPostBodySize the limit, positive, or {@code null} for the SDK's default + */ + public void setMaxPostBodySize(@Nullable DataSize maxPostBodySize) { + this.maxPostBodySize = maxPostBodySize; + } + + /** + * Returns the interval between keep-alive comments on open SSE streams, which stop proxies + * from cutting idle streams ({@code spring.acp.agent.transport.http.keep-alive-interval}). + * Zero turns them off. Default: unset, which keeps the SDK's default of 15 seconds. Maps to + * the transport option {@code keepAliveInterval}. + * @return the interval, or {@code null} when unset + */ + public @Nullable Duration getKeepAliveInterval() { + return keepAliveInterval; + } + + /** + * Sets {@code spring.acp.agent.transport.http.keep-alive-interval}; see + * {@link #getKeepAliveInterval()}. + * @param keepAliveInterval the interval, zero for none, or {@code null} for the SDK's + * default; not negative + */ + public void setKeepAliveInterval(@Nullable Duration keepAliveInterval) { + this.keepAliveInterval = keepAliveInterval; + } + + /** + * Returns how many events one SSE stream keeps unsent while no client is reading it, to + * send when the client reconnects + * ({@code spring.acp.agent.transport.http.mailbox-capacity}). One more closes the + * connection. Default: unset, which keeps the SDK's default of 1024. Maps to the transport + * option {@code mailboxCapacity}. + * @return the event count, or {@code null} when unset + */ + public @Nullable Integer getMailboxCapacity() { + return mailboxCapacity; + } + + /** + * Sets {@code spring.acp.agent.transport.http.mailbox-capacity}; see + * {@link #getMailboxCapacity()}. + * @param mailboxCapacity the event count, positive, or {@code null} for the SDK's default + */ + public void setMailboxCapacity(@Nullable Integer mailboxCapacity) { + this.mailboxCapacity = mailboxCapacity; + } + + /** + * Returns how many events are queued for a client that is reading an SSE stream + * ({@code spring.acp.agent.transport.http.max-pending-sse-events}). One more detaches that + * client and keeps the events for its next GET. Default: unset, which keeps the SDK's + * default of 1024. Maps to the transport option {@code maxPendingSseEvents}. + * @return the event count, or {@code null} when unset + */ + public @Nullable Integer getMaxPendingSseEvents() { + return maxPendingSseEvents; + } + + /** + * Sets {@code spring.acp.agent.transport.http.max-pending-sse-events}; see + * {@link #getMaxPendingSseEvents()}. + * @param maxPendingSseEvents the event count, positive, or {@code null} for the SDK's + * default + */ + public void setMaxPendingSseEvents(@Nullable Integer maxPendingSseEvents) { + this.maxPendingSseEvents = maxPendingSseEvents; + } + + /** + * Returns how many frames are queued for one WebSocket connection + * ({@code spring.acp.agent.transport.http.max-web-socket-pending-frames}). One more closes + * the connection. Default: unset, which keeps the SDK's default of 1024. Maps to the + * transport option {@code maxWebSocketPendingFrames}. + * @return the frame count, or {@code null} when unset + */ + public @Nullable Integer getMaxWebSocketPendingFrames() { + return maxWebSocketPendingFrames; + } + + /** + * Sets {@code spring.acp.agent.transport.http.max-web-socket-pending-frames}; see + * {@link #getMaxWebSocketPendingFrames()}. + * @param maxWebSocketPendingFrames the frame count, positive, or {@code null} for the SDK's + * default + */ + public void setMaxWebSocketPendingFrames(@Nullable Integer maxWebSocketPendingFrames) { + this.maxWebSocketPendingFrames = maxWebSocketPendingFrames; + } + + /** + * Returns how many session streams a connection may open before the agent knows the + * session, as a client does before {@code session/load} + * ({@code spring.acp.agent.transport.http.max-provisional-sessions}). A further one is + * refused. Default: unset, which keeps the SDK's default of 64. Maps to the transport + * option {@code maxProvisionalSessions}. + * @return the stream count, or {@code null} when unset + */ + public @Nullable Integer getMaxProvisionalSessions() { + return maxProvisionalSessions; + } + + /** + * Sets {@code spring.acp.agent.transport.http.max-provisional-sessions}; see + * {@link #getMaxProvisionalSessions()}. + * @param maxProvisionalSessions the stream count, positive, or {@code null} for the SDK's + * default + */ + public void setMaxProvisionalSessions(@Nullable Integer maxProvisionalSessions) { + this.maxProvisionalSessions = maxProvisionalSessions; + } + + /** + * Returns how long closing the endpoint waits for its connections' agents to close before + * it closes the rest at once ({@code spring.acp.agent.transport.http.shutdown-timeout}). + * Default: unset, which keeps the SDK's default of 5 seconds. Maps to the transport option + * {@code shutdownTimeout}. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getShutdownTimeout() { + return shutdownTimeout; + } + + /** + * Sets {@code spring.acp.agent.transport.http.shutdown-timeout}; see + * {@link #getShutdownTimeout()}. + * @param shutdownTimeout the timeout, positive, or {@code null} for the SDK's default + */ + public void setShutdownTimeout(@Nullable Duration shutdownTimeout) { + this.shutdownTimeout = shutdownTimeout; + } + + /** + * Returns how long a WebSocket connection may pass no frame in either direction before the + * endpoint closes it with 1001 + * ({@code spring.acp.agent.transport.http.web-socket-idle-timeout}). Default: unset, which + * keeps the SDK's default of 30 minutes. Maps to the transport option + * {@code webSocketIdleTimeout}. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getWebSocketIdleTimeout() { + return webSocketIdleTimeout; + } + + /** + * Sets {@code spring.acp.agent.transport.http.web-socket-idle-timeout}; see + * {@link #getWebSocketIdleTimeout()}. + * @param webSocketIdleTimeout the timeout, positive, or {@code null} for the SDK's default + */ + public void setWebSocketIdleTimeout(@Nullable Duration webSocketIdleTimeout) { + this.webSocketIdleTimeout = webSocketIdleTimeout; + } + + /** + * Returns how long a connection has to complete {@code initialize}: a WebSocket that has + * not sent it is closed with 1008, and a POST {@code initialize} the agent has not + * answered is answered 500 ({@code spring.acp.agent.transport.http.initialize-timeout}). + * Default: unset, which keeps the SDK's default of 30 seconds. Maps to the transport + * option {@code initializeTimeout}. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getInitializeTimeout() { + return initializeTimeout; + } + + /** + * Sets {@code spring.acp.agent.transport.http.initialize-timeout}; see + * {@link #getInitializeTimeout()}. + * @param initializeTimeout the timeout, positive, or {@code null} for the SDK's default + */ + public void setInitializeTimeout(@Nullable Duration initializeTimeout) { + this.initializeTimeout = initializeTimeout; + } + + /** + * Returns the SDK listener's properties, + * {@code spring.acp.agent.transport.http.listener.*}. + * @return the listener properties + */ + public ListenerProperties getListener() { + return listener; + } + + /** + * Replaces the SDK listener's properties, + * {@code spring.acp.agent.transport.http.listener.*}. + * @param listener the listener properties + */ + public void setListener(ListenerProperties listener) { + this.listener = listener; + } + + } + + /** + * The {@code spring.acp.agent.transport.http.listener.*} properties: the SDK's own listener, + * which serves the endpoint outside a servlet web application. A servlet web application serves + * the endpoint on its own server and port ({@code server.port}) and ignores these. + */ + public static class ListenerProperties { + + /** + * Address the standalone listener binds. Unset binds the loopback interface only + * (127.0.0.1 and ::1); 0.0.0.0 exposes the agent on every interface. + */ + private @Nullable String host; + + /** + * Port of the standalone listener; 0 for an ephemeral port. Default 8080. + */ + private int port = AcpAgentSettings.DEFAULT_LISTENER_PORT; + + /** + * HTTP/2 streams one client connection may hold open. Unset keeps the SDK default, 1024. + */ + private @Nullable Integer maxConcurrentStreamsPerConnection; + + /** + * Returns the address the listener binds + * ({@code spring.acp.agent.transport.http.listener.host}). Default: unset, which binds the + * loopback interface only ({@code 127.0.0.1}, and {@code ::1} where the machine has IPv6), + * so only programs on the same machine can connect. {@code 0.0.0.0} exposes the agent on + * every interface; the endpoint has no authentication of its own, so do that only behind a + * proxy or firewall that controls who connects. A servlet web application's own server + * ignores it ({@code server.address} applies there). Maps to + * {@link AcpAgentSettings.Listener#host()}. + * @return the host, or {@code null} for loopback only + */ + public @Nullable String getHost() { + return host; + } + + /** + * Sets {@code spring.acp.agent.transport.http.listener.host}; see {@link #getHost()}. + * @param host a host name or address, or {@code null} for loopback only + */ + public void setHost(@Nullable String host) { + this.host = host; + } + + /** + * Returns the listener's port ({@code spring.acp.agent.transport.http.listener.port}). + * Default {@value AcpAgentSettings#DEFAULT_LISTENER_PORT}; {@code 0} picks a free port. + * Maps to {@link AcpAgentSettings.Listener#port()}. + * @return the port + */ + public int getPort() { + return port; + } + + /** + * Sets {@code spring.acp.agent.transport.http.listener.port}; see {@link #getPort()}. + * @param port the port, or {@code 0} for a free one + */ + public void setPort(int port) { + this.port = port; + } + + /** + * Returns how many HTTP/2 streams one client connection may hold open at once, each open + * SSE stream holding one + * ({@code spring.acp.agent.transport.http.listener.max-concurrent-streams-per-connection}). + * Default: unset, which keeps the SDK's default of 1024. Maps to the transport option + * {@code maxConcurrentStreamsPerConnection}. + * @return the stream count, or {@code null} when unset + */ + public @Nullable Integer getMaxConcurrentStreamsPerConnection() { + return maxConcurrentStreamsPerConnection; + } + + /** + * Sets + * {@code spring.acp.agent.transport.http.listener.max-concurrent-streams-per-connection}; + * see {@link #getMaxConcurrentStreamsPerConnection()}. + * @param maxConcurrentStreamsPerConnection the stream count, positive, or {@code null} for + * the SDK's default + */ + public void setMaxConcurrentStreamsPerConnection(@Nullable Integer maxConcurrentStreamsPerConnection) { + this.maxConcurrentStreamsPerConnection = maxConcurrentStreamsPerConnection; + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfiguration.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfiguration.java new file mode 100644 index 00000000..739059d2 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfiguration.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.integration.AcpAgentTransports; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; + +/** + * Creates the stdio transport that {@link AcpAgentAutoConfiguration} serves the application's + * {@code @AcpAgent} bean on. It applies when the application has an {@code @AcpAgent} bean and no + * {@code AcpAgentTransport} bean of its own, and {@code spring.acp.agent.transport.type} is + * {@code stdio} or unset. An application that defines its own transport bean, such as the agent + * side of an {@code InMemoryTransportPair} in a test, is served on that one instead. + * + *

    The stdio transport reads the client's messages from standard input and writes its own to + * standard output, so nothing else may write to standard output: Spring Boot's banner and console + * logging do by default, so turn the banner off and send logging to standard error. The transport + * bean has no destroy method; the agent's lifecycle closes it when it stops the agent. + * + *

    With {@code type} {@code http} or {@code websocket} but none of {@code acp-http-servlet}, + * {@code acp-http-webflux} and {@code acp-streamable-http-jetty} on the classpath, the startup + * fails with an error naming them. Nothing here applies when + * {@code spring.acp.agent.enabled=false}. + */ +@AutoConfiguration +@ConditionalOnClass(AcpAgent.class) +@ConditionalOnProperty(prefix = "spring.acp.agent", name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(AcpAgentProperties.class) +public class AcpAgentTransportAutoConfiguration { + + // Only for an application that defines an @AcpAgent bean: a client-only application + // gets no agent transport. + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(AcpAgentTransport.class) + @ConditionalOnBean(annotation = com.agentclientprotocol.sdk.annotation.AcpAgent.class) + @ConditionalOnProperty(prefix = "spring.acp.agent.transport", name = "type", havingValue = "stdio", + matchIfMissing = true) + static class StdioAgentTransportConfiguration { + + // The agent lifecycle closes the transport when it stops the agent. + @Bean(destroyMethod = "") + AcpAgentTransport acpAgentTransport() { + return AcpAgentTransports.stdio(); + } + + } + + // type=http without the HTTP module would otherwise leave the application with no agent + // and no explanation. + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(annotation = com.agentclientprotocol.sdk.annotation.AcpAgent.class) + @Conditional(OnHttpAgentTransportCondition.class) + @ConditionalOnMissingClass({ "com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet", + "com.agentclientprotocol.sdk.http.webflux.AcpWebFluxHost" }) + static class MissingHttpTransportConfiguration { + + @Bean + Object acpAgentHttpTransportMissing(AcpAgentProperties properties) { + throw new IllegalStateException("spring.acp.agent.transport.type=" + + properties.toSettings().transport().value() + " needs " + + "com.agentclientprotocol:acp-http-servlet (a servlet web application), " + + "com.agentclientprotocol:acp-http-webflux (a reactive web application) or " + + "com.agentclientprotocol:acp-streamable-http-jetty (the SDK's own listener) on the classpath"); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutors.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutors.java new file mode 100644 index 00000000..f2e722d4 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutors.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; + +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import com.agentclientprotocol.sdk.util.PlatformThreads; + +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.thread.Threading; +import org.springframework.context.ApplicationContext; +import org.springframework.core.task.support.ExecutorServiceAdapter; + +/** + * The threads the agent runs on, by Spring Boot's own opt-in to virtual threads + * ({@code spring.threads.virtual.enabled=true} on JDK 21 and later). + *

    + * The handler methods ({@code spring.acp.agent.handler-executor}): + *

      + *
    • A bean name: that {@code Executor} bean, adapted to an {@code ExecutorService} when it is + * a plain {@code TaskExecutor}.
    • + *
    • {@code none}: the SDK's pool of platform threads.
    • + *
    • Unset: with virtual threads on, the context's {@code applicationTaskExecutor}, where it + * starts a virtual thread per task; otherwise the SDK's pool of platform threads, on every JDK, + * since the application has not opted into virtual threads. Without virtual threads that + * executor is a pool of 8 threads by default, which would cap the prompts served at once; name + * the bean to use it anyway.
    • + *
    + * The listener: the {@code applicationTaskExecutor} with virtual threads on, else Jetty's pool + * of platform threads. + */ +final class HandlerExecutors { + + static final String NONE = "none"; + + private HandlerExecutors() { + } + + static ExecutorService resolve(ApplicationContext context, AcpAgentProperties properties) { + return resolve(context, properties, virtualThreadsActive(context)); + } + + /** + * The handler executor, with Spring Boot's virtual-thread opt-in given: tests decide it on any + * JDK, where {@link Threading#VIRTUAL} is never active before 21. + */ + static ExecutorService resolve(ApplicationContext context, AcpAgentProperties properties, + boolean virtualThreads) { + String name = properties.getHandlerExecutor(); + if (name == null) { + Executor applicationTaskExecutor = virtualThreads ? applicationTaskExecutor(context) : null; + if (applicationTaskExecutor == null) { + return PlatformPool.POOL; + } + return adapt(applicationTaskExecutor); + } + if (NONE.equalsIgnoreCase(name)) { + return PlatformPool.POOL; + } + Object bean = context.getBean(name); + if (bean instanceof Executor executor) { + return adapt(executor); + } + throw new IllegalStateException( + "spring.acp.agent.handler-executor=" + name + " names a " + bean.getClass().getName() + ", not an Executor"); + } + + /** The listener's threads: the virtual-thread task executor, else platform threads. */ + static AcpTransportThreads listenerThreads(ApplicationContext context) { + return listenerThreads(context, virtualThreadsActive(context)); + } + + /** The listener's threads, with Spring Boot's virtual-thread opt-in given. */ + static AcpTransportThreads listenerThreads(ApplicationContext context, boolean virtualThreads) { + Executor applicationTaskExecutor = virtualThreads ? applicationTaskExecutor(context) : null; + return (applicationTaskExecutor != null) ? AcpTransportThreads.executor(applicationTaskExecutor) + : AcpTransportThreads.platform(); + } + + /** Spring Boot's opt-in: {@code spring.threads.virtual.enabled=true} on JDK 21 and later. */ + private static boolean virtualThreadsActive(ApplicationContext context) { + return Threading.VIRTUAL.isActive(context.getEnvironment()); + } + + /** The context's {@code applicationTaskExecutor}, or null when it has none. */ + private static @org.jspecify.annotations.Nullable Executor applicationTaskExecutor(ApplicationContext context) { + String name = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME; + if (!context.containsBean(name) || !(context.getBean(name) instanceof Executor executor)) { + return null; + } + return executor; + } + + private static ExecutorService adapt(Executor executor) { + if (executor instanceof ExecutorService executorService) { + return executorService; + } + // Submitted tasks are FutureTasks, so cancelling a handler still interrupts it. + return new ExecutorServiceAdapter(executor::execute); + } + + /** + * The SDK's pool of daemon platform threads, shared by the agents of every context in the JVM + * as the SDK shares its own; idle threads end after a minute. + */ + private static final class PlatformPool { + + static final ExecutorService POOL = PlatformThreads.newCachedPool("acp-agent-sync-handler"); + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/OnHttpAgentTransportCondition.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/OnHttpAgentTransportCondition.java new file mode 100644 index 00000000..04413d7e --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/OnHttpAgentTransportCondition.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import com.agentclientprotocol.sdk.integration.AcpTransportType; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * Matches {@code spring.acp.agent.transport.type=http} or {@code websocket}, in any case: both + * mean the Streamable HTTP endpoint, which takes WebSocket upgrades on its path. + */ +class OnHttpAgentTransportCondition extends SpringBootCondition { + + static final String PROPERTY = "spring.acp.agent.transport.type"; + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + String type = context.getEnvironment().getProperty(PROPERTY); + if (type == null || type.isBlank()) { + return ConditionOutcome.noMatch(PROPERTY + " is not set"); + } + boolean http; + try { + http = AcpTransportType.parse(type) != AcpTransportType.STDIO; + } + catch (IllegalArgumentException ex) { + // The properties binding reports the value. + return ConditionOutcome.noMatch(PROPERTY + "=" + type + " is no transport type"); + } + return http ? ConditionOutcome.match(PROPERTY + "=" + type) + : ConditionOutcome.noMatch(PROPERTY + "=" + type); + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/WebSocketApiCheck.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/WebSocketApiCheck.java new file mode 100644 index 00000000..ada89afa --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/WebSocketApiCheck.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.security.CodeSource; +import java.util.Arrays; + +import org.jspecify.annotations.Nullable; + +/** + * Refuses to serve ACP on Tomcat 11 when the Jakarta WebSocket API classes on the classpath are + * older than the 2.2 that Tomcat implements: Tomcat then fails every WebSocket send with + * {@code NoSuchMethodError} ({@code SendResult(Session)}), and clients hang without a word in + * the server's log. It happens when a build takes the API from Jetty's jars without managing its + * version, as adding {@code acp-streamable-http-jetty} to a Boot web application used to do. + * Checked by name, so nothing here loads unless Tomcat's WebSocket support is present. + * + * @author Mark Pollack + */ +final class WebSocketApiCheck { + + private static final String TOMCAT_WEBSOCKET = "org.apache.tomcat.websocket.server.WsSci"; + + private WebSocketApiCheck() { + } + + /** + * Fails when Tomcat's WebSocket support sees an older Jakarta WebSocket API. + * @param classLoader the application's class loader + * @throws IllegalStateException naming the fix + */ + static void requireCompatible(@Nullable ClassLoader classLoader) { + String problem = problem(classLoader); + if (problem != null) { + throw new IllegalStateException(problem); + } + } + + static @Nullable String problem(@Nullable ClassLoader classLoader) { + Class sendResult; + Class session; + try { + Class.forName(TOMCAT_WEBSOCKET, false, classLoader); + sendResult = Class.forName("jakarta.websocket.SendResult", false, classLoader); + session = Class.forName("jakarta.websocket.Session", false, classLoader); + } + catch (ClassNotFoundException | LinkageError e) { + return null; + } + boolean api22 = Arrays.stream(sendResult.getConstructors()) + .anyMatch(constructor -> Arrays.equals(constructor.getParameterTypes(), new Class[] { session })); + if (api22) { + return null; + } + return "ACP WebSocket on Tomcat 11 needs the Jakarta WebSocket 2.2 API, but the " + + "jakarta.websocket classes come from an older one (" + location(sendResult) + + "), and Tomcat would fail every WebSocket send. In a servlet web application add " + + "com.agentclientprotocol:acp-http-servlet, not acp-streamable-http-jetty (the SDK's own " + + "listener, for applications without a web server), or manage " + + "jakarta.websocket:jakarta.websocket-api and jakarta.websocket-client-api to 2.2.0"; + } + + private static String location(Class type) { + CodeSource source = type.getProtectionDomain().getCodeSource(); + return (source != null && source.getLocation() != null) ? source.getLocation().toString() : "unknown jar"; + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/package-info.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/package-info.java new file mode 100644 index 00000000..32628061 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/package-info.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Autoconfiguration of an ACP agent: its transport, its lifecycle, and serving it over Streamable HTTP. + */ +@NullMarked +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfiguration.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfiguration.java new file mode 100644 index 00000000..5ed772eb --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfiguration.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.integration.AcpClientHost; +import com.agentclientprotocol.sdk.integration.AcpClients; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; + +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Creates the application's ACP client on the client transport bean: an {@link AcpAsyncClient}, and + * an {@link AcpSyncClient} over that same client, so both share one connection. Inject either. It + * applies when there is an {@code AcpClientTransport} bean, which + * {@link AcpClientTransportAutoConfiguration} creates from {@code spring.acp.client.transport.*}, + * or the application defines itself. + * + *

    The client advertises the capabilities and uses the timeouts of {@link AcpClientProperties}. + * Every {@link AcpClientCustomizer} bean is applied to its builder, in bean order: register the + * session-update handler, the permission handler, and the file system, terminal and elicitation + * handlers there. Without a session-update handler of the application's own, session updates are + * logged at DEBUG. Creating the client connects the transport (for stdio, starts the agent process) + * when the context starts; the application then calls {@code initialize()} and opens sessions. + * + *

    When the context closes, the client is closed once: pending notifications are delivered, + * waiting at most the request timeout plus 10 seconds, then the transport is closed. An + * {@code AcpAsyncClient} or {@code AcpSyncClient} bean of the application's own replaces the one + * created here. + */ +@AutoConfiguration(after = AcpClientTransportAutoConfiguration.class) +@ConditionalOnClass(AcpClient.class) +@ConditionalOnBean(AcpClientTransport.class) +@EnableConfigurationProperties(AcpClientProperties.class) +public class AcpClientAutoConfiguration { + + // destroyMethod = "": AcpClientLifecycle closes the client (and with it the transport) + // once; Spring's inferred close() on these beans and the transport bean closed it three + // times. + @Bean(destroyMethod = "") + @ConditionalOnMissingBean + AcpAsyncClient acpAsyncClient(AcpClientTransport transport, AcpClientProperties properties, + ObjectProvider customizers) { + return AcpClients.async(transport, properties.toSettings(), customizers.orderedStream().toList(), + AcpClientTransportAutoConfiguration.PREFIX); + } + + /** + * The sync client is a facade over the async client: one session, one transport + * connection. + */ + @Bean(destroyMethod = "") + @ConditionalOnMissingBean + AcpSyncClient acpSyncClient(AcpAsyncClient asyncClient) { + return AcpClients.sync(asyncClient); + } + + @Bean + AcpClientLifecycle acpClientLifecycle(AcpAsyncClient asyncClient, AcpClientProperties properties) { + return new AcpClientLifecycle(new AcpClientHost(asyncClient), properties.toSettings().closeTimeout()); + } + + static class AcpClientLifecycle implements DisposableBean { + + private final AcpClientHost host; + + private final Duration timeout; + + AcpClientLifecycle(AcpClientHost host, Duration timeout) { + this.host = host; + this.timeout = timeout; + } + + @Override + public void destroy() { + host.close(timeout); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientProperties.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientProperties.java new file mode 100644 index 00000000..db4337e9 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientProperties.java @@ -0,0 +1,576 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import org.jspecify.annotations.Nullable; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The {@code spring.acp.client.*} configuration properties: which agent the application's ACP + * client connects to, and how. They choose the transport (start an agent process over stdio, or + * connect to a WebSocket or Streamable HTTP endpoint), set the client's timeouts, and say which + * capabilities the client advertises. Set them in {@code application.properties} or + * {@code application.yaml}; {@link AcpClientTransportAutoConfiguration} and + * {@link AcpClientAutoConfiguration} read them through {@link #toSettings()}. + * + *

    Setting a transport's command or URI is what creates the client: with no + * {@code spring.acp.client.transport.*} property set, the application gets no client. A timeout + * left unset keeps the SDK's default, which its getter names. Durations take Spring's forms + * ({@code 30s}, {@code 500ms}, {@code PT1M}). + * + *

    The properties map onto {@link AcpClientSettings}, the framework-neutral settings the + * Micronaut and Quarkus integrations fill from their own keys, so each property means the same + * there. The timeouts end up on the client's builder, + * {@link com.agentclientprotocol.sdk.client.AcpClient.AsyncSpec}. + */ +@ConfigurationProperties(prefix = "spring.acp.client") +public class AcpClientProperties { + + /** + * How long the client waits for the agent to answer a request. Unset keeps the SDK default, 60 + * seconds. + */ + private @Nullable Duration requestTimeout; + + /** + * How long a prompt turn may take before the client cancels it; unset (the default) for no + * limit. Prompts are not bound by the request timeout. + */ + private @Nullable Duration promptTimeout; + + private TransportProperties transport = new TransportProperties(); + + private CapabilitiesProperties capabilities = new CapabilitiesProperties(); + + /** + * Returns how long the client waits for the agent to answer a request other than a prompt + * ({@code spring.acp.client.request-timeout}). Default: unset, which keeps the SDK's default of + * 60 seconds. Maps to {@code AcpClient.AsyncSpec.requestTimeout}. Closing the client at context + * shutdown also waits at most this long plus 10 seconds before it closes at once. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getRequestTimeout() { + return requestTimeout; + } + + /** + * Sets {@code spring.acp.client.request-timeout}; see {@link #getRequestTimeout()}. + * @param requestTimeout the timeout, or {@code null} for the SDK's default + */ + public void setRequestTimeout(@Nullable Duration requestTimeout) { + this.requestTimeout = requestTimeout; + } + + /** + * Returns how long a prompt turn ({@code session/prompt}) may take + * ({@code spring.acp.client.prompt-timeout}). When it passes, {@code prompt} fails with a + * {@link java.util.concurrent.TimeoutException} and the client sends the agent a + * {@code $/cancel_request}. Default: unset, which keeps the SDK's default of no limit; zero + * also means no limit. The request timeout never applies to prompts. Maps to + * {@code AcpClient.AsyncSpec.promptTimeout}. + * @return the timeout, or {@code null} when unset + */ + public @Nullable Duration getPromptTimeout() { + return promptTimeout; + } + + /** + * Sets {@code spring.acp.client.prompt-timeout}; see {@link #getPromptTimeout()}. + * @param promptTimeout the timeout, zero for no limit, or {@code null} for the SDK's default; + * not negative + */ + public void setPromptTimeout(@Nullable Duration promptTimeout) { + this.promptTimeout = promptTimeout; + } + + /** + * Returns the transport properties, {@code spring.acp.client.transport.*}. + * @return the transport properties + */ + public TransportProperties getTransport() { + return transport; + } + + /** + * Replaces the transport properties, {@code spring.acp.client.transport.*}. + * @param transport the transport properties + */ + public void setTransport(TransportProperties transport) { + this.transport = transport; + } + + /** + * Returns the advertised capabilities, {@code spring.acp.client.capabilities.*}. + * @return the capability properties + */ + public CapabilitiesProperties getCapabilities() { + return capabilities; + } + + /** + * Replaces the advertised capabilities, {@code spring.acp.client.capabilities.*}. + * @param capabilities the capability properties + */ + public void setCapabilities(CapabilitiesProperties capabilities) { + this.capabilities = capabilities; + } + + /** + * Returns these properties as the SDK's framework-neutral {@link AcpClientSettings}, which the + * autoconfiguration passes to {@code acp-integration}. An unset timeout or transport type stays + * unset ({@code null}), so the SDK applies its default or infers the type. Each call builds new + * settings from the current values. + * @return the settings + */ + public AcpClientSettings toSettings() { + CapabilitiesProperties caps = capabilities; + return AcpClientSettings.builder() + .requestTimeout(requestTimeout) + .promptTimeout(promptTimeout) + .transport(transport.getType()) + .stdioCommand(transport.getStdio().getCommand()) + .stdioArgs(transport.getStdio().getArgs()) + .stdioEnv(transport.getStdio().getEnv()) + .websocketUri(transport.getWebsocket().getUri()) + .websocketConnectTimeout(transport.getWebsocket().getConnectTimeout()) + .httpUri(transport.getHttp().getUri()) + .capabilities(new AcpClientSettings.Capabilities(caps.isReadTextFile(), caps.isWriteTextFile(), + caps.isTerminal(), caps.isElicitationForm(), caps.isElicitationUrl(), + caps.isBooleanConfigOptions())) + .build(); + } + + /** + * The {@code spring.acp.client.transport.*} properties: which transport the client uses, and + * each transport's own settings. Set the command or URI of one transport and it is chosen; set + * {@code type} as well only when more than one is set. + */ + public static class TransportProperties { + + /** + * The transport: stdio, websocket or http. Unset, it is the one whose command or URI + * is set; with several set, it must be given. + */ + private @Nullable AcpTransportType type; + + private WebSocketProperties websocket = new WebSocketProperties(); + + private StdioProperties stdio = new StdioProperties(); + + private HttpProperties http = new HttpProperties(); + + /** + * Returns the transport ({@code spring.acp.client.transport.type}): {@code stdio}, + * {@code websocket} or {@code http}, read in any case. Maps to + * {@link AcpClientSettings#transport()}. + * + *

    Unset (the default), the transport is the one whose {@code stdio.command}, + * {@code websocket.uri} or {@code http.uri} is set; with more than one set, the application + * fails at startup naming them. A type that is set wins, and fails the startup when its own + * command or URI is missing, naming that property. + * @return the transport, or {@code null} when unset + */ + public @Nullable AcpTransportType getType() { + return type; + } + + /** + * Sets {@code spring.acp.client.transport.type}; see {@link #getType()}. + * @param type the transport, or {@code null} to infer it + */ + public void setType(@Nullable AcpTransportType type) { + this.type = type; + } + + /** + * Returns the WebSocket properties, {@code spring.acp.client.transport.websocket.*}. + * @return the WebSocket properties + */ + public WebSocketProperties getWebsocket() { + return websocket; + } + + /** + * Replaces the WebSocket properties, {@code spring.acp.client.transport.websocket.*}. + * @param websocket the WebSocket properties + */ + public void setWebsocket(WebSocketProperties websocket) { + this.websocket = websocket; + } + + /** + * Returns the stdio properties, {@code spring.acp.client.transport.stdio.*}. + * @return the stdio properties + */ + public StdioProperties getStdio() { + return stdio; + } + + /** + * Replaces the stdio properties, {@code spring.acp.client.transport.stdio.*}. + * @param stdio the stdio properties + */ + public void setStdio(StdioProperties stdio) { + this.stdio = stdio; + } + + /** + * Returns the Streamable HTTP properties, {@code spring.acp.client.transport.http.*}. + * @return the HTTP properties + */ + public HttpProperties getHttp() { + return http; + } + + /** + * Replaces the Streamable HTTP properties, {@code spring.acp.client.transport.http.*}. + * @param http the HTTP properties + */ + public void setHttp(HttpProperties http) { + this.http = http; + } + + } + + /** + * The {@code spring.acp.client.transport.http.*} properties: the Streamable HTTP client + * transport, for an agent served over HTTP, such as one served by + * {@code spring.acp.agent.transport.type=http}. + */ + public static class HttpProperties { + + /** + * Endpoint of the agent (e.g. http://localhost:8080/acp). + */ + private @Nullable URI uri; + + /** + * Returns the agent's endpoint ({@code spring.acp.client.transport.http.uri}), such as + * {@code http://localhost:8080/acp}. Setting it selects the Streamable HTTP transport, + * {@link com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport}. No + * default. Maps to {@link AcpClientSettings.Http#uri()}. + * @return the endpoint, or {@code null} when unset + */ + public @Nullable URI getUri() { + return uri; + } + + /** + * Sets {@code spring.acp.client.transport.http.uri}; see {@link #getUri()}. + * @param uri the endpoint, or {@code null} for none + */ + public void setUri(@Nullable URI uri) { + this.uri = uri; + } + + } + + /** + * The {@code spring.acp.client.transport.websocket.*} properties: the WebSocket client + * transport, for an agent that accepts WebSocket connections, such as the SDK's listener. + */ + public static class WebSocketProperties { + + /** + * Endpoint of the agent (e.g. ws://localhost:8080/acp). + */ + private @Nullable URI uri; + + /** + * How long connecting to the agent may take. Default 10 seconds. + */ + private Duration connectTimeout = AcpClientSettings.DEFAULT_CONNECT_TIMEOUT; + + /** + * Returns the agent's endpoint ({@code spring.acp.client.transport.websocket.uri}), such as + * {@code ws://localhost:8080/acp}. Setting it selects the WebSocket transport, + * {@link com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport}. No + * default. Maps to {@link AcpClientSettings.WebSocket#uri()}. + * @return the endpoint, or {@code null} when unset + */ + public @Nullable URI getUri() { + return uri; + } + + /** + * Sets {@code spring.acp.client.transport.websocket.uri}; see {@link #getUri()}. + * @param uri the endpoint, or {@code null} for none + */ + public void setUri(@Nullable URI uri) { + this.uri = uri; + } + + /** + * Returns how long connecting to the agent, the WebSocket handshake, may take + * ({@code spring.acp.client.transport.websocket.connect-timeout}). Default 10 seconds + * ({@link AcpClientSettings#DEFAULT_CONNECT_TIMEOUT}), which replaces the transport's own + * default. Maps to {@code WebSocketAcpClientTransport.connectTimeout}. + * @return the timeout + */ + public Duration getConnectTimeout() { + return connectTimeout; + } + + /** + * Sets {@code spring.acp.client.transport.websocket.connect-timeout}; see + * {@link #getConnectTimeout()}. + * @param connectTimeout the timeout; positive + */ + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + } + + /** + * The {@code spring.acp.client.transport.stdio.*} properties: the agent process the stdio + * client transport starts, then talks to over the process's standard input and output. + */ + public static class StdioProperties { + + /** + * Command that starts the agent process, such as an executable on the PATH. + */ + private @Nullable String command; + + /** + * Arguments passed to the command, in order. Default none. + */ + private List args = new ArrayList<>(); + + /** + * Environment variables added to the agent process's environment. Default none. + */ + private Map env = new LinkedHashMap<>(); + + /** + * Returns the command that starts the agent process + * ({@code spring.acp.client.transport.stdio.command}). Setting it selects the stdio + * transport, {@link com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport}, + * which starts the process when the client is created, at context startup. No default. Maps + * to {@link AcpClientSettings.Stdio#command()}. + * @return the command, or {@code null} when unset + */ + public @Nullable String getCommand() { + return command; + } + + /** + * Sets {@code spring.acp.client.transport.stdio.command}; see {@link #getCommand()}. + * @param command the command, or {@code null} for none + */ + public void setCommand(@Nullable String command) { + this.command = command; + } + + /** + * Returns the command's arguments ({@code spring.acp.client.transport.stdio.args}), in + * order. Default none. Maps to {@link AcpClientSettings.Stdio#args()}. + * @return the arguments; the list the binder fills + */ + public List getArgs() { + return args; + } + + /** + * Sets {@code spring.acp.client.transport.stdio.args}; see {@link #getArgs()}. + * @param args the arguments + */ + public void setArgs(List args) { + this.args = args; + } + + /** + * Returns the environment variables added to the agent process + * ({@code spring.acp.client.transport.stdio.env.*}). The process inherits the application's + * whole environment, and these add to it or replace a variable of the same name. Default + * none. Maps to {@link AcpClientSettings.Stdio#env()}. + * @return the variables, by name; the map the binder fills + */ + public Map getEnv() { + return env; + } + + /** + * Sets {@code spring.acp.client.transport.stdio.env}; see {@link #getEnv()}. + * @param env the variables, by name + */ + public void setEnv(Map env) { + this.env = env; + } + + } + + /** + * The {@code spring.acp.client.capabilities.*} properties: the capabilities the client + * advertises to the agent in {@code initialize}. All are off by default. + * + *

    The autoconfiguration registers no handler for the methods a capability promises to serve. + * Turn a capability on together with its handler, registered through an + * {@link com.agentclientprotocol.sdk.integration.AcpClientCustomizer} bean; a capability turned + * on without its handler fails the application's startup, naming the property. Together they + * map to {@link AcpClientSettings.Capabilities}. + */ + public static class CapabilitiesProperties { + + /** + * Advertise the fs/read_text_file capability. Off by default: the + * autoconfiguration registers no file handler, so enable it with one registered + * through an AcpClientCustomizer. + */ + private boolean readTextFile = false; + + /** + * Advertise the fs/write_text_file capability. Off by default: the + * autoconfiguration registers no file handler, so enable it with one registered + * through an AcpClientCustomizer. + */ + private boolean writeTextFile = false; + + /** + * Advertise the terminal capability. Off by default; enable it with terminal handlers + * registered through an AcpClientCustomizer. + */ + private boolean terminal = false; + + /** + * Advertise form-mode elicitation/create. Off by default; enable it with an elicitation + * handler registered through an AcpClientCustomizer. + */ + private boolean elicitationForm = false; + + /** + * Advertise URL-mode elicitation/create. Off by default; enable it with an elicitation + * handler registered through an AcpClientCustomizer. + */ + private boolean elicitationUrl = false; + + /** + * Advertise that the client accepts boolean session config options. Off by default. + */ + private boolean booleanConfigOptions = false; + + /** + * Returns whether the client advertises {@code fs/read_text_file} + * ({@code spring.acp.client.capabilities.read-text-file}). Default {@code false}. Needs a + * read-file handler. Maps to {@link AcpClientSettings.Capabilities#readTextFile()}. + * @return whether the capability is advertised + */ + public boolean isReadTextFile() { + return readTextFile; + } + + /** + * Sets {@code spring.acp.client.capabilities.read-text-file}; see + * {@link #isReadTextFile()}. + * @param readTextFile whether the capability is advertised + */ + public void setReadTextFile(boolean readTextFile) { + this.readTextFile = readTextFile; + } + + /** + * Returns whether the client advertises {@code fs/write_text_file} + * ({@code spring.acp.client.capabilities.write-text-file}). Default {@code false}. Needs a + * write-file handler. Maps to {@link AcpClientSettings.Capabilities#writeTextFile()}. + * @return whether the capability is advertised + */ + public boolean isWriteTextFile() { + return writeTextFile; + } + + /** + * Sets {@code spring.acp.client.capabilities.write-text-file}; see + * {@link #isWriteTextFile()}. + * @param writeTextFile whether the capability is advertised + */ + public void setWriteTextFile(boolean writeTextFile) { + this.writeTextFile = writeTextFile; + } + + /** + * Returns whether the client advertises the {@code terminal/*} methods + * ({@code spring.acp.client.capabilities.terminal}). Default {@code false}. Needs the + * terminal handlers. Maps to {@link AcpClientSettings.Capabilities#terminal()}. + * @return whether the capability is advertised + */ + public boolean isTerminal() { + return terminal; + } + + /** + * Sets {@code spring.acp.client.capabilities.terminal}; see {@link #isTerminal()}. + * @param terminal whether the capability is advertised + */ + public void setTerminal(boolean terminal) { + this.terminal = terminal; + } + + /** + * Returns whether the client advertises form-mode {@code elicitation/create} + * ({@code spring.acp.client.capabilities.elicitation-form}). Default {@code false}. Needs + * an elicitation handler. Maps to {@link AcpClientSettings.Capabilities#elicitationForm()}. + * @return whether the capability is advertised + */ + public boolean isElicitationForm() { + return elicitationForm; + } + + /** + * Sets {@code spring.acp.client.capabilities.elicitation-form}; see + * {@link #isElicitationForm()}. + * @param elicitationForm whether the capability is advertised + */ + public void setElicitationForm(boolean elicitationForm) { + this.elicitationForm = elicitationForm; + } + + /** + * Returns whether the client advertises URL-mode {@code elicitation/create} + * ({@code spring.acp.client.capabilities.elicitation-url}). Default {@code false}. Needs an + * elicitation handler. Maps to {@link AcpClientSettings.Capabilities#elicitationUrl()}. + * @return whether the capability is advertised + */ + public boolean isElicitationUrl() { + return elicitationUrl; + } + + /** + * Sets {@code spring.acp.client.capabilities.elicitation-url}; see + * {@link #isElicitationUrl()}. + * @param elicitationUrl whether the capability is advertised + */ + public void setElicitationUrl(boolean elicitationUrl) { + this.elicitationUrl = elicitationUrl; + } + + /** + * Returns whether the client advertises that it accepts boolean session config options + * ({@code spring.acp.client.capabilities.boolean-config-options}). Default {@code false}. + * Needs no handler. Maps to {@link AcpClientSettings.Capabilities#booleanConfigOptions()}. + * @return whether the capability is advertised + */ + public boolean isBooleanConfigOptions() { + return booleanConfigOptions; + } + + /** + * Sets {@code spring.acp.client.capabilities.boolean-config-options}; see + * {@link #isBooleanConfigOptions()}. + * @param booleanConfigOptions whether the capability is advertised + */ + public void setBooleanConfigOptions(boolean booleanConfigOptions) { + this.booleanConfigOptions = booleanConfigOptions; + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfiguration.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfiguration.java new file mode 100644 index 00000000..ba4082d8 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfiguration.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.integration.AcpClientTransports; +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.thread.Threading; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.ConfigurationCondition; +import org.springframework.core.env.Environment; + +/** + * Creates the client transport that {@code spring.acp.client.transport.*} describes, for + * {@link AcpClientAutoConfiguration} to build the client on. It applies when any of {@code type}, + * {@code stdio.command}, {@code websocket.uri} or {@code http.uri} is set and the application has + * no {@code AcpClientTransport} bean of its own; with none of them set, the application gets no + * transport and no client. + * + *

    The transport is chosen by the SDK's rule ({@link AcpClientTransports}), the same in every + * framework: a {@code type} that is set wins, and fails the startup when its command or URI is + * missing; otherwise the one transport whose command or URI is set is used, and more than one fails + * the startup, naming them. The errors name the {@code spring.acp.client} properties. The transport + * bean has no destroy method: the client closes it when it is closed. The WebSocket and HTTP + * transports run on the virtual-thread {@code applicationTaskExecutor} when + * {@code spring.threads.virtual.enabled=true}, and on platform threads otherwise. + */ +@AutoConfiguration +@ConditionalOnClass(AcpClient.class) +@EnableConfigurationProperties(AcpClientProperties.class) +public class AcpClientTransportAutoConfiguration { + + static final String PREFIX = "spring.acp.client"; + + // The client lifecycle closes the transport with the client. + @Bean(destroyMethod = "") + @ConditionalOnMissingBean(AcpClientTransport.class) + @Conditional(OnClientTransportCondition.class) + AcpClientTransport acpClientTransport(AcpClientProperties properties, Environment environment, + @Qualifier(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME) + ObjectProvider applicationTaskExecutor) { + return AcpClientTransports.create(properties.toSettings(), PREFIX, threads(Threading.VIRTUAL.isActive(environment), applicationTaskExecutor)) + .orElseThrow(() -> new IllegalStateException("No ACP client transport is configured")); + } + + /** + * Spring Boot's opt-in decides: with {@code spring.threads.virtual.enabled=true} (JDK 21 and + * later) the WebSocket and HTTP transports run on the virtual-thread + * {@code applicationTaskExecutor}; otherwise on platform threads, on every JDK. + * @param virtualThreads whether Spring Boot's virtual-thread opt-in is active + * @param applicationTaskExecutor the context's task executor + * @return the transports' threads + */ + static AcpTransportThreads threads(boolean virtualThreads, ObjectProvider applicationTaskExecutor) { + Executor executor = virtualThreads ? applicationTaskExecutor.getIfAvailable() : null; + return (executor != null) ? AcpTransportThreads.executor(executor) : AcpTransportThreads.platform(); + } + + /** Any client transport property: a type, a command or a URI. */ + static class OnClientTransportCondition extends AnyNestedCondition { + + OnClientTransportCondition() { + super(ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(prefix = PREFIX + ".transport", name = "type") + static class Type { + + } + + @ConditionalOnProperty(prefix = PREFIX + ".transport.stdio", name = "command") + static class StdioCommand { + + } + + @ConditionalOnProperty(prefix = PREFIX + ".transport.websocket", name = "uri") + static class WebSocketUri { + + } + + @ConditionalOnProperty(prefix = PREFIX + ".transport.http", name = "uri") + static class HttpUri { + + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/package-info.java b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/package-info.java new file mode 100644 index 00000000..7c2938e5 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/package-info.java @@ -0,0 +1,11 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Autoconfiguration of an ACP client: its transport, the async and sync clients, and customizers. + */ +@NullMarked +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/acp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..8c143b97 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,5 @@ +com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientTransportAutoConfiguration +com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientAutoConfiguration +com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentTransportAutoConfiguration +com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentAutoConfiguration +com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfiguration diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/ConfigurationMetadataTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/ConfigurationMetadataTests.java new file mode 100644 index 00000000..d5526946 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/ConfigurationMetadataTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientProperties; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The build writes Spring Boot's metadata into this module's classes on every JDK: the + * configuration metadata IDEs complete {@code spring.acp.*} from, and the autoconfiguration + * metadata Boot reads its conditions from early. On JDK 21 and later the {@code errorprone} + * profile's processor path used to hide both processors, so neither file was written. + */ +class ConfigurationMetadataTests { + + @Test + void everySpringAcpPropertyIsDescribed() throws IOException { + Path metadata = moduleClasses().resolve("META-INF/spring-configuration-metadata.json"); + assertThat(metadata).exists(); + JsonNode root = new ObjectMapper().readTree(metadata.toFile()); + List names = new ArrayList<>(); + for (JsonNode property : root.get("properties")) { + String name = property.get("name").asString(); + names.add(name); + assertThat(property.path("description").asString()).as(name).isNotBlank(); + } + assertThat(names).hasSize(36).allMatch(name -> name.startsWith("spring.acp.")); + assertThat(names).contains("spring.acp.agent.transport.http.shutdown-timeout", + "spring.acp.agent.transport.http.web-socket-idle-timeout", + "spring.acp.agent.transport.http.initialize-timeout", + "spring.acp.client.transport.websocket.connect-timeout", "spring.acp.client.transport.stdio.env"); + } + + @Test + void theAutoConfigurationMetadataIsWritten() throws IOException { + Path metadata = moduleClasses().resolve("META-INF/spring-autoconfigure-metadata.properties"); + assertThat(metadata).exists(); + Properties properties = new Properties(); + try (InputStream in = Files.newInputStream(metadata)) { + properties.load(in); + } + assertThat(properties.stringPropertyNames()).anyMatch(key -> key.startsWith( + "com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientAutoConfiguration.")); + } + + /** This module's classes directory, not another jar's metadata of the same name. */ + private static Path moduleClasses() { + try { + URI location = AcpClientProperties.class.getProtectionDomain().getCodeSource().getLocation().toURI(); + return Path.of(location); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/DetachedStdinListener.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/DetachedStdinListener.java new file mode 100644 index 00000000..338422a5 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/DetachedStdinListener.java @@ -0,0 +1,22 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure; + +import java.io.ByteArrayInputStream; + +import org.junit.platform.launcher.LauncherSession; +import org.junit.platform.launcher.LauncherSessionListener; + +/** + * Gives the tests an empty {@code System.in}. Contexts that start the default stdio agent + * would otherwise leave its reader blocked on the forked JVM's real stdin after the agent + * closes, where it holds the stream's lock and can swallow surefire's exit + * acknowledgement, stalling the fork for 30 seconds. Surefire keeps its own reference to + * the original stream. + */ +public class DetachedStdinListener implements LauncherSessionListener { + + @Override + public void launcherSessionOpened(LauncherSession session) { + System.setIn(new ByteArrayInputStream(new byte[0])); + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/SpringBootAutoconfigureArchitectureTest.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/SpringBootAutoconfigureArchitectureTest.java new file mode 100644 index 00000000..21b4954b --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/SpringBootAutoconfigureArchitectureTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientAutoConfiguration; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-spring-boot-autoconfigure: Spring Boot autoconfiguration over the SDK's public API. + * + *

    + *        client          agent
    + *            \           /
    + *         acp-integration
    + * 
    + * + *

    + * The client and agent sides are independent: a client-only application loads nothing of + * the agent side, and the reverse. The module builds on the SDK's public types and does + * not reach into other framework integrations. + *

    + */ +@AnalyzeClasses(locations = SpringBootAutoconfigureArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class SpringBootAutoconfigureArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allSatisfy(javaClass -> assertThat(javaClass.getPackageName()) + .startsWith("com.agentclientprotocol.sdk.spring.boot.autoconfigure")); + } + + @ArchTest + static final ArchRule noPackageCycles = slices() + .matching("com.agentclientprotocol.sdk.spring.boot.autoconfigure.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule clientDoesNotDependOnAgent = noClasses().that() + .resideInAPackage("..autoconfigure.client..") + .should() + .dependOnClassesThat(resideInAnyPackage("..autoconfigure.agent..", "com.agentclientprotocol.sdk.agent..")) + .because("a client-only application must not need the agent side"); + + @ArchTest + static final ArchRule agentDoesNotDependOnClientSide = noClasses().that() + .resideInAPackage("..autoconfigure.agent..") + .should() + .dependOnClassesThat(resideInAnyPackage("..autoconfigure.client..")) + .because("the agent autoconfiguration is independent of the client autoconfiguration"); + + /** + * The Spring host mounts the SDK's servlet or WebFlux host and adapts nothing itself: no + * JSON-RPC transport messages, connections, routing or SSE of its own, and no servlet, + * WebSocket or reactive server code beside the hosts'. + */ + @ArchTest + static final ArchRule mountsTheHostAndHoldsNoProtocolRules = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("jakarta.websocket..", "org.apache.tomcat..", "org.eclipse.jetty..", + "org.springframework.web.reactive.socket..", "org.springframework.http.server.reactive..", + "reactor.netty..", "io.netty..") + .or(com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf( + com.agentclientprotocol.sdk.agent.transport.RemoteAcpConnection.class, + com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage.class))) + .because("all protocol semantics live in acp-http-core; hosts only adapt I/O through the host contract"); + + @ArchTest + static final ArchRule usesNoOtherFramework = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("io.quarkus..", "io.micronaut..")) + .because("each framework integration builds on the SDK alone"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * that the rules see this module only. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = AcpClientAutoConfiguration.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfigurationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfigurationTests.java new file mode 100644 index 00000000..02628893 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentAutoConfigurationTests.java @@ -0,0 +1,343 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.support.AcpAgentSupport; +import com.agentclientprotocol.sdk.agent.support.interceptor.AcpInterceptor; +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.Initialize; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.InitializeResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; + +import org.junit.jupiter.api.Test; + +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class AcpAgentAutoConfigurationTests { + + private static final AutoConfigurations AGENT_AUTO_CONFIGURATIONS = AutoConfigurations + .of(AcpAgentTransportAutoConfiguration.class, AcpAgentAutoConfiguration.class); + + // The default stdio agent reads the tests' empty System.in and ends at once: keep the + // context open for the assertions. The shutdown itself is tested on its own + // transport. + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withPropertyValues("spring.acp.agent.shutdown-on-transport-end=false"); + + @Test + void noAgentLifecycleWithoutAgentBean() { + // Client-only app: the agent autoconfiguration must back off, not fail the + // context. + this.runner.run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("acpAgentLifecycle"); + }); + } + + @Test + void createsAgentLifecycleWithAgentBean() { + this.runner.withUserConfiguration(SingleAgentConfiguration.class) + .run(context -> assertThat(context).hasBean("acpAgentLifecycle")); + } + + @Test + void failsWithMultipleAgentBeans() { + this.runner.withUserConfiguration(MultipleAgentConfiguration.class).run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .isInstanceOf(BeanCreationException.class) + .hasMessageContaining("Found 2 @AcpAgent beans"); + }); + } + + @Test + void respectsCustomRequestTimeout() { + this.runner.withUserConfiguration(SingleAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.request-timeout=120s") + .run(context -> { + assertThat(context).hasBean("acpAgentLifecycle"); + AcpAgentProperties props = context.getBean(AcpAgentProperties.class); + assertThat(props.getRequestTimeout()).hasSeconds(120); + }); + } + + @Test + void noAgentWhenDisabled() { + this.runner.withUserConfiguration(SingleAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("acpAgentLifecycle"); + assertThat(context).doesNotHaveBean(AcpAgentTransport.class); + }); + } + + @Test + void disabledServesNothingOnTheApplicationsOwnTransport() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpAgentTransportAutoConfiguration.class, + AcpAgentAutoConfiguration.class, AcpAgentHttpAutoConfiguration.class)) + .withUserConfiguration(SingleAgentWithInMemoryTransportConfiguration.class) + .withPropertyValues("spring.acp.agent.enabled=false") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(AcpAgentFactory.class); + assertThat(context).doesNotHaveBean("acpAgentLifecycle"); + }); + } + + @Test + void disabledToleratesTwoAgentBeans() { + this.runner.withUserConfiguration(MultipleAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.enabled=false") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(AcpAgentFactory.class); + assertThat(context).doesNotHaveBean("acpAgentLifecycle"); + }); + } + + @Test + void noAgentWithoutTransportBean() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(AcpAgentAutoConfiguration.class)) + .withUserConfiguration(SingleAgentConfiguration.class) + .run(context -> assertThat(context).doesNotHaveBean("acpAgentLifecycle")); + } + + @Test + void wiresInterceptors() { + this.runner.withUserConfiguration(SingleAgentConfiguration.class, InterceptorConfiguration.class) + .run(context -> { + assertThat(context).hasBean("acpAgentLifecycle"); + assertThat(context).hasSingleBean(AcpInterceptor.class); + }); + } + + @Test + void agentLifecycleImplementsSmartLifecycle() { + this.runner.withUserConfiguration(SingleAgentConfiguration.class).run(context -> { + Object lifecycle = context.getBean("acpAgentLifecycle"); + assertThat(lifecycle).isInstanceOf(org.springframework.context.SmartLifecycle.class); + }); + } + + @Test + void usesInMemoryTransportWhenProvided() { + this.runner.withUserConfiguration(SingleAgentWithInMemoryTransportConfiguration.class).run(context -> { + assertThat(context).hasBean("acpAgentLifecycle"); + assertThat(context).hasSingleBean(AcpAgentTransport.class); + }); + } + + @Test + void closesContextWhenTransportEnds() throws IOException { + PipedOutputStream clientSide = new PipedOutputStream(); + PipedInputStream agentInput = new PipedInputStream(clientSide); + new ApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withUserConfiguration(SingleAgentConfiguration.class) + .withBean(AcpAgentTransport.class, + () -> new StdioAcpAgentTransport(AcpJsonMapper.createDefault(), agentInput, + new ByteArrayOutputStream())) + .run(context -> { + ConfigurableApplicationContext source = (ConfigurableApplicationContext) context + .getSourceApplicationContext(); + assertThat(source.isActive()).isTrue(); + clientSide.close(); + await().atMost(Duration.ofSeconds(10)).until(() -> !source.isActive()); + }); + } + + @Test + void keepsContextWhenShutdownOnTransportEndDisabled() throws IOException { + PipedOutputStream clientSide = new PipedOutputStream(); + PipedInputStream agentInput = new PipedInputStream(clientSide); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(AcpJsonMapper.createDefault(), agentInput, + new ByteArrayOutputStream()); + this.runner.withUserConfiguration(SingleAgentConfiguration.class) + .withBean(AcpAgentTransport.class, () -> transport) + .run(context -> { + clientSide.close(); + transport.awaitTermination().block(Duration.ofSeconds(10)); + ConfigurableApplicationContext source = (ConfigurableApplicationContext) context + .getSourceApplicationContext(); + await().during(Duration.ofMillis(500)).atMost(Duration.ofSeconds(2)).until(source::isActive); + }); + } + + @Test + void servesACglibProxiedAgentBean() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AtomicInteger intercepted = new AtomicInteger(); + this.runner.withUserConfiguration(SingleAgentConfiguration.class) + .withBean(AcpAgentTransport.class, pair::agentTransport) + .withBean(ProxyingPostProcessor.class, () -> new ProxyingPostProcessor(intercepted)) + .run(context -> { + assertThat(AopUtils.isCglibProxy(context.getBean("testAgent"))).isTrue(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).build(); + try { + client.initialize(); + NewSessionResponse session = client.newSession(new NewSessionRequest("/workspace", List.of())); + PromptResponse response = client + .prompt(new PromptRequest(session.sessionId(), List.of(new TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); + // The handlers were invoked through the proxy, so its advice ran + assertThat(intercepted).hasValueGreaterThanOrEqualTo(3); + } + finally { + client.closeGracefully(); + } + }); + } + + /** Wraps the agent bean in a CGLIB proxy, as Spring AOP does for an advised class. */ + static class ProxyingPostProcessor implements BeanPostProcessor { + + private final AtomicInteger intercepted; + + ProxyingPostProcessor(AtomicInteger intercepted) { + this.intercepted = intercepted; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (!(bean instanceof TestAgent)) { + return bean; + } + ProxyFactory proxyFactory = new ProxyFactory(bean); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice((MethodInterceptor) invocation -> { + this.intercepted.incrementAndGet(); + return invocation.proceed(); + }); + return proxyFactory.getProxy(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class SingleAgentConfiguration { + + @Bean + TestAgent testAgent() { + return new TestAgent(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class MultipleAgentConfiguration { + + @Bean + TestAgent testAgent1() { + return new TestAgent(); + } + + @Bean + SecondTestAgent testAgent2() { + return new SecondTestAgent(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class InterceptorConfiguration { + + @Bean + AcpInterceptor testInterceptor() { + return new AcpInterceptor() { + }; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SingleAgentWithInMemoryTransportConfiguration { + + @Bean + TestAgent testAgent() { + return new TestAgent(); + } + + @Bean + AcpAgentTransport acpAgentTransport() { + return InMemoryTransportPair.create().agentTransport(); + } + + } + + @AcpAgent(name = "test-agent", version = "1.0") + static class TestAgent { + + @Initialize + public InitializeResponse initialize(InitializeRequest request) { + return InitializeResponse.ok(); + } + + @NewSession + public NewSessionResponse newSession(NewSessionRequest request) { + return new NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + return PromptResponse.endTurn(); + } + + } + + @AcpAgent(name = "second-agent", version = "1.0") + static class SecondTestAgent { + + @Initialize + public InitializeResponse initialize(InitializeRequest request) { + return InitializeResponse.ok(); + } + + @NewSession + public NewSessionResponse newSession(NewSessionRequest request) { + return new NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + return PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHandlerExecutorTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHandlerExecutorTests.java new file mode 100644 index 00000000..b8abdf29 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHandlerExecutorTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +/** The agent's handler methods run on the executor spring.acp.agent.handler-executor selects. */ +class AcpAgentHandlerExecutorTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(AcpAgentTransportAutoConfiguration.class, AcpAgentAutoConfiguration.class)) + .withPropertyValues("spring.acp.agent.shutdown-on-transport-end=false"); + + @Test + void aNamedExecutorBean() { + ExecutorService pool = Executors.newCachedThreadPool(runnable -> new Thread(runnable, "app-pool")); + try { + assertThat(promptThread(this.runner.withBean("appPool", ExecutorService.class, () -> pool) + .withPropertyValues("spring.acp.agent.handler-executor=appPool"))).isEqualTo("app-pool"); + } + finally { + pool.shutdownNow(); + } + } + + /** Spring Boot's own rule: the property takes effect on JDK 21 and later only. */ + @Test + void theApplicationTaskExecutorWithVirtualThreads() { + String thread = promptThread(this.runner.withBean("applicationTaskExecutor", SimpleAsyncTaskExecutor.class, + () -> new SimpleAsyncTaskExecutor("app-task-")) + .withPropertyValues("spring.threads.virtual.enabled=true")); + if (Runtime.version().feature() >= 21) { + assertThat(thread).startsWith("app-task-"); + } + else { + assertThat(thread).isEqualTo("acp-agent-sync-handler"); + } + } + + @Test + void withoutVirtualThreadsOrWithNoneTheSdkPool() { + assertThat(promptThread(this.runner.withBean("applicationTaskExecutor", SimpleAsyncTaskExecutor.class, + () -> new SimpleAsyncTaskExecutor("app-task-")))).doesNotStartWith("app-task-"); + assertThat(promptThread(this.runner.withBean("applicationTaskExecutor", SimpleAsyncTaskExecutor.class, + () -> new SimpleAsyncTaskExecutor("app-task-")) + .withPropertyValues("spring.threads.virtual.enabled=true", "spring.acp.agent.handler-executor=none"))) + .doesNotStartWith("app-task-"); + } + + @Test + void aBeanThatIsNoExecutorFailsNamingIt() { + this.runner.withBean(ThreadAgent.class, ThreadAgent::new) + .withBean("notAnExecutor", String.class, () -> "x") + .withPropertyValues("spring.acp.agent.handler-executor=notAnExecutor") + .run(context -> assertThat(context).getFailure() + .hasStackTraceContaining("spring.acp.agent.handler-executor=notAnExecutor names a java.lang.String")); + } + + /** The name of the thread the prompt handler ran on. */ + private static String promptThread(ApplicationContextRunner runner) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List threads = new CopyOnWriteArrayList<>(); + runner.withBean(ThreadAgent.class, () -> new ThreadAgent(threads)) + .withBean(AcpAgentTransport.class, pair::agentTransport) + .run(context -> { + assertThat(context).hasNotFailed(); + AcpSyncClient client = AcpClient.sync(pair.clientTransport()).requestTimeout(Duration.ofSeconds(10)).build(); + try { + client.initialize(); + String session = client.newSession(new NewSessionRequest("/", List.of())).sessionId(); + client.prompt(new PromptRequest(session, List.of(new TextContent("hi")))); + } + finally { + client.closeGracefully(); + } + }); + assertThat(threads).hasSize(1); + return threads.get(0); + } + + @AcpAgent(name = "thread-agent", version = "1.0") + static class ThreadAgent { + + private final List threads; + + ThreadAgent() { + this(new CopyOnWriteArrayList<>()); + } + + ThreadAgent(List threads) { + this.threads = threads; + } + + @NewSession + public NewSessionResponse newSession(NewSessionRequest request) { + return new NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + threads.add(Thread.currentThread().getName()); + return PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfigurationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfigurationTests.java new file mode 100644 index 00000000..b50edb18 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpAutoConfigurationTests.java @@ -0,0 +1,355 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientAutoConfiguration; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientTransportAutoConfiguration; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.webflux.AcpWebFluxHost; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema.AgentMessageChunk; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.unit.DataSize; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class AcpAgentHttpAutoConfigurationTests { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final AutoConfigurations AGENT_AUTO_CONFIGURATIONS = AutoConfigurations.of( + AcpAgentTransportAutoConfiguration.class, AcpAgentAutoConfiguration.class, + AcpAgentHttpAutoConfiguration.class); + + // stdioRemainsTheDefault starts the stdio agent on the tests' empty System.in: keep + // the + // context open for its assertions. + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withPropertyValues("spring.acp.agent.shutdown-on-transport-end=false"); + + @Test + void listenerServesAgentOverStreamableHttpAndWebSocket() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http", "spring.acp.agent.transport.http.listener.port=0") + .run(context -> { + assertThat(context).hasSingleBean(StreamableHttpAcpAgentTransport.class); + int port = context.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + assertThat(context).doesNotHaveBean(AcpAgentTransport.class); + assertThat(context).doesNotHaveBean("acpAgentLifecycle"); + + URI endpoint = URI.create("http://localhost:" + port + "/acp"); + assertRoundTrip(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault())); + URI webSocket = URI.create("ws://localhost:" + port + "/acp"); + assertRoundTrip(new WebSocketAcpClientTransport(webSocket, AcpJsonMapper.createDefault())); + }); + } + + @Test + void websocketIsTheSameAsHttp() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=websocket", + "spring.acp.agent.transport.http.listener.port=0") + .run(context -> { + int port = context.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + assertThat(context).doesNotHaveBean(AcpAgentTransport.class); + assertRoundTrip(new WebSocketAcpClientTransport(URI.create("ws://localhost:" + port + "/acp"), + AcpJsonMapper.createDefault())); + }); + } + + @Test + void anExplicitStdioTypeServesNoListener() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=STDIO") + .run(context -> { + assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class); + assertThat(context).hasBean("acpAgentLifecycle"); + }); + } + + @Test + void anUnknownTypeFailsTheBinding() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=pigeon") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().hasStackTraceContaining("pigeon"); + }); + } + + @Test + void autoConfiguredHttpClientTalksToTheAgent() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http", "spring.acp.agent.transport.http.listener.port=0") + .run(agentContext -> { + int port = agentContext.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpClientTransportAutoConfiguration.class, + AcpClientAutoConfiguration.class)) + .withPropertyValues("spring.acp.client.transport.http.uri=http://localhost:" + port + "/acp") + .run(clientContext -> { + AcpSyncClient client = clientContext.getBean(AcpSyncClient.class); + client.initialize(); + NewSessionResponse session = client.newSession(new NewSessionRequest("/workspace", List.of())); + PromptResponse response = client + .prompt(new PromptRequest(session.sessionId(), List.of(new TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); + }); + }); + } + + @Test + void listenerUsesConfiguredPath() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http", "spring.acp.agent.transport.http.listener.port=0", + "spring.acp.agent.transport.http.path=/agents/echo") + .run(context -> { + int port = context.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + assertRoundTrip(new StreamableHttpAcpClientTransport( + URI.create("http://localhost:" + port + "/agents/echo"), AcpJsonMapper.createDefault())); + }); + } + + @Test + void userDefinedListenerOverridesAutoConfigured() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withBean("customListener", StreamableHttpAcpAgentTransport.class, + () -> new StreamableHttpAcpAgentTransport(0, AcpJsonMapper.createDefault(), + AcpAgentFactory.sync(transport -> { + throw new IllegalStateException("not used"); + }))) + .withPropertyValues("spring.acp.agent.transport.type=http") + .run(context -> { + assertThat(context).hasSingleBean(StreamableHttpAcpAgentTransport.class); + assertThat(context).hasBean("customListener"); + // Started by the autoconfigured lifecycle: the ephemeral port is bound + assertThat(context.getBean(StreamableHttpAcpAgentTransport.class).getPort()).isPositive(); + }); + } + + @Test + void userDefinedServletRegistrationOverridesAutoConfigured() { + new WebApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withUserConfiguration(EchoAgentConfiguration.class) + .withBean("acpServletRegistration", ServletRegistrationBean.class, () -> new ServletRegistrationBean<>( + new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), context -> { + throw new IllegalStateException("not used"); + }), "/custom")) + .withPropertyValues("spring.acp.agent.transport.type=http") + .run(context -> assertThat( + context.getBean("acpServletRegistration", ServletRegistrationBean.class).getUrlMappings()) + .containsExactly("/custom")); + } + + @Test + void stdioRemainsTheDefault() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class).run(context -> { + assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class); + assertThat(context).hasSingleBean(AcpAgentTransport.class); + assertThat(context).hasBean("acpAgentLifecycle"); + }); + } + + @Test + void backsOffWithoutAgentBean() { + this.runner.withPropertyValues("spring.acp.agent.transport.type=http").run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(AcpAgentFactory.class); + assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class); + }); + } + + @Test + void backsOffWhenAgentDisabled() { + this.runner.withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.enabled=false", "spring.acp.agent.transport.type=http") + .run(context -> assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class)); + } + + @Test + void reactiveWebApplicationRoutesThePathToTheEndpoint() { + new ReactiveWebApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http", "spring.acp.agent.transport.http.path=/agent") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AcpHttpEndpoint.class); + assertThat(context).hasBean("acpRouterFunction"); + assertThat(context).hasSingleBean(AcpAgentHttpAutoConfiguration.AcpEndpointLifecycle.class); + assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class); + assertThat(context).doesNotHaveBean(ServletRegistrationBean.class); + }); + } + + @Test + void reactiveWebApplicationNeedsOnlyTheWebFluxHost() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpAgentTransportAutoConfiguration.class, + AcpAgentAutoConfiguration.class, AcpAgentHttpAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(StreamableHttpAcpServlet.class, StreamableHttpAcpAgentTransport.class)) + .withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=websocket") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("acpRouterFunction"); + }); + } + + @Test + void reactiveWebApplicationWithoutTheWebFluxHostFailsTheStartup() { + new ReactiveWebApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withClassLoader(new FilteredClassLoader(AcpWebFluxHost.class)) + .withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("The ACP HTTP transport in a reactive (WebFlux) web application needs " + + "acp-http-webflux on the classpath"); + }); + } + + @Test + void userDefinedRouterFunctionOverridesAutoConfigured() { + new ReactiveWebApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withUserConfiguration(EchoAgentConfiguration.class) + .withBean("acpRouterFunction", RouterFunction.class, + () -> RouterFunctions.route(RequestPredicates.path("/mine"), request -> ServerResponse.ok().build())) + .withPropertyValues("spring.acp.agent.transport.type=http") + .run(context -> assertThat(context.getBean("acpRouterFunction").toString()).contains("/mine")); + } + + @Test + void servletWebApplicationMountsServletInsteadOfListener() { + new WebApplicationContextRunner().withConfiguration(AGENT_AUTO_CONFIGURATIONS) + .withUserConfiguration(EchoAgentConfiguration.class) + .withPropertyValues("spring.acp.agent.transport.type=http", + "spring.acp.agent.transport.http.path=/agents/echo") + .run(context -> { + assertThat(context).doesNotHaveBean(StreamableHttpAcpAgentTransport.class); + ServletRegistrationBean registration = context.getBean("acpServletRegistration", + ServletRegistrationBean.class); + assertThat(registration.getServlet()).isInstanceOf(StreamableHttpAcpServlet.class); + assertThat(registration.getUrlMappings()).containsExactly("/agents/echo"); + assertThat(registration.isAsyncSupported()).isTrue(); + }); + } + + @Test + void mapsLimitsToTransportOptions() { + AcpAgentProperties properties = new AcpAgentProperties(); + AcpAgentProperties.AgentHttpProperties http = properties.getTransport().getHttp(); + assertThat(properties.toSettings().toOptions(true)).isEqualTo(StreamableHttpAcpAgentTransportOptions.defaults()); + + http.setMaxPostBodySize(DataSize.ofMegabytes(1)); + http.setKeepAliveInterval(Duration.ZERO); + http.setMailboxCapacity(10); + http.setMaxPendingSseEvents(11); + http.setMaxWebSocketPendingFrames(12); + http.setMaxProvisionalSessions(13); + http.getListener().setMaxConcurrentStreamsPerConnection(14); + http.setShutdownTimeout(Duration.ofSeconds(2)); + http.setWebSocketIdleTimeout(Duration.ofMinutes(7)); + http.setInitializeTimeout(Duration.ofSeconds(8)); + assertThat(properties.toSettings().toOptions(true)) + .isEqualTo(StreamableHttpAcpAgentTransportOptions.builder() + .maxPostBodyBytes(1024 * 1024) + .mailboxCapacity(10) + .maxPendingSseEvents(11) + .maxWebSocketPendingFrames(12) + .maxProvisionalSessions(13) + .keepAliveInterval(Duration.ZERO) + .maxConcurrentStreamsPerConnection(14) + .shutdownTimeout(Duration.ofSeconds(2)) + .webSocketIdleTimeout(Duration.ofMinutes(7)) + .initializeTimeout(Duration.ofSeconds(8)) + .build()); + } + + static void assertRoundTrip(AcpClientTransport transport) { + List messages = new CopyOnWriteArrayList<>(); + AcpSyncClient client = AcpClient.sync(transport).requestTimeout(TIMEOUT).sessionUpdateHandler(notification -> { + if (notification.update() instanceof AgentMessageChunk chunk + && chunk.content() instanceof TextContent text) { + messages.add(text.text()); + } + }).build(); + try { + client.initialize(); + NewSessionResponse session = client.newSession(new NewSessionRequest("/workspace", List.of())); + PromptResponse response = client + .prompt(new PromptRequest(session.sessionId(), List.of(new TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); + await().atMost(TIMEOUT).untilAsserted(() -> assertThat(messages).containsExactly("echo: hello")); + } + finally { + client.closeGracefully(); + } + } + + @Configuration(proxyBeanMethods = false) + static class EchoAgentConfiguration { + + @Bean + EchoAgent echoAgent() { + return new EchoAgent(); + } + + } + + @AcpAgent(name = "echo-agent", version = "1.0") + static class EchoAgent { + + @NewSession + public NewSessionResponse newSession(NewSessionRequest request) { + return new NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + context.sendMessage("echo: " + ((TextContent) request.prompt().get(0)).text()); + return PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpObservationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpObservationTests.java new file mode 100644 index 00000000..4838b483 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpObservationTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import io.micrometer.common.KeyValue; +import io.micrometer.observation.tck.TestObservationRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The application's own HTTP server observations cover {@code /acp}: the endpoint is mounted on + * the application's server and filter chain, so Spring's {@code ServerHttpObservationFilter} + * records an {@code http.server.requests} observation for an ACP request as for any other. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "spring.acp.agent.transport.type=http") +class AcpAgentHttpObservationTests { + + @LocalServerPort + private int port; + + @Autowired + private StoppedObservations stoppedObservations; + + @Test + void anAcpRequestIsObserved() throws Exception { + URI endpoint = URI.create("http://127.0.0.1:" + this.port + "/acp"); + assertThat(HttpProbes.initialize(endpoint, null).statusCode()).isEqualTo(200); + // Waited for, not asserted at once: the server may stop its observation after the + // response has reached the client. Selected by path, not taken first: the server may + // observe other requests, such as another process probing its port. The endpoint is not + // a mapped handler, so its low cardinality uri is UNKNOWN and the path is the http.url. + this.stoppedObservations.await("http.server.requests", Duration.ofSeconds(10), KeyValue.of("method", "POST"), + KeyValue.of("http.url", "/acp"), KeyValue.of("status", "200")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class ObservedApplication { + + @Bean + TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + /** Registered with the registry by Spring Boot, as every observation handler bean. */ + @Bean + StoppedObservations stoppedObservations() { + return new StoppedObservations(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpSecurityTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpSecurityTests.java new file mode 100644 index 00000000..b715d225 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpSecurityTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.http.server.AcpHttpEndpoint; +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import com.agentclientprotocol.sdk.http.server.AcpHttpReply; +import com.agentclientprotocol.sdk.http.server.AcpWsHandshake; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The application's own Spring Security protects {@code /acp}, over HTTP and on the WebSocket + * handshake, because the endpoint is mounted on the application's server and filter chain: an + * unauthenticated request is refused, an authenticated one is served, and the authenticated + * principal reaches the endpoint ({@link AcpHttpExchange#principal()}). + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.autoconfigure.exclude=", "spring.acp.agent.transport.type=http" }) +class AcpAgentHttpSecurityTests { + + private static final Map ALICE = Map.of("Authorization", + "Basic " + Base64.getEncoder().encodeToString("alice:secret".getBytes(StandardCharsets.UTF_8))); + + @LocalServerPort + private int port; + + @Autowired + private PrincipalRecorder recorder; + + private URI endpoint; + + @BeforeEach + void endpoint() { + this.endpoint = URI.create("http://127.0.0.1:" + this.port + "/acp"); + this.recorder.principals.clear(); + } + + @Test + void anUnauthenticatedRequestIsRefusedOverHttpAndOnTheHandshake() throws Exception { + assertThat(HttpProbes.initialize(this.endpoint, null).statusCode()).isEqualTo(401); + assertThat(HttpProbes.webSocketHandshake(this.endpoint, null)).isEqualTo(401); + assertThat(this.recorder.principals).as("nothing reached the endpoint").isEmpty(); + } + + @Test + void anAuthenticatedRequestIsServedAndItsPrincipalReachesTheEndpoint() throws Exception { + assertThat(HttpProbes.initialize(this.endpoint, null, ALICE).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(this.endpoint, null, ALICE)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + assertThat(this.recorder.principals).hasSize(2).allSatisfy(name -> assertThat(name).isEqualTo("alice")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class SecuredApplication { + + @Bean + SecurityFilterChain acpSecurity(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(requests -> requests.requestMatchers("/acp").authenticated()) + .httpBasic(Customizer.withDefaults()) + // ACP clients are not browsers: no CSRF token; the Origin check guards browsers. + .csrf(csrf -> csrf.ignoringRequestMatchers("/acp")) + .build(); + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager( + User.withUsername("alice").password("{noop}secret").roles("USER").build()); + } + + @Bean + static PrincipalRecorder principalRecorder() { + return new PrincipalRecorder(); + } + + } + + /** Wraps the endpoint bean to record the principal of every exchange it is handed. */ + static class PrincipalRecorder implements BeanPostProcessor { + + final List principals = new CopyOnWriteArrayList<>(); + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (bean instanceof AcpHttpEndpoint endpoint) { + return new Recording(endpoint); + } + return bean; + } + + private void record(AcpHttpExchange exchange) { + Principal principal = exchange.principal(); + this.principals.add(principal != null ? principal.getName() : ""); + } + + private final class Recording implements AcpHttpEndpoint { + + private final AcpHttpEndpoint delegate; + + Recording(AcpHttpEndpoint delegate) { + this.delegate = delegate; + } + + @Override + public StreamableHttpAcpAgentTransportOptions options() { + return delegate.options(); + } + + @Override + public Mono handle(AcpHttpExchange exchange) { + record(exchange); + return delegate.handle(exchange); + } + + @Override + public AcpWsHandshake webSocketHandshake(AcpHttpExchange handshake) { + record(handshake); + return delegate.webSocketHandshake(handshake); + } + + @Override + public void start() { + delegate.start(); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully(); + } + + @Override + public int activeConnectionCount() { + return delegate.activeConnectionCount(); + } + + @Override + public void setExceptionHandler(Consumer handler) { + delegate.setExceptionHandler(handler); + } + + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpServletIntegrationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpServletIntegrationTests.java new file mode 100644 index 00000000..164949b3 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpServletIntegrationTests.java @@ -0,0 +1,48 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The agent served by the application's own embedded Tomcat, through the mounted servlet. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = "spring.acp.agent.transport.type=http") +class AcpAgentHttpServletIntegrationTests { + + @LocalServerPort + private int port; + + @Autowired + private ApplicationContext context; + + @Test + void agentAnswersOverTheApplicationServer() { + assertThat(this.context.getBeanNamesForType(StreamableHttpAcpAgentTransport.class)).isEmpty(); + AcpAgentHttpAutoConfigurationTests.assertRoundTrip(new StreamableHttpAcpClientTransport( + URI.create("http://localhost:" + this.port + "/acp"), AcpJsonMapper.createDefault())); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class TestApplication { + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpShutdownTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpShutdownTests.java new file mode 100644 index 00000000..2deff20d --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpShutdownTests.java @@ -0,0 +1,75 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An application shuts down promptly while a client still holds a connection open. The + * servlet left to its own {@code destroy()} waited out a 30 second timeout. + */ +class AcpAgentHttpShutdownTests { + + private static final Duration PROMPT = Duration.ofSeconds(10); + + @Test + void servletWebApplicationShutsDownPromptlyWithAnOpenConnection() { + assertShutsDownPromptly("servlet"); + } + + @Test + void listenerApplicationShutsDownPromptlyWithAnOpenConnection() { + assertShutsDownPromptly("none"); + } + + private void assertShutsDownPromptly(String webApplicationType) { + ConfigurableApplicationContext context = SpringApplication.run(TestApplication.class, "--server.port=0", + "--spring.main.web-application-type=" + webApplicationType, "--spring.acp.agent.transport.type=http", + "--spring.acp.agent.transport.http.listener.port=0"); + int port = (context instanceof WebServerApplicationContext web) ? web.getWebServer().getPort() + : context.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + AcpSyncClient client = AcpClient + .sync(new StreamableHttpAcpClientTransport(URI.create("http://localhost:" + port + "/acp"), + AcpJsonMapper.createDefault())) + .requestTimeout(PROMPT) + .build(); + try { + client.initialize(); + client.newSession(new NewSessionRequest("/workspace", List.of())); + + long start = System.nanoTime(); + context.close(); + assertThat(Duration.ofNanos(System.nanoTime() - start)).isLessThan(PROMPT); + } + finally { + context.close(); + client.close(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class TestApplication { + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpWebSocketApiTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpWebSocketApiTests.java new file mode 100644 index 00000000..8037002f --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentHttpWebSocketApiTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A servlet web application on Tomcat 11 whose classpath puts the Jakarta WebSocket 2.1 API + * ahead of Tomcat's 2.2 classes, as a build does that takes the API from Jetty's jars without + * managing its version: Tomcat then fails every WebSocket send with {@code NoSuchMethodError}. + * Before, the application started and its WebSocket clients hung; now the startup fails with a + * message that names the fix. + */ +class AcpAgentHttpWebSocketApiTests { + + @Test + void anOlderWebSocketApiAheadOfTomcatFailsTheStartupNamingTheFix() throws Exception { + List urls = new ArrayList<>(); + try (Stream jars = Files.list(Path.of("target", "websocket-api-2.1"))) { + for (Path jar : jars.toList()) { + urls.add(jar.toUri().toURL()); + } + } + assertThat(urls).hasSize(2); + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + urls.add(Path.of(entry).toUri().toURL()); + } + ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader loader = new URLClassLoader(urls.toArray(URL[]::new), ClassLoader.getPlatformClassLoader())) { + Thread.currentThread().setContextClassLoader(loader); + String outcome = (String) loader.loadClass(TrappedApplication.class.getName()) + .getMethod("start") + .invoke(null); + assertThat(outcome).contains("Jakarta WebSocket 2.2") + .contains("acp-http-servlet") + .contains("acp-streamable-http-jetty"); + } + finally { + Thread.currentThread().setContextClassLoader(previous); + } + } + + @Test + void anApplicationWithoutTomcatWebSocketsIsNotChecked() { + assertThat(WebSocketApiCheck.problem(ClassLoader.getPlatformClassLoader())).isNull(); + assertThat(WebSocketApiCheck.problem(getClass().getClassLoader())).as("Tomcat 11 with the 2.2 API").isNull(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + public static class TrappedApplication { + + /** + * Starts the application in the class loader this class came from. + * @return "started", or the message of the startup failure's root cause + */ + public static String start() { + // This Tomcat runs in a class loader of its own: it must not install the JVM-wide URL + // stream handler factory, which the other tests' Tomcat installs. + org.apache.catalina.webresources.TomcatURLStreamHandlerFactory.disable(); + try (ConfigurableApplicationContext context = SpringApplication.run(TrappedApplication.class, + "--server.port=0", "--server.address=127.0.0.1", "--spring.main.web-application-type=servlet", + "--spring.acp.agent.transport.type=http")) { + return "started"; + } + catch (RuntimeException e) { + Throwable cause = e; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + return String.valueOf(cause.getMessage()); + } + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentPropertiesTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentPropertiesTests.java new file mode 100644 index 00000000..63d76039 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentPropertiesTests.java @@ -0,0 +1,90 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; + +import com.agentclientprotocol.sdk.integration.AcpAgentSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpAgentPropertiesTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesConfiguration.class); + + @Test + void defaultValues() { + this.runner.run(context -> { + AcpAgentProperties props = context.getBean(AcpAgentProperties.class); + assertThat(props.isEnabled()).isTrue(); + // Unset: the SDK default + assertThat(props.getRequestTimeout()).isNull(); + assertThat(props.getTransport().getType()).isNull(); + }); + } + + @Test + void enabled() { + this.runner.withPropertyValues("spring.acp.agent.enabled=false").run(context -> { + AcpAgentProperties props = context.getBean(AcpAgentProperties.class); + assertThat(props.isEnabled()).isFalse(); + }); + } + + @Test + void requestTimeout() { + this.runner.withPropertyValues("spring.acp.agent.request-timeout=5m").run(context -> { + AcpAgentProperties props = context.getBean(AcpAgentProperties.class); + assertThat(props.getRequestTimeout()).isEqualTo(Duration.ofMinutes(5)); + }); + } + + @Test + void transportType() { + this.runner.withPropertyValues("spring.acp.agent.transport.type=stdio").run(context -> { + AcpAgentProperties props = context.getBean(AcpAgentProperties.class); + assertThat(props.getTransport().getType()).isEqualTo(AcpTransportType.STDIO); + }); + } + + @Test + void everyPropertyBindsOntoTheSettings() { + this.runner + .withPropertyValues("spring.acp.agent.cancel-grace-period=3s", "spring.acp.agent.max-prompt-duration=2m", + "spring.acp.agent.transport.type=websocket", "spring.acp.agent.transport.http.path=/agents/acp", + "spring.acp.agent.transport.http.listener.port=9123", + "spring.acp.agent.transport.http.listener.max-concurrent-streams-per-connection=15", + "spring.acp.agent.transport.http.listener.host=0.0.0.0", + "spring.acp.agent.transport.http.allowed-origins=https://a.example,https://b.example") + .run(context -> { + AcpAgentSettings settings = context.getBean(AcpAgentProperties.class).toSettings(); + assertThat(settings.requestTimeout()).isNull(); + assertThat(settings.cancelGracePeriod()).isEqualTo(Duration.ofSeconds(3)); + assertThat(settings.maxPromptDuration()).isEqualTo(Duration.ofMinutes(2)); + assertThat(settings.transport()).isEqualTo(AcpTransportType.WEBSOCKET); + assertThat(settings.http().path()).isEqualTo("/agents/acp"); + assertThat(settings.http().listener().port()).isEqualTo(9123); + assertThat(settings.http().listener().maxConcurrentStreamsPerConnection()).isEqualTo(15); + assertThat(settings.http().listener().host()).isEqualTo("0.0.0.0"); + assertThat(settings.http().allowedOrigins()).containsExactly("https://a.example", "https://b.example"); + }); + } + + @Test + void anUnsetTypeIsStdio() { + this.runner.run(context -> assertThat(context.getBean(AcpAgentProperties.class).toSettings().transport()) + .isEqualTo(AcpTransportType.STDIO)); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(AcpAgentProperties.class) + static class PropertiesConfiguration { + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveObservationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveObservationTests.java new file mode 100644 index 00000000..60b59c01 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveObservationTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import io.micrometer.common.KeyValue; +import io.micrometer.observation.tck.TestObservationRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * In a reactive application the application's own HTTP observations cover {@code /acp}, as any + * route on its server: a request to the endpoint is an {@code http.server.requests} + * observation. + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.main.web-application-type=reactive", "spring.acp.agent.transport.type=http" }) +class AcpAgentReactiveObservationTests { + + @LocalServerPort + private int port; + + @Autowired + private StoppedObservations stoppedObservations; + + @Test + void anAcpRequestIsObserved() throws Exception { + URI endpoint = URI.create("http://127.0.0.1:" + this.port + "/acp"); + assertThat(HttpProbes.initialize(endpoint, null).statusCode()).isEqualTo(200); + // Waited for, not asserted at once: the server stops its observation after the + // response has reached the client. Selected by route, not taken first: the server may + // observe other requests, such as a probe of its port. + this.stoppedObservations.await("http.server.requests", Duration.ofSeconds(10), KeyValue.of("method", "POST"), + KeyValue.of("uri", "/acp"), KeyValue.of("status", "200")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class ObservedApplication { + + @Bean + TestObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + /** Registered with the registry by Spring Boot, as every observation handler bean. */ + @Bean + StoppedObservations stoppedObservations() { + return new StoppedObservations(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveSecurityTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveSecurityTests.java new file mode 100644 index 00000000..98bd1781 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentReactiveSecurityTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import com.agentclientprotocol.sdk.http.server.AcpHttpExchange; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpAutoConfigurationTests.EchoAgentConfiguration; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.AcpAgentHttpSecurityTests.PrincipalRecorder; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * In a reactive application the application's own {@link SecurityWebFilterChain} protects + * {@code /acp}, over HTTP and on the WebSocket handshake, because the endpoint is a route on the + * application's server behind its {@code WebFilter}s: an unauthenticated request is refused, an + * authenticated one is served, and the authenticated principal reaches the endpoint + * ({@link AcpHttpExchange#principal()}). + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { "spring.autoconfigure.exclude=", + "spring.main.web-application-type=reactive", "spring.acp.agent.transport.type=http" }) +class AcpAgentReactiveSecurityTests { + + private static final Map ALICE = Map.of("Authorization", + "Basic " + Base64.getEncoder().encodeToString("alice:secret".getBytes(StandardCharsets.UTF_8))); + + @LocalServerPort + private int port; + + @Autowired + private PrincipalRecorder recorder; + + private URI endpoint = URI.create("http://127.0.0.1/acp"); + + @BeforeEach + void endpoint() { + this.endpoint = URI.create("http://127.0.0.1:" + this.port + "/acp"); + this.recorder.principals.clear(); + } + + @Test + void anUnauthenticatedRequestIsRefusedOverHttpAndOnTheHandshake() throws Exception { + assertThat(HttpProbes.initialize(this.endpoint, null).statusCode()).isEqualTo(401); + assertThat(HttpProbes.webSocketHandshake(this.endpoint, null)).isEqualTo(401); + assertThat(this.recorder.principals).as("nothing reached the endpoint").isEmpty(); + } + + @Test + void anAuthenticatedRequestIsServedAndItsPrincipalReachesTheEndpoint() throws Exception { + assertThat(HttpProbes.initialize(this.endpoint, null, ALICE).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(this.endpoint, null, ALICE)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + assertThat(this.recorder.principals).hasSize(2).allSatisfy(name -> assertThat(name).isEqualTo("alice")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(EchoAgentConfiguration.class) + static class SecuredApplication { + + @Bean + SecurityWebFilterChain acpSecurity(ServerHttpSecurity http) { + return http.authorizeExchange(exchanges -> exchanges.pathMatchers("/acp").authenticated().anyExchange().permitAll()) + .httpBasic(Customizer.withDefaults()) + // ACP clients are not browsers: no CSRF token; the Origin check guards browsers. + .csrf(csrf -> csrf.requireCsrfProtectionMatcher( + new NegatedServerWebExchangeMatcher(ServerWebExchangeMatchers.pathMatchers("/acp")))) + .build(); + } + + @Bean + MapReactiveUserDetailsService users() { + return new MapReactiveUserDetailsService( + User.withUsername("alice").password("{noop}secret").roles("USER").build()); + } + + @Bean + static PrincipalRecorder principalRecorder() { + return new PrincipalRecorder(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfigurationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfigurationTests.java new file mode 100644 index 00000000..bc6f90cc --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpAgentTransportAutoConfigurationTests.java @@ -0,0 +1,129 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpServlet; +import com.agentclientprotocol.sdk.http.webflux.AcpWebFluxHost; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpAgentTransportAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpAgentTransportAutoConfiguration.class)) + .withBean(TestAgent.class); + + @Test + void httpTypeWithoutHttpModuleFailsWithAClearMessage() { + this.runner.withClassLoader(new FilteredClassLoader(StreamableHttpAcpServlet.class, AcpWebFluxHost.class)) + .withPropertyValues("spring.acp.agent.transport.type=http") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("acp-http-servlet") + .hasMessageContaining("acp-http-webflux") + .hasMessageContaining("acp-streamable-http-jetty"); + }); + } + + @Test + void stdioTransportBeanHasNoDestroyMethod() { + // The agent lifecycle closes the transport; an inferred close() would close it + // again. + this.runner.run(context -> assertThat( + context.getBeanFactory().getBeanDefinition("acpAgentTransport").getDestroyMethodName()) + .isEmpty()); + } + + @Test + void noTransportInClientOnlyApplication() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpAgentTransportAutoConfiguration.class)) + .run(context -> assertThat(context).doesNotHaveBean(AcpAgentTransport.class)); + } + + @Test + void createsStdioTransportByDefault() { + this.runner.run(context -> { + assertThat(context).hasSingleBean(AcpAgentTransport.class); + assertThat(context.getBean(AcpAgentTransport.class)).isInstanceOf(StdioAcpAgentTransport.class); + }); + } + + @Test + void createsStdioTransportWithExplicitType() { + this.runner.withPropertyValues("spring.acp.agent.transport.type=stdio").run(context -> { + assertThat(context).hasSingleBean(AcpAgentTransport.class); + assertThat(context.getBean(AcpAgentTransport.class)).isInstanceOf(StdioAcpAgentTransport.class); + }); + } + + @Test + void noTransportWhenDisabled() { + this.runner.withPropertyValues("spring.acp.agent.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(AcpAgentTransport.class)); + } + + @Test + void userProvidedTransportTakesPrecedence() { + this.runner.withUserConfiguration(CustomTransportConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(AcpAgentTransport.class); + assertThat(context.getBean(AcpAgentTransport.class)).isInstanceOf(StubAgentTransport.class); + }); + } + + @com.agentclientprotocol.sdk.annotation.AcpAgent(name = "test-agent", version = "1.0") + static class TestAgent { + + } + + @Configuration(proxyBeanMethods = false) + static class CustomTransportConfiguration { + + @Bean + AcpAgentTransport customTransport() { + return new StubAgentTransport(); + } + + } + + static class StubAgentTransport implements AcpAgentTransport { + + @Override + public reactor.core.publisher.Mono start( + java.util.function.Function, reactor.core.publisher.Mono> handler) { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono awaitTermination() { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono sendMessage( + com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage message) { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono closeGracefully() { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, com.agentclientprotocol.sdk.json.TypeRef typeRef) { + return null; + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpThreadsTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpThreadsTests.java new file mode 100644 index 00000000..a00fe530 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/AcpThreadsTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.NewSessionResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptRequest; +import com.agentclientprotocol.sdk.spec.AcpSchema.PromptResponse; +import com.agentclientprotocol.sdk.spec.AcpSchema.StopReason; +import com.agentclientprotocol.sdk.spec.AcpSchema.TextContent; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientAutoConfiguration; +import com.agentclientprotocol.sdk.spring.boot.autoconfigure.client.AcpClientTransportAutoConfiguration; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Spring Boot's own opt-in decides the threads: with {@code spring.threads.virtual.enabled=true} + * on JDK 21 the agent's handlers, its listener and the client's network transport all run on the + * virtual-thread {@code applicationTaskExecutor}, and the SDK creates no pool of its own; without + * it they keep platform threads, even on JDK 21. + */ +class AcpThreadsTests { + + private static final String VIRTUAL = "spring.threads.virtual.enabled=true"; + + private static ApplicationContextRunner agentRunner() { + return new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(TaskExecutionAutoConfiguration.class, + AcpAgentTransportAutoConfiguration.class, AcpAgentAutoConfiguration.class, + AcpAgentHttpAutoConfiguration.class)) + .withPropertyValues("spring.acp.agent.shutdown-on-transport-end=false", + "spring.acp.agent.transport.http.listener.port=0"); + } + + private static ApplicationContextRunner clientRunner() { + return new ApplicationContextRunner().withConfiguration(AutoConfigurations.of( + TaskExecutionAutoConfiguration.class, AcpClientTransportAutoConfiguration.class, + AcpClientAutoConfiguration.class)); + } + + @ParameterizedTest + @ValueSource(strings = { "http", "websocket" }) + void withVirtualThreadsOnTheSdkCreatesNoPoolOfItsOwn(String type) { + assumeTrue(VirtualThreads.isSupported(), "virtual threads need JDK 21"); + List handlerThreads = new CopyOnWriteArrayList<>(); + Set before = Thread.getAllStackTraces().keySet(); + Set created = roundTrip(type, handlerThreads, before, VIRTUAL); + + assertThat(handlerThreads).singleElement().satisfies(thread -> { + assertThat(VirtualThreads.isVirtual(thread)).as("handler on %s", thread).isTrue(); + assertThat(thread.getName()).startsWith("task-"); + }); + // What remains is not a pool of the SDK's: Jetty's VirtualThreadPool parks one platform + // thread while the listener runs, the JDK's HttpClient has one selector thread, and the + // SDK's one JVM-wide timer serves every timeout. + assertThat(created).allMatch(name -> name.equals("jetty-virtual-thread-pool-keepalive") + || name.equals("acp-timeout") || name.matches("HttpClient-\\d+-SelectorManager"), "allowed") + .contains("jetty-virtual-thread-pool-keepalive"); + assertThat(created.stream().filter(name -> name.startsWith("HttpClient-"))).hasSizeLessThanOrEqualTo(1); + } + + @ParameterizedTest + @ValueSource(strings = { "http", "websocket" }) + void withoutVirtualThreadsEverythingStaysOnPlatformThreads(String type) { + List handlerThreads = new CopyOnWriteArrayList<>(); + Set before = Thread.getAllStackTraces().keySet(); + Set created = roundTrip(type, handlerThreads, before); + + assertThat(handlerThreads).singleElement().satisfies(thread -> { + assertThat(VirtualThreads.isVirtual(thread)).as("handler on %s", thread).isFalse(); + assertThat(thread.getName()).isEqualTo("acp-agent-sync-handler"); + }); + assertThat(created).anyMatch(name -> name.startsWith("qtp")); + assertThat(created).anyMatch(name -> name.startsWith(type.equals("http") ? "acp-streamable-http-" : "acp-ws-client")); + } + + /** Serves the agent, prompts it from a Spring client, and returns the platform threads created. */ + private static Set roundTrip(String type, List handlerThreads, Set before, + String... properties) { + Set created = new java.util.HashSet<>(); + agentRunner().withBean(ThreadAgent.class, () -> new ThreadAgent(handlerThreads)) + .withPropertyValues(properties) + .withPropertyValues("spring.acp.agent.transport.type=" + type) + .run(agentContext -> { + assertThat(agentContext).hasNotFailed(); + int port = agentContext.getBean(StreamableHttpAcpAgentTransport.class).getPort(); + String scheme = type.equals("http") ? "http" : "ws"; + clientRunner().withPropertyValues(properties) + .withPropertyValues("spring.acp.client.transport." + type + ".uri=" + scheme + "://localhost:" + port + + "/acp") + .run(clientContext -> { + assertThat(clientContext).hasNotFailed(); + AcpSyncClient client = clientContext.getBean(AcpSyncClient.class); + client.initialize(); + NewSessionResponse session = client.newSession(new NewSessionRequest("/workspace", List.of())); + PromptResponse response = client + .prompt(new PromptRequest(session.sessionId(), List.of(new TextContent("hello")))); + assertThat(response.stopReason()).isEqualTo(StopReason.END_TURN); + created.addAll(newPlatformThreads(before)); + }); + }); + return created; + } + + private static Set newPlatformThreads(Set before) { + return Thread.getAllStackTraces() + .keySet() + .stream() + .filter(thread -> !before.contains(thread)) + .map(Thread::getName) + .filter(name -> Arrays.asList("acp-", "qtp", "HttpClient-", "Scheduler-", "jetty-") + .stream() + .anyMatch(name::startsWith)) + .collect(Collectors.toSet()); + } + + @AcpAgent(name = "thread-agent", version = "1.0") + static class ThreadAgent { + + private final List threads; + + ThreadAgent(List threads) { + this.threads = threads; + } + + @NewSession + public NewSessionResponse newSession(NewSessionRequest request) { + return new NewSessionResponse(UUID.randomUUID().toString(), null, null); + } + + @Prompt + public PromptResponse prompt(PromptRequest request, SyncPromptContext context) { + threads.add(Thread.currentThread()); + context.sendMessage("hi"); + return PromptResponse.endTurn(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutorsTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutorsTests.java new file mode 100644 index 00000000..a8a94bc2 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/HandlerExecutorsTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.integration.AcpTransportThreads; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.task.SimpleAsyncTaskExecutor; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The decision {@link HandlerExecutors} makes once Spring Boot's virtual-thread opt-in is known. + * The opt-in is passed in, so the decision is tested on every JDK: {@code Threading.VIRTUAL} is + * never active before JDK 21. + */ +class HandlerExecutorsTests { + + private static final String APPLICATION_TASK_EXECUTOR = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME; + + private final AcpAgentProperties properties = new AcpAgentProperties(); + + @Test + void withVirtualThreadsTheHandlersRunOnTheApplicationTaskExecutor() throws Exception { + try (GenericApplicationContext context = context(new SimpleAsyncTaskExecutor("app-task-"))) { + ExecutorService handlers = HandlerExecutors.resolve(context, properties, true); + + // A plain TaskExecutor, adapted to an ExecutorService. + assertThat(handlers.submit(() -> Thread.currentThread().getName()).get(5, TimeUnit.SECONDS)) + .startsWith("app-task-"); + } + } + + @Test + void anApplicationTaskExecutorThatIsAnExecutorServiceIsUsedAsIs() { + ExecutorService pool = Executors.newSingleThreadExecutor(); + try (GenericApplicationContext context = context(pool)) { + assertThat(HandlerExecutors.resolve(context, properties, true)).isSameAs(pool); + } + finally { + pool.shutdownNow(); + } + } + + @Test + void withoutVirtualThreadsOrWithoutTheExecutorTheSdkPool() throws Exception { + try (GenericApplicationContext withExecutor = context(new SimpleAsyncTaskExecutor("app-task-")); + GenericApplicationContext without = context(null); + GenericApplicationContext notAnExecutor = context("not an executor")) { + for (ExecutorService handlers : new ExecutorService[] { + HandlerExecutors.resolve(withExecutor, properties, false), + HandlerExecutors.resolve(without, properties, true), + HandlerExecutors.resolve(notAnExecutor, properties, true) }) { + assertThat(handlers.submit(() -> Thread.currentThread().getName()).get(5, TimeUnit.SECONDS)) + .isEqualTo("acp-agent-sync-handler"); + } + } + } + + @Test + void theListenerFollowsTheSameRule() { + SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("app-task-"); + try (GenericApplicationContext context = context(executor); + GenericApplicationContext without = context(null)) { + assertThat(HandlerExecutors.listenerThreads(context, true).executor()).isSameAs(executor); + AcpTransportThreads platform = HandlerExecutors.listenerThreads(context, false); + assertThat(platform.executor()).isNull(); + assertThat(platform.virtualThreads()).isFalse(); + assertThat(HandlerExecutors.listenerThreads(without, true).executor()).isNull(); + } + } + + private static GenericApplicationContext context(Object applicationTaskExecutor) { + GenericApplicationContext context = new GenericApplicationContext(); + if (applicationTaskExecutor != null) { + context.registerBean(APPLICATION_TASK_EXECUTOR, Object.class, () -> applicationTaskExecutor); + } + context.refresh(); + return context; + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringMvcTckTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringMvcTckTests.java new file mode 100644 index 00000000..36b92668 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringMvcTckTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The servlet host mounted by the autoconfiguration on a Spring MVC application's own Tomcat + * ({@code server.port}), under the shared transport TCK: HTTP, SSE and WebSocket on one path of + * one server, beside the application's own controllers, and drained before Boot's graceful + * shutdown. + */ +class SpringMvcTckTests extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) { + var options = config.options(); + ConfigurableApplicationContext context = new SpringApplicationBuilder(TckApplication.class) + .initializers(ctx -> ((GenericApplicationContext) ctx).registerBean("tckAgentFactory", AcpAgentFactory.class, + config::agents)) + .run("--server.port=0", "--server.address=127.0.0.1", "--spring.acp.agent.transport.type=http", + "--spring.acp.agent.transport.http.keep-alive-interval=" + options.keepAliveInterval().toMillis() + "ms", + "--spring.acp.agent.transport.http.max-post-body-size=" + options.maxPostBodyBytes() + "B", + "--spring.acp.agent.transport.http.shutdown-timeout=" + options.shutdownTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.web-socket-idle-timeout=" + + options.webSocketIdleTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.initialize-timeout=" + options.initializeTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.allowed-origins=" + String.join(",", options.allowedOrigins()), + "--spring.mvc.async.request-timeout=" + config.containerAsyncTimeout().toMillis() + "ms", + // Compression on, including SSE: the autoconfiguration takes text/event-stream out. + "--server.compression.enabled=true", "--server.compression.min-response-size=1B", + "--server.compression.mime-types=text/event-stream,application/json,text/plain"); + int port = ((WebServerApplicationContext) context).getWebServer().getPort(); + URI endpoint = URI.create("http://127.0.0.1:" + port + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() { + // Boot's own shutdown: graceful, with the endpoint drained in an earlier phase. + context.close(); + } + + }; + } + + @Test + void anMvcControllerSharesThePortWithAcp() throws Exception { + Host host = startHost(standardConfig()); + try { + URI hello = host.endpoint().resolve("/hello"); + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(hello).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo("hello"); + assertThat(HttpProbes.initialize(host.endpoint(), null).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(host.endpoint(), null)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + finally { + host.stop(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TckApplication { + + @RestController + static class HelloController { + + @GetMapping("/hello") + String hello() { + return "hello"; + } + + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringWebFluxTckTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringWebFluxTckTests.java new file mode 100644 index 00000000..88a7bc70 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/SpringWebFluxTckTests.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The WebFlux host routed by the autoconfiguration in a reactive application on its own + * Reactor Netty server ({@code server.port}), under the shared transport TCK: HTTP, SSE and + * WebSocket on one path of one server, beside the application's own routes, and drained before + * Boot's graceful shutdown. + */ +class SpringWebFluxTckTests extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) { + var options = config.options(); + ConfigurableApplicationContext context = new SpringApplicationBuilder(TckApplication.class) + .initializers(ctx -> ((GenericApplicationContext) ctx).registerBean("tckAgentFactory", AcpAgentFactory.class, + config::agents)) + .run("--spring.main.web-application-type=reactive", "--server.port=0", "--server.address=127.0.0.1", + "--spring.acp.agent.transport.type=http", + "--spring.acp.agent.transport.http.keep-alive-interval=" + options.keepAliveInterval().toMillis() + "ms", + "--spring.acp.agent.transport.http.max-post-body-size=" + options.maxPostBodyBytes() + "B", + "--spring.acp.agent.transport.http.shutdown-timeout=" + options.shutdownTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.web-socket-idle-timeout=" + + options.webSocketIdleTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.initialize-timeout=" + options.initializeTimeout().toMillis() + "ms", + "--spring.acp.agent.transport.http.allowed-origins=" + String.join(",", options.allowedOrigins()), + // Compression on, including SSE: the autoconfiguration takes text/event-stream out. + "--server.compression.enabled=true", "--server.compression.min-response-size=1B", + "--server.compression.mime-types=text/event-stream,application/json,text/plain"); + int port = ((WebServerApplicationContext) context).getWebServer().getPort(); + URI endpoint = URI.create("http://127.0.0.1:" + port + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() { + // Boot's own shutdown: graceful, with the endpoint drained in an earlier phase. + context.close(); + } + + }; + } + + @Test + void anApplicationRouteSharesThePortWithAcp() throws Exception { + Host host = startHost(standardConfig()); + try { + URI hello = host.endpoint().resolve("/hello"); + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(hello).GET().build(), HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.body()).isEqualTo("hello"); + assertThat(HttpProbes.initialize(host.endpoint(), null).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(host.endpoint(), null)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + finally { + host.stop(); + } + } + + @Test + void anSseStreamIsNotCompressedForAClientThatAcceptsGzip() throws Exception { + Host host = startHost(standardConfig()); + try { + String connectionId = HttpProbes.initialize(host.endpoint(), null) + .headers() + .firstValue("Acp-Connection-Id") + .orElseThrow(); + HttpRequest get = HttpRequest.newBuilder(host.endpoint()) + .GET() + .header("Accept", "text/event-stream") + .header("Accept-Encoding", "gzip") + .header("Acp-Connection-Id", connectionId) + .build(); + HttpResponse stream = HttpClient.newHttpClient() + .sendAsync(get, HttpResponse.BodyHandlers.ofInputStream()) + .get(10, TimeUnit.SECONDS); + try (InputStream body = stream.body()) { + assertThat(stream.statusCode()).isEqualTo(200); + assertThat(stream.headers().firstValue("Content-Encoding")).isEmpty(); + byte[] opening = ": connected".getBytes(StandardCharsets.UTF_8); + assertThat(body.readNBytes(opening.length)).isEqualTo(opening); + } + } + finally { + host.stop(); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + static class TckApplication { + + @Bean + RouterFunction hello() { + return RouterFunctions.route(RequestPredicates.GET("/hello"), request -> ServerResponse.ok().bodyValue("hello")); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/StoppedObservations.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/StoppedObservations.java new file mode 100644 index 00000000..43038844 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/agent/StoppedObservations.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; + +import io.micrometer.common.KeyValue; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationHandler; + +/** + * Records the observations that have stopped, so a test can wait for the one it is about. A + * server observation stops after the response has reached the client, and the test's server may + * observe requests other than the test's own, so neither the first observation nor its state + * when the response arrives can be asserted. + */ +final class StoppedObservations implements ObservationHandler { + + private final List stopped = new CopyOnWriteArrayList<>(); + + @Override + public boolean supportsContext(Observation.Context context) { + return true; + } + + @Override + public void onStop(Observation.Context context) { + stopped.add(context); + } + + /** + * Waits for a stopped observation of the given name whose key values, low or high + * cardinality, include all those given. + */ + Observation.Context await(String name, Duration timeout, KeyValue... keyValues) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + Optional match = stopped.stream() + .filter(context -> name.equals(context.getName())) + .filter(context -> { + for (KeyValue keyValue : keyValues) { + KeyValue actual = context.getLowCardinalityKeyValue(keyValue.getKey()); + if (actual == null) { + actual = context.getHighCardinalityKeyValue(keyValue.getKey()); + } + if (actual == null || !keyValue.getValue().equals(actual.getValue())) { + return false; + } + } + return true; + }) + .findFirst(); + if (match.isPresent()) { + return match.get(); + } + if (System.nanoTime() > deadline) { + throw new AssertionError("No stopped " + name + " observation with " + List.of(keyValues) + " within " + + timeout + "; stopped: " + + stopped.stream().map(context -> context.getName() + context.getLowCardinalityKeyValues() + context.getHighCardinalityKeyValues()).toList()); + } + Thread.sleep(10); + } + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfigurationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfigurationTests.java new file mode 100644 index 00000000..3ebd0bd5 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientAutoConfigurationTests.java @@ -0,0 +1,410 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpSyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import reactor.core.publisher.Mono; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpClientAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(AcpClientTransportAutoConfiguration.class, AcpClientAutoConfiguration.class)); + + @Test + void noClientBeansWithoutTransport() { + this.runner.run(context -> { + assertThat(context).doesNotHaveBean(AcpSyncClient.class); + assertThat(context).doesNotHaveBean(AcpAsyncClient.class); + }); + } + + @Test + void createsClientBeansWhenTransportPresent() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(AcpSyncClient.class); + assertThat(context).hasSingleBean(AcpAsyncClient.class); + }); + } + + @Test + void contextStartConnectsTheTransportExactlyOnce() { + this.runner.withUserConfiguration(CountingTransportConfiguration.class).run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AcpAsyncClient.class); + assertThat(context).hasSingleBean(AcpSyncClient.class); + assertThat(context.getBean(CountingClientTransport.class).connects.get()).isEqualTo(1); + }); + } + + @Test + void createsLifecycleBeanWhenTransportPresent() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .run(context -> assertThat(context).hasBean("acpClientLifecycle")); + } + + @Test + void respectsCustomRequestTimeout() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .withPropertyValues("spring.acp.client.request-timeout=120s") + .run(context -> { + assertThat(context).hasSingleBean(AcpSyncClient.class); + assertThat(context).hasSingleBean(AcpAsyncClient.class); + }); + } + + @Test + void respectsCustomPromptTimeout() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .withPropertyValues("spring.acp.client.prompt-timeout=10m") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(AcpAsyncClient.class); + }); + } + + @Test + void defaultCapabilities() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class).run(context -> { + assertThat(context).hasSingleBean(AcpSyncClient.class); + // No capability is advertised by default: the autoconfiguration registers no + // file system or terminal handler + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getCapabilities().isReadTextFile()).isFalse(); + assertThat(props.getCapabilities().isWriteTextFile()).isFalse(); + assertThat(props.getCapabilities().isTerminal()).isFalse(); + }); + } + + @Test + void capabilitiesFromPropertiesWithTheirHandlersBuild() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .withPropertyValues("spring.acp.client.capabilities.read-text-file=true", + "spring.acp.client.capabilities.write-text-file=true", + "spring.acp.client.capabilities.terminal=true") + .withBean(AcpClientCustomizer.class, () -> spec -> spec.readTextFileHandler(request -> Mono.empty()) + .writeTextFileHandler(request -> Mono.empty()) + .createTerminalHandler(request -> Mono.empty()) + .terminalOutputHandler(request -> Mono.empty()) + .releaseTerminalHandler(request -> Mono.empty()) + .waitForTerminalExitHandler(request -> Mono.empty()) + .killTerminalHandler(request -> Mono.empty())) + .run(context -> { + assertThat(context).hasNotFailed(); + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getCapabilities().isReadTextFile()).isTrue(); + assertThat(props.getCapabilities().isWriteTextFile()).isTrue(); + assertThat(props.getCapabilities().isTerminal()).isTrue(); + }); + } + + @Test + void aCapabilityPropertyWithoutItsHandlerFailsNamingTheProperty() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .withPropertyValues("spring.acp.client.capabilities.read-text-file=true", + "spring.acp.client.capabilities.terminal=true") + .run(context -> assertThat(context).getFailure() + .hasStackTraceContaining("readTextFileHandler") + .hasStackTraceContaining("The capabilities come from spring.acp.client.capabilities.read-text-file=true, " + + "spring.acp.client.capabilities.terminal=true:") + .satisfies(failure -> assertThat(failure).hasStackTraceContaining("IllegalStateException"))); + } + + @Test + void advertisesNoFileSystemCapabilityByDefault() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List initializeRequests = new CopyOnWriteArrayList<>(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()).initializeHandler(request -> { + initializeRequests.add(request); + return AcpSchema.InitializeResponse.ok(); + }).promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()).build(); + agent.start(); + try { + this.runner.withBean(AcpClientTransport.class, pair::clientTransport).run(context -> { + context.getBean(AcpSyncClient.class).initialize(); + AcpSchema.ClientCapabilities capabilities = initializeRequests.get(0).clientCapabilities(); + assertThat(capabilities.fs().readTextFile()).isFalse(); + assertThat(capabilities.fs().writeTextFile()).isFalse(); + assertThat(capabilities.terminal()).isFalse(); + }); + } + finally { + agent.closeGracefully(); + } + } + + @Test + void contextCloseClosesTheClientOnce() { + CountingClientTransport transport = new CountingClientTransport( + InMemoryTransportPair.create().clientTransport()); + // The transport bean's own destroy method is off: count only the closes that come + // through the clients. + this.runner + .withBean("acpClientTransport", AcpClientTransport.class, () -> transport, + definition -> ((AbstractBeanDefinition) definition).setDestroyMethodName("")) + .run(context -> assertThat(context).hasSingleBean(AcpSyncClient.class)); + assertThat(transport.closes).hasValue(1); + } + + @Test + void appliesCustomizersInOrder() { + List applied = new CopyOnWriteArrayList<>(); + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class) + .withBean("second", AcpClientCustomizer.class, () -> new OrderedCustomizer(2, "second", applied)) + .withBean("first", AcpClientCustomizer.class, () -> new OrderedCustomizer(1, "first", applied)) + .run(context -> { + assertThat(context).hasSingleBean(AcpAsyncClient.class); + assertThat(applied).containsExactly("first", "second"); + }); + } + + @Test + void customizerReceivesSessionUpdates() { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null)) + .promptHandler((request, prompt) -> { + prompt.sendMessage("hello"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + agent.start(); + List received = new CopyOnWriteArrayList<>(); + try { + this.runner.withBean(AcpClientTransport.class, pair::clientTransport) + .withBean(AcpClientCustomizer.class, () -> spec -> spec.sessionUpdateHandler(notification -> { + received.add(notification); + return Mono.empty(); + })) + .run(context -> { + AcpSyncClient client = context.getBean(AcpSyncClient.class); + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())); + client.prompt(new AcpSchema.PromptRequest("session-1", List.of(new AcpSchema.TextContent("hi")))); + assertThat(received).singleElement() + .satisfies(notification -> assertThat(notification.sessionId()).isEqualTo("session-1")); + }); + } + finally { + agent.closeGracefully(); + } + } + + @Test + void anApplicationConsumerReplacesTheDebugLoggingDefault() { + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory + .getLogger(com.agentclientprotocol.sdk.integration.AcpClients.class); + ch.qos.logback.core.read.ListAppender logged = new ch.qos.logback.core.read.ListAppender<>(); + logged.start(); + ch.qos.logback.classic.Level level = logger.getLevel(); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + logger.addAppender(logged); + try { + // Without an application handler, the default logs each update at DEBUG. + assertThat(promptAndCollect(null)).isEmpty(); + assertThat(logged.list).anySatisfy(event -> assertThat(event.getFormattedMessage()) + .startsWith("Session update for session-1")); + + // With one, the default is replaced, not run beside it. + logged.list.clear(); + assertThat(promptAndCollect(spec -> { + })).singleElement(); + assertThat(logged.list).noneSatisfy(event -> assertThat(event.getFormattedMessage()) + .startsWith("Session update for")); + } + finally { + logger.detachAppender(logged); + logger.setLevel(level); + } + } + + /** + * Prompts an agent that sends one update; with a customizer, it also adds a consumer that + * collects the updates, which the method returns. + */ + private List promptAndCollect(AcpClientCustomizer customizer) { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null)) + .promptHandler((request, prompt) -> { + prompt.sendMessage("hello"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); + agent.start(); + List received = new CopyOnWriteArrayList<>(); + ApplicationContextRunner contextRunner = this.runner.withBean(AcpClientTransport.class, pair::clientTransport); + if (customizer != null) { + contextRunner = contextRunner.withBean(AcpClientCustomizer.class, + () -> spec -> spec.sessionUpdateHandler(notification -> { + received.add(notification); + return Mono.empty(); + })); + } + try { + contextRunner.run(context -> { + AcpSyncClient client = context.getBean(AcpSyncClient.class); + client.initialize(); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())); + client.prompt(new AcpSchema.PromptRequest("session-1", List.of(new AcpSchema.TextContent("hi")))); + }); + } + finally { + agent.closeGracefully(); + } + return received; + } + + record OrderedCustomizer(int order, String name, List applied) implements AcpClientCustomizer, Ordered { + + @Override + public void customize(AcpClient.AsyncSpec spec) { + this.applied.add(this.name); + } + + @Override + public int getOrder() { + return this.order; + } + + } + + @Test + void userProvidedSyncClientTakesPrecedence() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class, CustomSyncClientConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(AcpSyncClient.class); + assertThat(context.getBean("customSyncClient")).isNotNull(); + }); + } + + @Test + void userProvidedAsyncClientTakesPrecedence() { + this.runner.withUserConfiguration(InMemoryTransportConfiguration.class, CustomAsyncClientConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(AcpAsyncClient.class); + assertThat(context.getBean("customAsyncClient")).isNotNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class InMemoryTransportConfiguration { + + @Bean + AcpClientTransport acpClientTransport() { + return InMemoryTransportPair.create().clientTransport(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomSyncClientConfiguration { + + @Bean + AcpSyncClient customSyncClient(AcpAsyncClient asyncClient) { + return new AcpSyncClient(asyncClient); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomAsyncClientConfiguration { + + @Bean + AcpAsyncClient customAsyncClient(AcpClientTransport transport) { + return com.agentclientprotocol.sdk.client.AcpClient.async(transport).build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CountingTransportConfiguration { + + @Bean + CountingClientTransport acpClientTransport() { + return new CountingClientTransport(InMemoryTransportPair.create().clientTransport()); + } + + } + + /** + * Counts {@code connect()} calls: each client session connects its transport once, so + * the count is the number of sessions built on it. + */ + static class CountingClientTransport implements AcpClientTransport { + + final AtomicInteger connects = new AtomicInteger(); + + final AtomicInteger closes = new AtomicInteger(); + + private final AcpClientTransport delegate; + + CountingClientTransport(AcpClientTransport delegate) { + this.delegate = delegate; + } + + @Override + public Mono connect(Function, Mono> handler) { + this.connects.incrementAndGet(); + return this.delegate.connect(handler); + } + + @Override + public void setExceptionHandler(Consumer handler) { + this.delegate.setExceptionHandler(handler); + } + + @Override + public Mono awaitTermination() { + return this.delegate.awaitTermination(); + } + + @Override + public Mono closeGracefully() { + this.closes.incrementAndGet(); + return this.delegate.closeGracefully(); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return this.delegate.sendMessage(message); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return this.delegate.unmarshalFrom(data, typeRef); + } + + @Override + public List protocolVersions() { + return this.delegate.protocolVersions(); + } + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientPropertiesTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientPropertiesTests.java new file mode 100644 index 00000000..7b992af9 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientPropertiesTests.java @@ -0,0 +1,132 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.integration.AcpClientSettings; +import com.agentclientprotocol.sdk.integration.AcpTransportType; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpClientPropertiesTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(PropertiesConfiguration.class); + + @Test + void defaultValues() { + this.runner.run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + // Unset: the SDK default + assertThat(props.getRequestTimeout()).isNull(); + assertThat(props.getPromptTimeout()).isNull(); + assertThat(props.getTransport().getType()).isNull(); + assertThat(props.getTransport().getWebsocket().getUri()).isNull(); + assertThat(props.getTransport().getWebsocket().getConnectTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(props.getTransport().getStdio().getCommand()).isNull(); + assertThat(props.getTransport().getStdio().getArgs()).isEmpty(); + assertThat(props.getTransport().getStdio().getEnv()).isEmpty(); + assertThat(props.getCapabilities().isReadTextFile()).isFalse(); + assertThat(props.getCapabilities().isWriteTextFile()).isFalse(); + assertThat(props.getCapabilities().isTerminal()).isFalse(); + }); + } + + @Test + void requestTimeout() { + this.runner.withPropertyValues("spring.acp.client.request-timeout=2m").run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getRequestTimeout()).isEqualTo(Duration.ofMinutes(2)); + }); + } + + @Test + void promptTimeout() { + this.runner.withPropertyValues("spring.acp.client.prompt-timeout=10m").run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getPromptTimeout()).isEqualTo(Duration.ofMinutes(10)); + }); + } + + @Test + void transportTypeWebsocket() { + this.runner.withPropertyValues("spring.acp.client.transport.type=websocket").run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getTransport().getType()).isEqualTo(AcpTransportType.WEBSOCKET); + }); + } + + @Test + void transportTypeStdio() { + this.runner.withPropertyValues("spring.acp.client.transport.type=stdio").run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getTransport().getType()).isEqualTo(AcpTransportType.STDIO); + }); + } + + @Test + void websocketProperties() { + this.runner + .withPropertyValues("spring.acp.client.transport.websocket.uri=ws://example.com:9090/acp", + "spring.acp.client.transport.websocket.connect-timeout=30s") + .run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getTransport().getWebsocket().getUri()) + .isEqualTo(URI.create("ws://example.com:9090/acp")); + assertThat(props.getTransport().getWebsocket().getConnectTimeout()).isEqualTo(Duration.ofSeconds(30)); + }); + } + + @Test + void stdioProperties() { + this.runner + .withPropertyValues("spring.acp.client.transport.stdio.command=python", + "spring.acp.client.transport.stdio.args=agent.py,--verbose", + "spring.acp.client.transport.stdio.env.PYTHONPATH=/opt/lib") + .run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getTransport().getStdio().getCommand()).isEqualTo("python"); + assertThat(props.getTransport().getStdio().getArgs()).containsExactly("agent.py", "--verbose"); + assertThat(props.getTransport().getStdio().getEnv()).containsEntry("PYTHONPATH", "/opt/lib"); + }); + } + + @Test + void capabilitiesProperties() { + this.runner.withPropertyValues("spring.acp.client.capabilities.read-text-file=true", + "spring.acp.client.capabilities.write-text-file=true", "spring.acp.client.capabilities.terminal=true") + .run(context -> { + AcpClientProperties props = context.getBean(AcpClientProperties.class); + assertThat(props.getCapabilities().isReadTextFile()).isTrue(); + assertThat(props.getCapabilities().isWriteTextFile()).isTrue(); + assertThat(props.getCapabilities().isTerminal()).isTrue(); + }); + } + + @Test + void promptTimeoutAndTheNewCapabilitiesBindOntoTheSettings() { + this.runner.withPropertyValues("spring.acp.client.prompt-timeout=10m", + "spring.acp.client.capabilities.elicitation-form=true", + "spring.acp.client.capabilities.elicitation-url=true", + "spring.acp.client.capabilities.boolean-config-options=true") + .run(context -> { + AcpClientSettings settings = context.getBean(AcpClientProperties.class).toSettings(); + assertThat(settings.promptTimeout()).isEqualTo(Duration.ofMinutes(10)); + assertThat(settings.requestTimeout()).isNull(); + assertThat(settings.capabilities()) + .isEqualTo(new AcpClientSettings.Capabilities(false, false, false, true, true, true)); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(AcpClientProperties.class) + static class PropertiesConfiguration { + + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfigurationTests.java b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfigurationTests.java new file mode 100644 index 00000000..be8daa1c --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/java/com/agentclientprotocol/sdk/spring/boot/autoconfigure/client/AcpClientTransportAutoConfigurationTests.java @@ -0,0 +1,233 @@ +package com.agentclientprotocol.sdk.spring.boot.autoconfigure.client; + +import java.util.concurrent.Executor; + +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class AcpClientTransportAutoConfigurationTests { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(AcpClientTransportAutoConfiguration.class)); + + @Test + void httpTransportAutoDetectedFromUri() { + this.runner.withPropertyValues("spring.acp.client.transport.http.uri=http://localhost:8080/acp") + .run(context -> assertThat(context.getBean(AcpClientTransport.class)) + .isInstanceOf(StreamableHttpAcpClientTransport.class)); + } + + @Test + void httpTransportWithExplicitType() { + this.runner + .withPropertyValues("spring.acp.client.transport.type=http", + "spring.acp.client.transport.http.uri=http://localhost:8080/acp") + .run(context -> assertThat(context.getBean(AcpClientTransport.class)) + .isInstanceOf(StreamableHttpAcpClientTransport.class)); + } + + @Test + void httpTypeWithoutUriFailsWithAClearMessage() { + this.runner.withPropertyValues("spring.acp.client.transport.type=http").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("requires spring.acp.client.transport.http.uri"); + }); + } + + @Test + void websocketTypeWithoutUriFailsWithAClearMessage() { + this.runner.withPropertyValues("spring.acp.client.transport.type=websocket").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("requires spring.acp.client.transport.websocket.uri"); + }); + } + + @Test + void stdioTypeWithoutCommandFailsWithAClearMessage() { + this.runner.withPropertyValues("spring.acp.client.transport.type=stdio").run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("requires spring.acp.client.transport.stdio.command"); + }); + } + + @Test + void transportBeanHasNoDestroyMethod() { + // The client lifecycle closes the transport; an inferred close() would close it + // again. + this.runner.withPropertyValues("spring.acp.client.transport.http.uri=http://localhost:8080/acp") + .run(context -> assertThat( + context.getBeanFactory().getBeanDefinition("acpClientTransport").getDestroyMethodName()) + .isEmpty()); + } + + @Test + void noTransportWithoutProperties() { + this.runner.run(context -> assertThat(context).doesNotHaveBean(AcpClientTransport.class)); + } + + @Test + void stdioTransportWithExplicitType() { + this.runner + .withPropertyValues("spring.acp.client.transport.type=stdio", + "spring.acp.client.transport.stdio.command=echo") + .run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(StdioAcpClientTransport.class); + }); + } + + @Test + void websocketTransportWithExplicitType() { + this.runner + .withPropertyValues("spring.acp.client.transport.type=websocket", + "spring.acp.client.transport.websocket.uri=ws://localhost:8080/acp") + .run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(WebSocketAcpClientTransport.class); + }); + } + + @Test + void stdioTransportAutoDetectedFromCommand() { + this.runner.withPropertyValues("spring.acp.client.transport.stdio.command=python").run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(StdioAcpClientTransport.class); + }); + } + + @Test + void websocketTransportAutoDetectedFromUri() { + this.runner.withPropertyValues("spring.acp.client.transport.websocket.uri=ws://localhost:9090/acp") + .run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(WebSocketAcpClientTransport.class); + }); + } + + @Test + void stdioTransportWithArgs() { + this.runner + .withPropertyValues("spring.acp.client.transport.stdio.command=java", + "spring.acp.client.transport.stdio.args=-jar,agent.jar") + .run(context -> assertThat(context).hasSingleBean(AcpClientTransport.class)); + } + + @Test + void stdioTransportWithEnv() { + this.runner + .withPropertyValues("spring.acp.client.transport.stdio.command=node", + "spring.acp.client.transport.stdio.env.NODE_ENV=production") + .run(context -> assertThat(context).hasSingleBean(AcpClientTransport.class)); + } + + @Test + void explicitTypeTakesPrecedenceOverAutoDetect() { + this.runner + .withPropertyValues("spring.acp.client.transport.type=stdio", + "spring.acp.client.transport.stdio.command=echo", + "spring.acp.client.transport.websocket.uri=ws://localhost:8080/acp") + .run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(StdioAcpClientTransport.class); + }); + } + + @Test + void severalTransportsWithoutATypeFailAtStartupNamingThem() { + this.runner + .withPropertyValues("spring.acp.client.transport.stdio.command=echo", + "spring.acp.client.transport.websocket.uri=ws://localhost:8080/acp") + .run(context -> { + assertThat(context).hasFailed(); + assertThat(context.getStartupFailure()).rootCause() + .hasMessageContaining("Several ACP client transports are configured [STDIO, WEBSOCKET]") + .hasMessageContaining("spring.acp.client.transport.type"); + }); + } + + @Test + void userProvidedTransportBeanTakesPrecedence() { + this.runner.withUserConfiguration(CustomTransportConfiguration.class) + .withPropertyValues("spring.acp.client.transport.stdio.command=echo") + .run(context -> { + assertThat(context).hasSingleBean(AcpClientTransport.class); + assertThat(context.getBean(AcpClientTransport.class)).isInstanceOf(StubTransport.class); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomTransportConfiguration { + + @Bean + AcpClientTransport customTransport() { + return new StubTransport(); + } + + } + + static class StubTransport implements AcpClientTransport { + + @Override + public reactor.core.publisher.Mono connect( + java.util.function.Function, reactor.core.publisher.Mono> handler) { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono sendMessage( + com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage message) { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono closeGracefully() { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public T unmarshalFrom(Object data, com.agentclientprotocol.sdk.json.TypeRef typeRef) { + return null; + } + + } + + + /** + * With Spring Boot's virtual-thread opt-in (passed in, so tested on every JDK) the network + * transports run on the application task executor; without it, or without the bean, on + * platform threads. + */ + @Test + void transportThreadsFollowTheVirtualThreadOptIn() { + Executor executor = Runnable::run; + DefaultListableBeanFactory withExecutor = new DefaultListableBeanFactory(); + withExecutor.registerSingleton("applicationTaskExecutor", executor); + DefaultListableBeanFactory without = new DefaultListableBeanFactory(); + + assertThat(AcpClientTransportAutoConfiguration + .threads(true, withExecutor.getBeanProvider(Executor.class)) + .executor()).isSameAs(executor); + assertThat(AcpClientTransportAutoConfiguration + .threads(false, withExecutor.getBeanProvider(Executor.class)) + .executor()).isNull(); + assertThat(AcpClientTransportAutoConfiguration + .threads(true, without.getBeanProvider(Executor.class)) + .virtualThreads()).isFalse(); + } + +} diff --git a/acp-spring-boot-autoconfigure/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener b/acp-spring-boot-autoconfigure/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener new file mode 100644 index 00000000..fbdf299d --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener @@ -0,0 +1 @@ +com.agentclientprotocol.sdk.spring.boot.autoconfigure.DetachedStdinListener diff --git a/acp-spring-boot-autoconfigure/src/test/resources/application.properties b/acp-spring-boot-autoconfigure/src/test/resources/application.properties new file mode 100644 index 00000000..277c4328 --- /dev/null +++ b/acp-spring-boot-autoconfigure/src/test/resources/application.properties @@ -0,0 +1,10 @@ +# Spring Security is on the test classpath for AcpAgentHttpSecurityTests and +# AcpAgentReactiveSecurityTests, which turn it back on +# (spring.autoconfigure.exclude=). Every other application here runs without it, as an +# application without Spring Security does. +spring.autoconfigure.exclude=org.springframework.boot.security.autoconfigure.SecurityAutoConfiguration,\ + org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration,\ + org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterAutoConfiguration,\ + org.springframework.boot.security.autoconfigure.web.servlet.ServletWebSecurityAutoConfiguration,\ + org.springframework.boot.security.autoconfigure.ReactiveUserDetailsServiceAutoConfiguration,\ + org.springframework.boot.security.autoconfigure.web.reactive.ReactiveWebSecurityAutoConfiguration diff --git a/acp-spring-boot-starter/README.md b/acp-spring-boot-starter/README.md new file mode 100644 index 00000000..d094730b --- /dev/null +++ b/acp-spring-boot-starter/README.md @@ -0,0 +1,16 @@ +# acp-spring-boot-starter + +The Spring Boot 4.1 starter for the ACP Java SDK: depend on it to serve an `@AcpAgent` bean or to +get an ACP client from `spring.acp.client.*`. It brings `acp-spring-boot-autoconfigure`, +`acp-agent-support` and `spring-boot-starter`, and has no code of its own. + +```xml + + com.agentclientprotocol + acp-spring-boot-starter + ${acp.version} + +``` + +The guide, with every `spring.acp.*` property, the transports and the limitations, is the +[`acp-spring-boot-autoconfigure` README](../acp-spring-boot-autoconfigure/README.md). diff --git a/acp-spring-boot-starter/pom.xml b/acp-spring-boot-starter/pom.xml new file mode 100644 index 00000000..44fbd746 --- /dev/null +++ b/acp-spring-boot-starter/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + com.agentclientprotocol + acp-java-sdk + 0.80.0 + + + acp-spring-boot-starter + jar + + ACP Spring Boot Starter + Spring Boot starter for ACP clients and agents + + + 4.1.0 + + 1.5.34 + + true + + + + + + + org.eclipse.jetty + jetty-bom + ${jetty.version} + pom + import + + + org.eclipse.jetty.ee10 + jetty-ee10-bom + ${jetty.version} + pom + import + + + + io.projectreactor + reactor-bom + ${reactor.version} + pom + import + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.agentclientprotocol + acp-spring-boot-autoconfigure + ${project.version} + + + com.agentclientprotocol + acp-agent-support + ${project.version} + + + org.springframework.boot + spring-boot-starter + + + diff --git a/acp-streamable-http-jetty/pom.xml b/acp-streamable-http-jetty/pom.xml index 7dd764b7..2591e025 100644 --- a/acp-streamable-http-jetty/pom.xml +++ b/acp-streamable-http-jetty/pom.xml @@ -7,25 +7,37 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-streamable-http-jetty jar ACP Streamable HTTP Jetty - Streamable HTTP agent transport using Jetty for listener-backed remote agents + The SDK's own ACP listener: an embedded Jetty 12.1 server running the acp-http-servlet host, with Jetty's Jakarta WebSocket implementation, for agents served from a plain Java program + + + + 0.74 + 0.68 + 0.83 + 0.84 + + + + - com.agentclientprotocol - acp-core + org.jspecify + jspecify - com.agentclientprotocol - acp-json-jackson2 - ${project.version} + acp-http-servlet @@ -41,12 +53,23 @@ jetty-http2-server - org.eclipse.jetty.websocket - jetty-websocket-jetty-server + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-server + + + + jakarta.websocket + jakarta.websocket-api + ${jakarta-websocket-runtime.version} - org.eclipse.jetty.websocket - jetty-websocket-jetty-api + jakarta.websocket + jakarta.websocket-client-api + ${jakarta-websocket-runtime.version} @@ -54,6 +77,11 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + test + org.assertj assertj-core @@ -74,6 +102,12 @@ reactor-test test + + com.agentclientprotocol + acp-test + test + + diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStream.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStream.java deleted file mode 100644 index de491022..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStream.java +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.ArrayDeque; - -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import jakarta.servlet.AsyncContext; -import jakarta.servlet.AsyncEvent; -import jakarta.servlet.AsyncListener; -import jakarta.servlet.ServletOutputStream; -import jakarta.servlet.WriteListener; -import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * One outbound SSE stream of the Streamable HTTP transport (the connection stream or - * one session stream), with at most one attached {@link SseSubscriber} writing to a - * servlet async response. - * - *

    - * A mailbox, as in the Rust and TypeScript servers. Every event goes into one - * bounded queue owned by the stream. An attached subscriber removes an event only at the - * moment it writes it to the response, so an event is never owned by a subscriber: when a - * subscriber goes away (the client dropped, the server closed it for backpressure, a new - * GET took the stream over, or the container reported an error on another thread) nothing - * it had not written is lost or reordered, and the next subscriber continues from the - * same queue. All state changes happen under this stream's monitor, including the - * container's asynchronous callbacks, so a reconnect racing the old subscriber's close is - * serialised. Bytes already written to a connection that then died can still be lost; - * only event ids and {@code Last-Event-ID}, which the RFD defers, could close that. - *

    - * - * @author Kaiser Dandangi - */ -final class SseOutboundStream { - - private static final Logger logger = LoggerFactory.getLogger(SseOutboundStream.class); - - private static final byte[] SSE_KEEP_ALIVE_COMMENT = ": keep-alive\n\n".getBytes(StandardCharsets.UTF_8); - - // Commits the SSE response when there are no queued events, without emitting an ACP message. - private static final byte[] SSE_OPEN_COMMENT = ": connected\n\n".getBytes(StandardCharsets.UTF_8); - - private final int mailboxCapacity; - - private final int maxPendingSseEvents; - - /** Events not yet written to any subscriber, oldest first. Guarded by {@code this}. */ - private final ArrayDeque mailbox = new ArrayDeque<>(); - - /** Guarded by {@code this}. */ - private SseSubscriber current; - - /** Guarded by {@code this}. */ - private boolean closed; - - SseOutboundStream(int mailboxCapacity, int maxPendingSseEvents) { - this.mailboxCapacity = mailboxCapacity; - this.maxPendingSseEvents = maxPendingSseEvents; - } - - /** - * Queues an event and writes as much as the attached subscriber can take. - * @throws AcpConnectionException when the mailbox is full; the caller closes the - * connection rather than drop an event - */ - synchronized void push(String payload) { - if (closed) { - return; - } - if (mailbox.size() == mailboxCapacity) { - throw new AcpConnectionException("Outbound SSE replay buffer exceeded " + mailboxCapacity + " events"); - } - mailbox.addLast(payload); - if (current == null) { - return; - } - if (mailbox.size() > maxPendingSseEvents) { - // The attached client is not reading. Detach it; the events stay queued for the - // next GET, and a full mailbox then closes the connection. - logger.warn("Closing backpressured SSE subscriber after {} pending events", maxPendingSseEvents); - detach(current); - return; - } - current.drain(); - } - - synchronized void subscribe(AsyncContext asyncContext, HttpServletResponse response) throws IOException { - if (closed) { - // DELETE may close the connection after GET has started async processing. - // Complete that request instead of leaving its response open indefinitely. - asyncContext.complete(); - return; - } - // One subscriber per stream: a new GET takes the stream over from a previous - // one that the server may not yet know is dead (proxy drop, client restart). - // Rust and TypeScript answer 409 instead; taking over is friendlier to a - // reconnecting client and never duplicates events. - if (current != null) { - logger.debug("New SSE subscriber replaces the attached one"); - detach(current); - } - SseSubscriber subscriber = new SseSubscriber(asyncContext, response); - current = subscriber; - subscriber.start(); - subscriber.drain(); - } - - synchronized void keepAlive() { - if (!closed && current != null) { - current.sendKeepAlive(); - } - } - - synchronized void close() { - if (closed) { - return; - } - closed = true; - if (current != null) { - detach(current); - } - mailbox.clear(); - } - - /** Detaches a subscriber if it is still the attached one, and completes its response. */ - private synchronized void detach(SseSubscriber subscriber) { - if (subscriber.detached) { - return; - } - subscriber.detached = true; - if (current == subscriber) { - current = null; - } - try { - subscriber.asyncContext.complete(); - } - catch (IllegalStateException ignored) { - // already completed by the container - } - } - - /** - * Writes to one servlet async response. Holds no events of its own except comments; - * every method runs under the enclosing stream's monitor. - */ - private final class SseSubscriber implements AsyncListener, WriteListener { - - private final AsyncContext asyncContext; - - private final ServletOutputStream output; - - /** Comments (open, keep-alive) not yet written; written before queued events. */ - private final ArrayDeque comments = new ArrayDeque<>(); - - private boolean detached; - - private boolean flushPending; - - SseSubscriber(AsyncContext asyncContext, HttpServletResponse response) throws IOException { - this.asyncContext = asyncContext; - this.output = response.getOutputStream(); - } - - void start() { - asyncContext.addListener(this); - comments.addLast(SSE_OPEN_COMMENT); - output.setWriteListener(this); - } - - void sendKeepAlive() { - if (!detached && comments.isEmpty() && mailbox.isEmpty()) { - comments.addLast(SSE_KEEP_ALIVE_COMMENT); - drain(); - } - } - - void drain() { - synchronized (SseOutboundStream.this) { - if (detached) { - return; - } - try { - flushIfReady(); - while (!detached && output.isReady()) { - byte[] bytes = comments.pollFirst(); - String payload = null; - if (bytes == null) { - payload = mailbox.pollFirst(); - if (payload == null) { - break; - } - bytes = ("data: " + payload + "\n\n").getBytes(StandardCharsets.UTF_8); - } - try { - output.write(bytes); - } - catch (IOException | IllegalStateException e) { - if (payload != null) { - // The write failed, so the event did not go out: keep it first in line. - mailbox.addFirst(payload); - } - throw e; - } - flushPending = true; - } - flushIfReady(); - } - catch (IOException | IllegalStateException e) { - detach(this); - } - } - } - - private void flushIfReady() throws IOException { - if (flushPending && output.isReady()) { - output.flush(); - flushPending = false; - } - } - - @Override - public void onWritePossible() { - drain(); - } - - @Override - public void onError(Throwable error) { - detach(this); - } - - @Override - public void onComplete(AsyncEvent event) { - detach(this); - } - - @Override - public void onTimeout(AsyncEvent event) { - detach(this); - } - - @Override - public void onError(AsyncEvent event) { - detach(this); - } - - @Override - public void onStartAsync(AsyncEvent event) { - event.getAsyncContext().addListener(this); - } - - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java index 850e55b4..f9913945 100644 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransport.java @@ -4,58 +4,80 @@ package com.agentclientprotocol.sdk.agent.transport; -import java.time.Duration; -import java.util.ArrayList; +import java.io.IOException; import java.util.List; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import com.agentclientprotocol.sdk.agent.AcpAgentFactory; import com.agentclientprotocol.sdk.error.AcpConnectionException; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.util.Assert; +import com.agentclientprotocol.sdk.util.VirtualThreads; import org.eclipse.jetty.ee10.servlet.ServletContextHandler; import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.HttpConnectionFactory; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; +import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; +import org.eclipse.jetty.util.thread.VirtualThreadPool; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; /** - * Listener-backed ACP Streamable HTTP transport for agents. + * A network server for ACP agents: runs an embedded Jetty server that accepts Streamable HTTP + * and WebSocket connections on one endpoint path ({@value #DEFAULT_ACP_PATH} by default), and + * creates a fresh agent for each connection with an {@link AcpAgentFactory}. Use it to run an + * agent as a network service from a plain Java program; to mount the endpoint in a servlet + * container you already run (Spring Boot, Tomcat), use {@link StreamableHttpAcpServlet} + * instead, and to serve the one client that launched the agent process, + * {@link StdioAcpAgentTransport}. * - *

    - * This transport hosts the ACP Streamable HTTP endpoint on Jetty, including POST/SSE - * request handling and WebSocket upgrades on the same path. It creates one fresh agent - * runtime per remote ACP connection through {@link AcpAgentFactory}. The accepted - * connection then owns its own per-connection {@link RemoteAcpConnection}, while the - * listener remains responsible only for wire-level concerns such as headers, SSE - * streams, WebSocket frames, and request routing. - *

    + *
    {@code
    + * AcpAgentFactory agents = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport)
    + *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
    + *     .build());
    + * var server = new StreamableHttpAcpAgentTransport(8080, AcpJsonMapper.createDefault(), agents);
    + * server.start().block(); // http://localhost:8080/acp and ws://localhost:8080/acp
    + * }
    * - *

    - * WebSocket support is intentionally hosted here instead of as a separate public - * listener so one {@code /acp} endpoint can behave like the RFD and the Rust - * {@code AcpHttpServer}: HTTP requests fall through to the servlet, while valid - * WebSocket upgrade requests are accepted by Jetty's {@link WebSocketUpgradeHandler}. - *

    + *

    Unlike the stdio transport, it is not an + * {@link com.agentclientprotocol.sdk.spec.AcpAgentTransport} and is not passed to an agent + * builder; it works the other way round. Each connection, opened by an + * {@code initialize} POST or a WebSocket upgrade, gets a connection-bound transport of its own + * ({@link RemoteAcpConnection}), and the factory builds that connection's agent on it. The + * agent lives until the client sends {@code DELETE} or closes the WebSocket, or the listener + * closes. Agents from one factory run concurrently, so whatever they share must be + * thread-safe. Clients connect with {@code StreamableHttpAcpClientTransport} or + * {@code WebSocketAcpClientTransport} from {@code acp-core}, or with another SDK's client. * - *

    - * The wire work is split across package collaborators: {@link StreamableHttpAcpServlet} - * (POST/GET/DELETE), {@link StreamableHttpConnection} (one POST/SSE connection and its - * scope routing), {@link SseOutboundStream} (mailbox and SSE subscriber), - * {@link StreamableHttpWebSocketConnection} (one upgraded connection) and - * {@link StreamableHttpRouting} (method-to-stream rules). This class owns the Jetty - * server, the connection registries, keep-alive and shutdown. - *

    + *

    It listens on the loopback interface only by default ({@code 127.0.0.1}, and {@code ::1} + * where the machine has IPv6), over plain HTTP/1.1 and cleartext HTTP/2 (h2c), so only + * programs on the same machine can connect. It has no authentication of its own: anyone who + * can reach the port can start an agent. Exposing it to the network is an explicit opt-in, + * {@link StreamableHttpAcpAgentTransportOptions.Builder#host host("0.0.0.0")} (or a single + * address); do so only behind a proxy or firewall that controls who connects, and for TLS put + * a proxy that terminates it in front, or mount the servlet in a container that has TLS. + * Browser requests from an origin other than a loopback one are refused (403) unless listed + * in {@link StreamableHttpAcpAgentTransportOptions.Builder#allowedOrigins allowedOrigins}. + * Close it with {@link #closeGracefully()}; it registers no JVM shutdown hook. + * + *

    On JDK 21 and later it serves on Jetty's {@code VirtualThreadPool}: every task on a + * virtual thread ({@code acp-listener-*}), or on the application's executor when the options + * name one ({@link StreamableHttpAcpAgentTransportOptions.Builder#executor}), and its timer on a + * virtual thread too; Jetty parks one platform thread + * ({@code jetty-virtual-thread-pool-keepalive}) while the server runs. Before JDK 21, or with + * {@link StreamableHttpAcpAgentTransportOptions.Builder#virtualThreads virtualThreads(false)}, + * it serves on Jetty's default pool of platform threads ({@code qtp*}) and its scheduler + * thread. * * @author Kaiser Dandangi */ @@ -63,57 +85,72 @@ public class StreamableHttpAcpAgentTransport { private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpAgentTransport.class); + /** + * The endpoint path used when none is given: {@value}, the path that ACP's remote + * transport RFD names. + */ public static final String DEFAULT_ACP_PATH = "/acp"; - static final String HEADER_CONNECTION_ID = "Acp-Connection-Id"; - - static final String HEADER_SESSION_ID = "Acp-Session-Id"; - - static final String CONTENT_TYPE_EVENT_STREAM = "text/event-stream"; - - static final Duration INITIALIZE_TIMEOUT = Duration.ofSeconds(30); - private final int configuredPort; private final String path; - private final AcpJsonMapper jsonMapper; - - private final AcpAgentFactory agentFactory; - private final StreamableHttpAcpAgentTransportOptions options; private final StreamableHttpAcpServlet servlet; - private final ConcurrentMap webSocketConnections = - new ConcurrentHashMap<>(); - private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean closing = new AtomicBoolean(false); private final Sinks.One terminationSink = Sinks.one(); - private volatile Server server; + private volatile @Nullable Server server; + + /** The timer of a server on the application's executor; Jetty does not stop it. */ + private volatile @Nullable ScheduledThreadPoolExecutor ownTimer; - private volatile ServerConnector connector; + /** The port the listener bound when it started; 0 until then. */ + private volatile int boundPort; + + /** + * Creates a listener on the default path, {@value #DEFAULT_ACP_PATH}, with the default limits + * and JSON mapper ({@link AcpJsonMapper#createDefault()}). Nothing listens until + * {@link #start()}. + * @param port the port to listen on, or 0 for an ephemeral port chosen when the listener + * starts + * @param agentFactory creates the agent for each connection + * @throws IllegalArgumentException if the port is outside 0 to 65535 or + * {@code agentFactory} is null + */ + public StreamableHttpAcpAgentTransport(int port, AcpAgentFactory agentFactory) { + this(port, AcpJsonMapper.createDefault(), agentFactory); + } /** - * Creates a new Streamable HTTP listener on the default ACP path. - * @param port port to listen on - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory factory used to create one agent runtime per connection + * Creates a listener on the default path, {@value #DEFAULT_ACP_PATH}, with the default + * limits. Nothing listens until {@link #start()}. + * @param port the port to listen on, or 0 for an ephemeral port chosen when the listener + * starts (see {@link #getPort()}) + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @throws IllegalArgumentException if the port is outside 0 to 65535 or an argument is + * null */ public StreamableHttpAcpAgentTransport(int port, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { this(port, DEFAULT_ACP_PATH, jsonMapper, agentFactory); } /** - * Creates a new Streamable HTTP listener. - * @param port port to listen on - * @param path endpoint path - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory factory used to create one agent runtime per connection + * Creates a listener on the given path, with the default limits. Nothing listens until + * {@link #start()}. + * @param port the port to listen on, or 0 for an ephemeral port chosen when the listener + * starts (see {@link #getPort()}) + * @param path the endpoint path, such as {@code /acp} + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @throws IllegalArgumentException if the port is outside 0 to 65535, the path is empty + * or an argument is null */ public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { @@ -121,31 +158,39 @@ public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper json } /** - * Creates a new Streamable HTTP listener with explicit limits. - * @param port port to listen on - * @param path endpoint path - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory factory used to create one agent runtime per connection - * @param options bounds and timings + * Creates a listener on the given path, with the limits and timings of {@code options}. + * Nothing listens until {@link #start()}. + * @param port the port to listen on, or 0 for an ephemeral port chosen when the listener + * starts (see {@link #getPort()}) + * @param path the endpoint path, such as {@code /acp} + * @param jsonMapper the mapper that reads and writes the messages + * @param agentFactory creates the agent for each connection + * @param options the endpoint's limits and timings + * @throws IllegalArgumentException if the port is outside 0 to 65535, the path is empty, + * an argument is null, or the options name an executor before JDK 21 */ public StreamableHttpAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, StreamableHttpAcpAgentTransportOptions options) { - Assert.isTrue(port > 0, "Port must be positive"); + Assert.isTrue(port >= 0 && port <= 65535, "Port must be between 0 and 65535, 0 for an ephemeral port"); Assert.hasText(path, "Path must not be empty"); Assert.notNull(jsonMapper, "The JsonMapper can not be null"); Assert.notNull(agentFactory, "The agentFactory can not be null"); Assert.notNull(options, "The options can not be null"); + Assert.isTrue(options.executor() == null || VirtualThreads.isSupported(), + "A listener on an executor of the application's needs JDK 21 or later (Jetty's VirtualThreadPool)"); this.configuredPort = port; this.path = path; - this.jsonMapper = jsonMapper; - this.agentFactory = agentFactory; this.options = options; this.servlet = new StreamableHttpAcpServlet(jsonMapper, agentFactory, options); } /** - * Starts the embedded Jetty server. - * @return a mono that completes when the listener is ready + * Starts the embedded Jetty server and binds the port, on the thread that subscribes to + * the returned Mono. A listener whose start failed, for instance because the port is in + * use, cannot be started again. + * @return a Mono that completes once the listener accepts connections; it errors with an + * {@link IllegalStateException} if the listener was started before, and with Jetty's + * exception if the server cannot start */ public Mono start() { return Mono.fromCallable(() -> { @@ -154,81 +199,165 @@ public Mono start() { if (!started.compareAndSet(false, true)) { throw new IllegalStateException("Already started"); } - Server jettyServer = new Server(); - HttpConfiguration httpConfig = new HttpConfiguration(); - HTTP2CServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig); - // Every SSE stream is a long-lived HTTP/2 stream: one ACP client holds one per - // session plus the connection stream. Jetty's default of 128 per connection - // is reached by a client with ~120 sessions, and the server then answers with - // GOAWAY, which takes down every exchange on the connection. - h2c.setMaxConcurrentStreams(options.maxConcurrentStreamsPerConnection()); - ServerConnector jettyConnector = new ServerConnector(jettyServer, - new HttpConnectionFactory(httpConfig), h2c); - jettyConnector.setPort(configuredPort); - jettyServer.addConnector(jettyConnector); - - ServletContextHandler context = new ServletContextHandler(); - context.setContextPath("/"); - ServletHolder holder = new ServletHolder(servlet); - holder.setAsyncSupported(true); - context.addServlet(holder, path); - - WebSocketUpgradeHandler webSocketHandler = WebSocketUpgradeHandler.from(jettyServer, context, container -> { - container.setIdleTimeout(Duration.ofMinutes(30)); - container.addMapping(path, (request, response, callback) -> { - StreamableHttpWebSocketConnection connection = createWebSocketConnection(); - try { - connection.start(); - webSocketConnections.put(connection.id(), connection); - response.getHeaders().put(HEADER_CONNECTION_ID, connection.id()); - return new StreamableHttpWebSocketConnection.AcpWebSocketEndpoint(connection, jsonMapper); - } - catch (Exception e) { - connection.close(); - callback.failed(e); - return null; - } - }); - }); - context.insertHandler(webSocketHandler); - jettyServer.setHandler(context); - - jettyServer.start(); + Server jettyServer = createServer(); + List connectors; + try { + connectors = openConnectors(jettyServer); + } + catch (Exception e) { + stopOwnTimer(); + throw e; + } + connectors.forEach(jettyServer::addConnector); + jettyServer.setHandler(createContext()); + + try { + jettyServer.start(); + } + catch (Exception e) { + connectors.forEach(ServerConnector::close); + stopOwnTimer(); + throw e; + } this.server = jettyServer; - this.connector = jettyConnector; - logger.info("Streamable HTTP agent listener started on port {} at path {}", getPort(), path); + this.boundPort = connectors.get(0).getLocalPort(); + logger.info("Streamable HTTP agent listener started on {} port {} at path {}", + options.host() != null ? options.host() : "loopback", getPort(), path); return null; }).then(); } /** - * Returns the bound port. - * @return listener port + * Before JDK 21, Jetty's default server. From JDK 21, a server on Jetty's + * {@link VirtualThreadPool}, every task on a virtual thread of its own or on the + * application's executor, and a timer on a virtual thread of the listener's. + */ + private Server createServer() { + Executor executor = options.executor(); + if (!VirtualThreads.isSupported() || !options.virtualThreads()) { + return new Server(); + } + VirtualThreadPool threadPool = new VirtualThreadPool(); + if (executor != null) { + // Jetty does not shut down an executor it was given. + threadPool.setVirtualThreadsExecutor(executor); + } + else { + threadPool.setName("acp-listener"); + } + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1, + VirtualThreads.factoryOrDaemon("acp-listener-timer")); + timer.setRemoveOnCancelPolicy(true); + this.ownTimer = timer; + // Given its executor, Jetty's scheduler does not shut it down either: stopServer does. + return new Server(threadPool, new ScheduledExecutorScheduler(timer), null); + } + + private void stopOwnTimer() { + ScheduledThreadPoolExecutor timer = this.ownTimer; + if (timer != null) { + timer.shutdownNow(); + } + } + + /** + * The connectors, bound before the server starts: one on the configured host, or by default + * one on {@code 127.0.0.1} and, where the machine has IPv6, one on {@code ::1} on the same + * port. Binding first gives the IPv6 connector the port an ephemeral IPv4 one was given, and + * lets a machine without IPv6 run on IPv4 alone. + */ + private List openConnectors(Server jettyServer) throws IOException { + String host = options.host(); + ServerConnector primary = createConnector(jettyServer, host != null ? host : "127.0.0.1", configuredPort); + primary.open(); + if (host != null) { + return List.of(primary); + } + ServerConnector ipv6 = createConnector(jettyServer, "::1", primary.getLocalPort()); + try { + ipv6.open(); + return List.of(primary, ipv6); + } + catch (IOException e) { + logger.debug("Not listening on ::1 (port {}): {}", primary.getLocalPort(), e.getMessage()); + ipv6.close(); + return List.of(primary); + } + } + + /** HTTP/1.1 and cleartext HTTP/2 on one address and port. */ + private ServerConnector createConnector(Server jettyServer, String host, int port) { + HttpConfiguration httpConfig = new HttpConfiguration(); + HTTP2CServerConnectionFactory h2c = new HTTP2CServerConnectionFactory(httpConfig); + // Every SSE stream is a long-lived HTTP/2 stream: one ACP client holds one per + // session plus the connection stream. Jetty's default of 128 per connection + // is reached by a client with ~120 sessions, and the server then answers with + // GOAWAY, which takes down every exchange on the connection. + h2c.setMaxConcurrentStreams(options.maxConcurrentStreamsPerConnection()); + ServerConnector jettyConnector = new ServerConnector(jettyServer, new HttpConnectionFactory(httpConfig), h2c); + jettyConnector.setHost(host); + jettyConnector.setPort(port); + return jettyConnector; + } + + /** + * The servlet at the endpoint path, on a context with Jetty's Jakarta WebSocket + * implementation, which the servlet upgrades requests with: one host, the same one an + * application's own container runs. + */ + private ServletContextHandler createContext() { + ServletContextHandler context = new ServletContextHandler(); + context.setContextPath("/"); + ServletHolder holder = new ServletHolder(servlet); + holder.setAsyncSupported(true); + context.addServlet(holder, path); + JakartaWebSocketServletContainerInitializer.configure(context, null); + return context; + } + + /** + * Sets the handler for the transport errors of every remote connection this listener + * holds, over HTTP/SSE and WebSocket alike, including those opened before the call. The + * default logs them. An agent factory may still install its own handler on the + * transport it is given. + * @param handler receives the connections' transport errors + */ + public void setExceptionHandler(Consumer handler) { + servlet.setExceptionHandler(handler); + } + + /** + * Returns the port the listener accepts connections on. Before {@link #start()} completes + * this is the configured port, which is 0 for an ephemeral port; once started it is the + * port actually bound (the OS-chosen one for 0), and it stays that after the listener + * closes. + * @return the bound port once started, otherwise the configured port */ public int getPort() { - ServerConnector currentConnector = this.connector; - return currentConnector != null ? currentConnector.getLocalPort() : configuredPort; + int bound = this.boundPort; + return bound > 0 ? bound : configuredPort; } /** - * Closes all active connections and stops the listener. - * @return a mono that completes when shutdown finishes + * Closes every connection, HTTP and WebSocket alike, as + * {@link StreamableHttpAcpServlet#closeGracefully()} does, then stops Jetty and completes + * {@link #awaitTermination()}. It waits for each WebSocket's going-away close (1001) to + * reach the client, and for nothing else from a client; a connection whose agent or socket + * has not closed within the + * {@linkplain StreamableHttpAcpAgentTransportOptions#shutdownTimeout() shutdown timeout} + * is closed at once. The listener registers no JVM shutdown hook; an application stops it + * by calling this method. Only the first call has an effect. + * @return a Mono that completes when the listener has stopped */ public Mono closeGracefully() { return Mono.defer(() -> { if (!closing.compareAndSet(false, true)) { - return Mono.empty(); + return Mono.empty(); } - List> connectionClosures = new ArrayList<>(); - connectionClosures.add(servlet.closeGracefully()); - webSocketConnections.values().forEach(connection -> connectionClosures.add(connection.closeGracefully())); - webSocketConnections.clear(); - - return Mono.whenDelayError(connectionClosures) + // The servlet's close is bounded by the shutdown timeout. + return servlet.closeGracefully() .then(Mono.fromRunnable(this::stopServer)) - .doOnSuccess(ignored -> { - terminationSink.tryEmitValue(null); - }); + .doOnSuccess(ignored -> terminationSink.tryEmitValue(null)); }); } @@ -241,25 +370,22 @@ private void stopServer() { catch (Exception e) { throw new AcpConnectionException("Failed to stop Streamable HTTP listener", e); } + finally { + stopOwnTimer(); + } } } /** - * Returns a mono that completes once the listener terminates. - * @return termination mono + * Returns a Mono that completes once {@link #closeGracefully()} has stopped the listener. + * @return a Mono that completes when the listener has stopped */ public Mono awaitTermination() { return terminationSink.asMono(); } int activeConnectionCount() { - return servlet.activeConnectionCount() + webSocketConnections.size(); - } - - private StreamableHttpWebSocketConnection createWebSocketConnection() { - String connectionId = UUID.randomUUID().toString(); - return new StreamableHttpWebSocketConnection(connectionId, jsonMapper, agentFactory, options, - connection -> webSocketConnections.remove(connection.id(), connection)); + return servlet.activeConnectionCount(); } } diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java deleted file mode 100644 index 4bb3c940..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportOptions.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.time.Duration; - -import com.agentclientprotocol.sdk.util.Assert; - -/** - * Limits and timings for {@link StreamableHttpAcpAgentTransport}. Every network-facing - * allocation is bounded by one of these so a slow or hostile client cannot grow memory - * without bound. - * - * @param maxPostBodyBytes largest accepted POST body; larger requests get 413 - * @param mailboxCapacity events retained per outbound stream while no subscriber is - * attached; overflow closes the connection - * @param maxPendingSseEvents events queued for one attached SSE subscriber before it is - * closed as backpressured - * @param maxWebSocketPendingFrames frames queued for one WebSocket connection before it is - * closed as backpressured - * @param maxProvisionalSessions session-scoped streams a connection may open before the - * session is known (the {@code session/load} pre-open case) - * @param keepAliveInterval interval between SSE keep-alive comments on attached streams; - * {@link Duration#ZERO} disables them - * @param maxConcurrentStreamsPerConnection HTTP/2 streams one client connection may hold open - * at once; each attached SSE stream holds one for its lifetime - * @author Mark Pollack - */ -public record StreamableHttpAcpAgentTransportOptions(long maxPostBodyBytes, int mailboxCapacity, - int maxPendingSseEvents, int maxWebSocketPendingFrames, int maxProvisionalSessions, - Duration keepAliveInterval, int maxConcurrentStreamsPerConnection) { - - private static final long DEFAULT_MAX_POST_BODY_BYTES = 16L * 1024 * 1024; - - private static final int DEFAULT_MAILBOX_CAPACITY = 1024; - - private static final int DEFAULT_MAX_PENDING_SSE_EVENTS = 1024; - - private static final int DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES = 1024; - - private static final int DEFAULT_MAX_PROVISIONAL_SESSIONS = 64; - - private static final Duration DEFAULT_KEEP_ALIVE_INTERVAL = Duration.ofSeconds(15); - - private static final int DEFAULT_MAX_CONCURRENT_STREAMS_PER_CONNECTION = 1024; - - public StreamableHttpAcpAgentTransportOptions { - Assert.isTrue(maxPostBodyBytes > 0, "maxPostBodyBytes must be positive"); - Assert.isTrue(mailboxCapacity > 0, "mailboxCapacity must be positive"); - Assert.isTrue(maxPendingSseEvents > 0, "maxPendingSseEvents must be positive"); - Assert.isTrue(maxWebSocketPendingFrames > 0, "maxWebSocketPendingFrames must be positive"); - Assert.isTrue(maxProvisionalSessions > 0, "maxProvisionalSessions must be positive"); - Assert.notNull(keepAliveInterval, "keepAliveInterval must not be null"); - Assert.isTrue(!keepAliveInterval.isNegative(), "keepAliveInterval must not be negative"); - Assert.isTrue(maxConcurrentStreamsPerConnection > 0, "maxConcurrentStreamsPerConnection must be positive"); - } - - public static StreamableHttpAcpAgentTransportOptions defaults() { - return builder().build(); - } - - public static Builder builder() { - return new Builder(); - } - - public static final class Builder { - - private long maxPostBodyBytes = DEFAULT_MAX_POST_BODY_BYTES; - - private int mailboxCapacity = DEFAULT_MAILBOX_CAPACITY; - - private int maxPendingSseEvents = DEFAULT_MAX_PENDING_SSE_EVENTS; - - private int maxWebSocketPendingFrames = DEFAULT_MAX_WEBSOCKET_PENDING_FRAMES; - - private int maxProvisionalSessions = DEFAULT_MAX_PROVISIONAL_SESSIONS; - - private Duration keepAliveInterval = DEFAULT_KEEP_ALIVE_INTERVAL; - - private int maxConcurrentStreamsPerConnection = DEFAULT_MAX_CONCURRENT_STREAMS_PER_CONNECTION; - - private Builder() { - } - - public Builder maxPostBodyBytes(long maxPostBodyBytes) { - this.maxPostBodyBytes = maxPostBodyBytes; - return this; - } - - public Builder mailboxCapacity(int mailboxCapacity) { - this.mailboxCapacity = mailboxCapacity; - return this; - } - - public Builder maxPendingSseEvents(int maxPendingSseEvents) { - this.maxPendingSseEvents = maxPendingSseEvents; - return this; - } - - public Builder maxWebSocketPendingFrames(int maxWebSocketPendingFrames) { - this.maxWebSocketPendingFrames = maxWebSocketPendingFrames; - return this; - } - - public Builder maxProvisionalSessions(int maxProvisionalSessions) { - this.maxProvisionalSessions = maxProvisionalSessions; - return this; - } - - public Builder keepAliveInterval(Duration keepAliveInterval) { - this.keepAliveInterval = keepAliveInterval; - return this; - } - - public Builder maxConcurrentStreamsPerConnection(int maxConcurrentStreamsPerConnection) { - this.maxConcurrentStreamsPerConnection = maxConcurrentStreamsPerConnection; - return this; - } - - public StreamableHttpAcpAgentTransportOptions build() { - return new StreamableHttpAcpAgentTransportOptions(maxPostBodyBytes, mailboxCapacity, maxPendingSseEvents, - maxWebSocketPendingFrames, maxProvisionalSessions, keepAliveInterval, - maxConcurrentStreamsPerConnection); - } - - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java deleted file mode 100644 index 4b354868..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServlet.java +++ /dev/null @@ -1,437 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.Optional; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicBoolean; - -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpConnection.UnknownSessionException; -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; -import com.agentclientprotocol.sdk.util.AcpSchedulers; -import com.agentclientprotocol.sdk.util.Assert; -import jakarta.servlet.AsyncContext; -import jakarta.servlet.AsyncEvent; -import jakarta.servlet.AsyncListener; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.Disposable; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; - -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.CONTENT_TYPE_EVENT_STREAM; -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.HEADER_CONNECTION_ID; -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.HEADER_SESSION_ID; -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.INITIALIZE_TIMEOUT; - -/** - * The HTTP side of the ACP Streamable HTTP endpoint: {@code POST} carries client - * messages (and {@code initialize}, which creates the connection), {@code GET} opens the - * connection or a session SSE stream, {@code DELETE} closes the connection. - * - *

    - * Mountable in any Servlet 6 container (Spring Boot, Tomcat, Jetty, Undertow) at - * the path of your choice; register it with async support enabled: - *

    - * - *
    {@code
    - * StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), agentFactory);
    - * ServletRegistration.Dynamic registration = servletContext.addServlet("acp", servlet);
    - * registration.addMapping("/acp");
    - * registration.setAsyncSupported(true);
    - * }
    - * - *

    - * The servlet owns its connections: {@link #init()} starts the SSE keep-alive and - * {@link #destroy()} closes every connection, cancelling in-flight prompts, so the - * container's lifecycle drives it. It serves the HTTP/SSE profile of the RFD; the - * WebSocket upgrade on the same path needs {@link StreamableHttpAcpAgentTransport}, which - * mounts this servlet on its own Jetty server next to the upgrade handler. HTTP/2 is the - * container's to configure. - *

    - * - * @author Kaiser Dandangi - */ -public class StreamableHttpAcpServlet extends HttpServlet { - - private static final Logger logger = LoggerFactory.getLogger(StreamableHttpAcpServlet.class); - - private static final String CONTENT_TYPE_JSON = "application/json"; - - private final transient AcpJsonMapper jsonMapper; - - private final transient StreamableHttpAcpAgentTransportOptions options; - - private final transient ConcurrentMap connections = new ConcurrentHashMap<>(); - - private final transient AcpAgentFactory agentFactory; - - private final transient StreamableHttpRouting routing; - - private final transient AtomicBoolean closing = new AtomicBoolean(false); - - private transient volatile Scheduler keepAliveScheduler; - - private transient volatile Disposable keepAliveTask; - - /** - * Creates a servlet with the default limits. - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory creates one agent runtime per remote connection - */ - public StreamableHttpAcpServlet(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory) { - this(jsonMapper, agentFactory, StreamableHttpAcpAgentTransportOptions.defaults()); - } - - /** - * Creates a servlet with explicit limits. - * @param jsonMapper JSON mapper used for serialization - * @param agentFactory creates one agent runtime per remote connection - * @param options bounds and timings; {@code maxConcurrentStreamsPerConnection} is the - * container's to configure when this servlet is mounted elsewhere - */ - public StreamableHttpAcpServlet(AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, - StreamableHttpAcpAgentTransportOptions options) { - Assert.notNull(jsonMapper, "The JsonMapper can not be null"); - Assert.notNull(agentFactory, "The agentFactory can not be null"); - Assert.notNull(options, "The options can not be null"); - this.jsonMapper = jsonMapper; - this.agentFactory = agentFactory; - this.options = options; - this.routing = new StreamableHttpRouting(jsonMapper); - } - - /** Starts the SSE keep-alive. Called by the container when the servlet is put into service. */ - @Override - public void init() throws ServletException { - super.init(); - Duration interval = options.keepAliveInterval(); - if (interval.isZero() || keepAliveTask != null) { - return; - } - Scheduler scheduler = Schedulers.newSingle("acp-streamable-http-keepalive", true); - this.keepAliveScheduler = scheduler; - // A comment every interval keeps proxies from cutting idle streams and surfaces - // dead subscribers (the write fails) without waiting for the next real event. - this.keepAliveTask = Flux.interval(interval, interval, scheduler) - .subscribe(tick -> connections.values().forEach(connection -> { - try { - connection.keepAlive(); - } - catch (RuntimeException e) { - // One broken connection must not stop keep-alives for every other one. - logger.debug("Keep-alive failed for connection {}: {}", connection.id(), e.getMessage()); - } - })); - } - - /** Closes every connection and stops the keep-alive. Called by the container on shutdown. */ - @Override - public void destroy() { - try { - closeGracefully().block(INITIALIZE_TIMEOUT); - } - catch (RuntimeException e) { - logger.warn("Streamable HTTP servlet did not close within {}: {}", INITIALIZE_TIMEOUT, e.getMessage()); - } - super.destroy(); - } - - /** - * Closes every connection this servlet holds, cancelling in-flight prompts, and stops - * the keep-alive. New requests are refused afterwards. - * @return a Mono that completes when every connection has closed - */ - public Mono closeGracefully() { - return Mono.defer(() -> { - if (!closing.compareAndSet(false, true)) { - return Mono.empty(); - } - Disposable task = this.keepAliveTask; - if (task != null) { - task.dispose(); - } - Scheduler scheduler = this.keepAliveScheduler; - if (scheduler != null) { - scheduler.dispose(); - } - List> closures = new ArrayList<>(); - connections.values().forEach(connection -> closures.add(connection.closeGracefully())); - connections.clear(); - return Mono.whenDelayError(closures); - }); - } - - /** - * The number of remote connections this servlet currently holds. - * @return open connections - */ - public int activeConnectionCount() { - return connections.size(); - } - - private StreamableHttpConnection createConnection() { - return new StreamableHttpConnection(UUID.randomUUID().toString(), jsonMapper, agentFactory, routing, options, - connection -> connections.remove(connection.id(), connection)); - } - - @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - if (!hasContentType(request, CONTENT_TYPE_JSON)) { - writeText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, - "Content-Type must be application/json"); - return; - } - - long declaredLength = request.getContentLengthLong(); - if (declaredLength > options.maxPostBodyBytes()) { - writeText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, - "POST body exceeds " + options.maxPostBodyBytes() + " bytes"); - return; - } - byte[] bodyBytes = request.getInputStream().readNBytes((int) Math.min(Integer.MAX_VALUE, - options.maxPostBodyBytes() + 1)); - if (bodyBytes.length > options.maxPostBodyBytes()) { - writeText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, - "POST body exceeds " + options.maxPostBodyBytes() + " bytes"); - return; - } - String body = new String(bodyBytes, StandardCharsets.UTF_8); - if (body.stripLeading().startsWith("[")) { - writeText(response, HttpServletResponse.SC_NOT_IMPLEMENTED, "JSON-RPC batches are not supported"); - return; - } - - JSONRPCMessage message; - try { - message = AcpSchema.deserializeJsonRpcMessage(jsonMapper, body); - } - catch (Exception e) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid JSON-RPC"); - return; - } - - if (StreamableHttpRouting.isInitialize(message)) { - handleInitialize(request, response, (AcpSchema.JSONRPCRequest) message); - return; - } - - String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); - if (connectionId == null) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); - return; - } - StreamableHttpConnection connection = connections.get(connectionId); - if (connection == null) { - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - return; - } - - try { - connection.acceptClientPost(message, header(request, HEADER_SESSION_ID).orElse(null)); - response.setStatus(HttpServletResponse.SC_ACCEPTED); - } - catch (UnknownSessionException e) { - writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); - } - catch (AcpConnectionException | IllegalArgumentException e) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, e.getMessage()); - } - } - - @Override - protected void doGet(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - if (!accepts(request, CONTENT_TYPE_EVENT_STREAM)) { - writeText(response, HttpServletResponse.SC_NOT_ACCEPTABLE, "client must accept text/event-stream"); - return; - } - - String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); - if (connectionId == null) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); - return; - } - StreamableHttpConnection connection = connections.get(connectionId); - if (connection == null) { - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - return; - } - - try { - connection.openStream(request, response, header(request, HEADER_SESSION_ID).orElse(null)); - } - catch (UnknownSessionException e) { - writeText(response, HttpServletResponse.SC_NOT_FOUND, e.getMessage()); - } - } - - @Override - protected void doDelete(HttpServletRequest request, HttpServletResponse response) - throws ServletException, IOException { - String connectionId = header(request, HEADER_CONNECTION_ID).orElse(null); - if (connectionId == null) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, HEADER_CONNECTION_ID + " header required"); - return; - } - StreamableHttpConnection connection = connections.remove(connectionId); - if (connection == null) { - response.setStatus(HttpServletResponse.SC_NOT_FOUND); - return; - } - connection.close(); - response.setStatus(HttpServletResponse.SC_ACCEPTED); - } - - private void handleInitialize(HttpServletRequest request, HttpServletResponse response, - AcpSchema.JSONRPCRequest initializeRequest) throws IOException { - if (header(request, HEADER_CONNECTION_ID).isPresent()) { - writeText(response, HttpServletResponse.SC_BAD_REQUEST, - "initialize must not include " + HEADER_CONNECTION_ID); - return; - } - - if (closing.get()) { - writeText(response, HttpServletResponse.SC_SERVICE_UNAVAILABLE, "ACP endpoint is shutting down"); - return; - } - StreamableHttpConnection connection = createConnection(); - AsyncContext asyncContext = request.startAsync(); - asyncContext.setTimeout(INITIALIZE_TIMEOUT.toMillis()); - AtomicBoolean completed = new AtomicBoolean(false); - asyncContext.addListener(new AsyncListener() { - - @Override - public void onComplete(AsyncEvent event) { - } - - @Override - public void onTimeout(AsyncEvent event) { - completeInitializeFailure(asyncContext, response, connection, completed); - } - - @Override - public void onError(AsyncEvent event) { - completeInitializeFailure(asyncContext, response, connection, completed); - } - - @Override - public void onStartAsync(AsyncEvent event) { - event.getAsyncContext().addListener(this); - } - - }); - - connection.start() - .then(Mono.defer(() -> connection.initialize(initializeRequest))) - .timeout(INITIALIZE_TIMEOUT, AcpSchedulers.timeouts()) - // Runs on the servlet thread: the request is already async, agent creation - // is cheap, and the handler runs on the agent's own scheduler. The SDK does - // not use the global boundedElastic scheduler. - .subscribe(initializeResponse -> completeInitializeSuccess(asyncContext, response, connection, - completed, initializeResponse), - error -> completeInitializeFailure(asyncContext, response, connection, completed)); - } - - private void completeInitializeSuccess(AsyncContext asyncContext, HttpServletResponse response, - StreamableHttpConnection connection, AtomicBoolean completed, JSONRPCMessage initializeResponse) { - if (!completed.compareAndSet(false, true)) { - return; - } - try { - if (!(initializeResponse instanceof AcpSchema.JSONRPCResponse)) { - throw new AcpConnectionException("initialize did not produce a JSON-RPC response"); - } - connections.put(connection.id(), connection); - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType(CONTENT_TYPE_JSON); - response.setHeader(HEADER_CONNECTION_ID, connection.id()); - response.getWriter().write(jsonMapper.writeValueAsString(initializeResponse)); - } - catch (Exception e) { - connections.remove(connection.id(), connection); - connection.close(); - try { - writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed"); - } - catch (IOException writeError) { - logger.warn("Failed to write Streamable HTTP initialize failure", writeError); - } - } - finally { - completeAsyncContext(asyncContext); - } - } - - private void completeInitializeFailure(AsyncContext asyncContext, HttpServletResponse response, - StreamableHttpConnection connection, AtomicBoolean completed) { - if (!completed.compareAndSet(false, true)) { - return; - } - connections.remove(connection.id(), connection); - connection.close(); - try { - writeText(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "initialize failed"); - } - catch (IOException writeError) { - logger.warn("Failed to write Streamable HTTP initialize failure", writeError); - } - finally { - completeAsyncContext(asyncContext); - } - } - - private void completeAsyncContext(AsyncContext asyncContext) { - try { - asyncContext.complete(); - } - catch (IllegalStateException ignored) { - } - } - - private boolean hasContentType(HttpServletRequest request, String expected) { - return Optional.ofNullable(request.getContentType()) - .map(String::toLowerCase) - .map(contentType -> contentType.split(";", 2)[0].trim()) - .filter(contentType -> contentType.equals(expected)) - .isPresent(); - } - - private boolean accepts(HttpServletRequest request, String expected) { - return Optional.ofNullable(request.getHeader("Accept")) - .map(String::toLowerCase) - .filter(accept -> accept.contains(expected)) - .isPresent(); - } - - private Optional header(HttpServletRequest request, String name) { - return Optional.ofNullable(request.getHeader(name)).filter(value -> !value.isBlank()); - } - - private void writeText(HttpServletResponse response, int status, String body) throws IOException { - response.setStatus(status); - response.setContentType("text/plain"); - response.getWriter().write(body); - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpConnection.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpConnection.java deleted file mode 100644 index c7dc0b85..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpConnection.java +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.io.IOException; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpRouting.ClientRequestRoute; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpRouting.RequestKind; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpRouting.ResolvedInboundRoute; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpRouting.RouteScope; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpRouting.SessionState; -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; -import jakarta.servlet.AsyncContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; - -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.CONTENT_TYPE_EVENT_STREAM; -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.HEADER_CONNECTION_ID; -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.HEADER_SESSION_ID; - -/** - * One remote ACP connection over Streamable HTTP (POST/SSE): its agent runtime, its - * connection stream and session streams, the sessions it knows (including provisional - * {@code session/load} sessions), and the request-id route maps that send each response - * back on the stream its request came from. - * - * @author Kaiser Dandangi - */ -final class StreamableHttpConnection { - - private static final Logger logger = LoggerFactory.getLogger(StreamableHttpConnection.class); - - - private final String id; - - private final RemoteAcpConnection connection; - - private final SseOutboundStream connectionStream; - - private final ConcurrentMap sessionStreams = new ConcurrentHashMap<>(); - - private final ConcurrentMap sessions = new ConcurrentHashMap<>(); - - // Client-originated request id -> route expected for the later agent response. - private final ConcurrentMap clientRequestRoutes = new ConcurrentHashMap<>(); - - // Agent-originated request id -> route required for the later client response. - private final ConcurrentMap agentRequestRoutes = new ConcurrentHashMap<>(); - - private final Sinks.One initializeResponse = Sinks.one(); - - private final AtomicBoolean initialized = new AtomicBoolean(false); - - private volatile Object initializeRequestId; - - private final AcpJsonMapper jsonMapper; - - private final AcpAgentFactory agentFactory; - - private final StreamableHttpRouting routing; - - private final StreamableHttpAcpAgentTransportOptions options; - - private final Consumer deregister; - - StreamableHttpConnection(String id, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, - StreamableHttpRouting routing, StreamableHttpAcpAgentTransportOptions options, - Consumer deregister) { - this.id = id; - this.jsonMapper = jsonMapper; - this.agentFactory = agentFactory; - this.routing = routing; - this.options = options; - this.deregister = deregister; - this.connectionStream = new SseOutboundStream(options.mailboxCapacity(), options.maxPendingSseEvents()); - this.connection = new RemoteAcpConnection(id, jsonMapper, this::routeAgentMessage); - } - - String id() { - return id; - } - - Mono start() { - return this.connection.start(agentFactory); - } - - Mono initialize(AcpSchema.JSONRPCRequest request) { - this.initializeRequestId = request.id(); - connection.acceptInbound(request); - return initializeResponse.asMono().doOnSuccess(ignored -> initialized.set(true)); - } - - void acceptClientPost(JSONRPCMessage message, String sessionHeader) { - if (message instanceof AcpSchema.JSONRPCResponse response) { - validateClientResponseScope(response, sessionHeader); - connection.acceptInbound(message); - return; - } - - ResolvedInboundRoute resolved = routing.resolveInboundRoute(message, sessionHeader); - if (resolved.requestScope().isSession()) { - prepareSessionForInbound(resolved.requestScope().sessionId(), resolved.requestRoute()); - } - if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null - && resolved.requestRoute() != null) { - clientRequestRoutes.put(request.id(), resolved.requestRoute()); - } - connection.acceptInbound(message); - } - - void openStream(HttpServletRequest request, HttpServletResponse response, String sessionId) - throws IOException { - RouteScope scope = sessionId == null ? RouteScope.connection() : RouteScope.session(sessionId); - SseOutboundStream stream; - if (scope.isSession()) { - stream = openSessionStream(scope.sessionId()); - } - else { - stream = connectionStream; - } - - response.setStatus(HttpServletResponse.SC_OK); - response.setContentType(CONTENT_TYPE_EVENT_STREAM); - response.setHeader("Cache-Control", "no-cache"); - response.setHeader(HEADER_CONNECTION_ID, id); - if (scope.isSession()) { - response.setHeader(HEADER_SESSION_ID, scope.sessionId()); - } - AsyncContext asyncContext = request.startAsync(); - asyncContext.setTimeout(0); - stream.subscribe(asyncContext, response); - } - - Mono closeGracefully() { - deregister.accept(this); - connectionStream.close(); - sessionStreams.values().forEach(SseOutboundStream::close); - return connection.closeGracefully(); - } - - void close() { - closeGracefully().subscribe(v -> { - }, error -> logger.warn("Error closing Streamable HTTP ACP connection {}", id, error)); - } - - private void routeAgentMessage(JSONRPCMessage message) { - try { - if (message instanceof AcpSchema.JSONRPCResponse response - && Objects.equals(response.id(), initializeRequestId) && !initialized.get()) { - initializeResponse.tryEmitValue(message); - return; - } - - RouteScope scope = resolveAgentOutboundScope(message); - String payload = jsonMapper.writeValueAsString(message); - if (scope.isSession()) { - sessionStream(scope.sessionId()).push(payload); - } - else { - connectionStream.push(payload); - } - } - catch (Exception e) { - connection.signalException(e); - close(); - } - } - - private RouteScope resolveAgentOutboundScope(JSONRPCMessage message) { - if (message instanceof AcpSchema.JSONRPCResponse response) { - ClientRequestRoute route = clientRequestRoutes.remove(response.id()); - if (route == null) { - logger.warn("Agent emitted response for unknown client request id {}; routing to connection stream", - response.id()); - return RouteScope.connection(); - } - if ((route.kind() == RequestKind.SESSION_NEW || route.kind() == RequestKind.SESSION_FORK) - && response.error() == null) { - // Both replies carry the id of a session that now exists on this connection. - String sessionId = routing.extractSessionIdFromNewSessionResponse(response); - markSessionKnown(sessionId); - } - if (route.kind() == RequestKind.SESSION_LOAD) { - if (response.error() == null) { - markSessionKnown(route.requestScope().sessionId()); - } - else { - discardProvisionalSession(route.requestScope().sessionId()); - } - } - return route.responseScope(); - } - - String method; - Object params; - Object id = null; - if (message instanceof AcpSchema.JSONRPCRequest request) { - method = request.method(); - params = request.params(); - id = request.id(); - } - else if (message instanceof AcpSchema.JSONRPCNotification notification) { - method = notification.method(); - params = notification.params(); - } - else { - throw new AcpConnectionException("Unsupported outbound JSON-RPC message type: " + message); - } - - RouteScope scope = routing.resolveAgentRequestOrNotificationScope(method, params); - if (id != null) { - agentRequestRoutes.put(id, scope); - } - return scope; - } - - private void prepareSessionForInbound(String sessionId, ClientRequestRoute route) { - SessionState current = sessions.get(sessionId); - if (route != null && route.kind() == RequestKind.SESSION_LOAD) { - if (current == null) { - addProvisionalSession(sessionId); - sessionStream(sessionId); - } - return; - } - if (current != SessionState.KNOWN) { - throw new UnknownSessionException("Unknown session " + sessionId); - } - } - - private void validateClientResponseScope(AcpSchema.JSONRPCResponse response, String sessionHeader) { - RouteScope expected = agentRequestRoutes.get(response.id()); - if (expected == null) { - logger.warn("Client posted response for unknown agent request id {}", response.id()); - return; - } - if (sessionHeader == null) { - // The RFD asks for Acp-Session-Id on permission responses; the Rust and Python - // clients omit it on every response. The id alone identifies the exchange, so a - // missing header is accepted. A header naming a different scope is still an error. - logger.debug("Client response {} carried no {}; accepting it for {}", response.id(), - StreamableHttpAcpAgentTransport.HEADER_SESSION_ID, expected); - agentRequestRoutes.remove(response.id(), expected); - return; - } - RouteScope actual = RouteScope.session(sessionHeader); - if (!Objects.equals(expected, actual)) { - throw new AcpConnectionException( - "Response id " + response.id() + " arrived on " + actual + " but expected " + expected); - } - agentRequestRoutes.remove(response.id(), expected); - } - - private SseOutboundStream openSessionStream(String sessionId) { - SessionState current = sessions.get(sessionId); - if (current == null) { - /* - * RFD gap: - * The current text says unknown session-scoped GET requests return 404, - * but its resume flow also asks clients to open a session stream before - * sending session/load. Keep a provisional stream so practical resume can work. - */ - addProvisionalSession(sessionId); - } - return sessionStream(sessionId); - } - - /** Provisional sessions are bounded: a client cannot grow state with arbitrary ids. */ - private synchronized void addProvisionalSession(String sessionId) { - long provisional = sessions.values().stream().filter(state -> state == SessionState.PENDING_LOAD).count(); - if (provisional >= options.maxProvisionalSessions() - && sessions.get(sessionId) != SessionState.PENDING_LOAD) { - throw new UnknownSessionException("Too many provisional sessions on connection " + id - + " (limit " + options.maxProvisionalSessions() + ")"); - } - sessions.putIfAbsent(sessionId, SessionState.PENDING_LOAD); - } - - /** A failed session/load leaves no provisional state behind. */ - private void discardProvisionalSession(String sessionId) { - if (sessions.remove(sessionId, SessionState.PENDING_LOAD)) { - SseOutboundStream stream = sessionStreams.remove(sessionId); - if (stream != null) { - stream.close(); - } - } - } - - void keepAlive() { - connectionStream.keepAlive(); - sessionStreams.values().forEach(SseOutboundStream::keepAlive); - } - - private SseOutboundStream sessionStream(String sessionId) { - return sessionStreams.computeIfAbsent(sessionId, - ignored -> new SseOutboundStream(options.mailboxCapacity(), options.maxPendingSseEvents())); - } - - private void markSessionKnown(String sessionId) { - sessions.put(sessionId, SessionState.KNOWN); - sessionStream(sessionId); - } - - static final class UnknownSessionException extends RuntimeException { - - UnknownSessionException(String message) { - super(message); - } - - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpRouting.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpRouting.java deleted file mode 100644 index 9cf3728c..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpRouting.java +++ /dev/null @@ -1,226 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.util.Map; -import java.util.Optional; - -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.json.TypeRef; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; - -/** - * Routing rules of the Streamable HTTP transport: which stream (connection or session) - * each ACP method travels on, and how session ids are read from params and headers. - * - *

    - * Stateless apart from the JSON mapper used to look into untyped params. Holds no - * connection state and has no servlet or Jetty dependency; per-connection bookkeeping - * lives in {@link StreamableHttpConnection}. - *

    - * - * @author Kaiser Dandangi - */ -final class StreamableHttpRouting { - - private final AcpJsonMapper jsonMapper; - - StreamableHttpRouting(AcpJsonMapper jsonMapper) { - this.jsonMapper = jsonMapper; - } - - enum ScopeKind { - - CONNECTION, - - SESSION - - } - - enum RequestKind { - - INITIALIZE, - - SESSION_NEW, - - SESSION_FORK, - - SESSION_LOAD, - - GENERIC - - } - - enum SessionState { - - PENDING_LOAD, - - KNOWN - - } - - record RouteScope(ScopeKind kind, String sessionId) { - - static RouteScope connection() { - return new RouteScope(ScopeKind.CONNECTION, null); - } - - static RouteScope session(String sessionId) { - return new RouteScope(ScopeKind.SESSION, sessionId); - } - - boolean isSession() { - return kind == ScopeKind.SESSION; - } - - } - - record ClientRequestRoute(RequestKind kind, RouteScope requestScope, RouteScope responseScope) { - } - - record ResolvedInboundRoute(JSONRPCMessage message, RouteScope requestScope, ClientRequestRoute requestRoute) { - } - - static boolean isInitialize(JSONRPCMessage message) { - return message instanceof AcpSchema.JSONRPCRequest request - && AcpSchema.METHOD_INITIALIZE.equals(request.method()); - } - - static boolean isInitializeRequest(JSONRPCMessage message) { - return message instanceof AcpSchema.JSONRPCRequest request - && AcpSchema.METHOD_INITIALIZE.equals(request.method()) && request.id() != null; - } - - RouteScope resolveAgentRequestOrNotificationScope(String method, Object params) { - switch (method) { - case AcpSchema.METHOD_SESSION_REQUEST_PERMISSION: - case AcpSchema.METHOD_SESSION_UPDATE: - case AcpSchema.METHOD_FS_READ_TEXT_FILE: - case AcpSchema.METHOD_FS_WRITE_TEXT_FILE: - case AcpSchema.METHOD_TERMINAL_CREATE: - case AcpSchema.METHOD_TERMINAL_OUTPUT: - case AcpSchema.METHOD_TERMINAL_RELEASE: - case AcpSchema.METHOD_TERMINAL_WAIT_FOR_EXIT: - case AcpSchema.METHOD_TERMINAL_KILL: - return RouteScope.session(requireSessionId(params, method)); - default: - Optional sessionId = extractSessionId(params); - return sessionId.map(RouteScope::session).orElseGet(RouteScope::connection); - } - } - - ResolvedInboundRoute resolveInboundRoute(JSONRPCMessage message, String sessionHeader) { - String method; - Object params; - if (message instanceof AcpSchema.JSONRPCRequest request) { - method = request.method(); - params = request.params(); - } - else if (message instanceof AcpSchema.JSONRPCNotification notification) { - method = notification.method(); - params = notification.params(); - } - else { - throw new AcpConnectionException("Unsupported inbound JSON-RPC message type: " + message); - } - - RouteScope requestScope; - RequestKind kind = RequestKind.GENERIC; - RouteScope responseScope; - - switch (method) { - case AcpSchema.METHOD_AUTHENTICATE: - case AcpSchema.METHOD_SESSION_NEW: - requestScope = RouteScope.connection(); - kind = AcpSchema.METHOD_SESSION_NEW.equals(method) ? RequestKind.SESSION_NEW : RequestKind.GENERIC; - responseScope = RouteScope.connection(); - break; - case AcpSchema.METHOD_SESSION_LOAD: - case AcpSchema.METHOD_SESSION_RESUME: - // The RFD is ambiguous here: its reconnect diagram sends the header, its text - // says load answers on the connection stream because the client has no session - // yet. The Python client omits it. Accept either; the id comes from params. - requestScope = sessionScopeFromParams(method, params, sessionHeader); - kind = RequestKind.SESSION_LOAD; - responseScope = RouteScope.connection(); - break; - case AcpSchema.METHOD_SESSION_FORK: - // Scoped to the parent session; the reply names the forked session. - requestScope = requireSessionScope(method, params, sessionHeader); - kind = RequestKind.SESSION_FORK; - responseScope = requestScope; - break; - case AcpSchema.METHOD_SESSION_PROMPT: - case AcpSchema.METHOD_SESSION_SET_MODE: - case AcpSchema.METHOD_SESSION_SET_MODEL: - case AcpSchema.METHOD_SESSION_CANCEL: - requestScope = requireSessionScope(method, params, sessionHeader); - responseScope = requestScope; - break; - default: - Optional sessionId = extractSessionId(params); - if (sessionId.isPresent()) { - requestScope = requireSessionScope(method, params, sessionHeader); - } - else { - requestScope = RouteScope.connection(); - } - responseScope = requestScope; - } - - ClientRequestRoute requestRoute = message instanceof AcpSchema.JSONRPCRequest - ? new ClientRequestRoute(kind, requestScope, responseScope) : null; - return new ResolvedInboundRoute(message, requestScope, requestRoute); - } - - /** Like {@link #requireSessionScope} but tolerates a missing header; a conflicting one is still rejected. */ - RouteScope sessionScopeFromParams(String method, Object params, String sessionHeader) { - if (sessionHeader == null) { - return RouteScope.session(requireSessionId(params, method)); - } - return requireSessionScope(method, params, sessionHeader); - } - - RouteScope requireSessionScope(String method, Object params, String sessionHeader) { - String sessionId = requireSessionId(params, method); - if (sessionHeader == null) { - throw new AcpConnectionException( - StreamableHttpAcpAgentTransport.HEADER_SESSION_ID + " header required for " + method); - } - if (!sessionId.equals(sessionHeader)) { - throw new AcpConnectionException("Header " + StreamableHttpAcpAgentTransport.HEADER_SESSION_ID - + " does not match params.sessionId"); - } - return RouteScope.session(sessionId); - } - - Optional extractSessionId(Object params) { - if (params == null) { - return Optional.empty(); - } - Map paramsMap = jsonMapper.convertValue(params, Map.class); - Object sessionId = paramsMap.get("sessionId"); - return sessionId == null ? Optional.empty() : Optional.of(sessionId.toString()); - } - - String requireSessionId(Object params, String method) { - return extractSessionId(params) - .filter(sessionId -> !sessionId.isBlank()) - .orElseThrow(() -> new AcpConnectionException("Missing sessionId for method " + method)); - } - - String extractSessionIdFromNewSessionResponse(AcpSchema.JSONRPCResponse response) { - AcpSchema.NewSessionResponse sessionResponse = jsonMapper.convertValue(response.result(), - new TypeRef() { - }); - if (sessionResponse.sessionId() == null || sessionResponse.sessionId().isBlank()) { - throw new AcpConnectionException("session/new response missing sessionId"); - } - return sessionResponse.sessionId(); - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketConnection.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketConnection.java deleted file mode 100644 index c4bb4aae..00000000 --- a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketConnection.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.nio.channels.ClosedChannelException; -import java.util.ArrayDeque; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import com.agentclientprotocol.sdk.error.AcpErrorCodes; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; -import org.eclipse.jetty.websocket.api.Callback; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.StatusCode; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; -import org.eclipse.jetty.websocket.api.annotations.WebSocket; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; - -import static com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport.INITIALIZE_TIMEOUT; - -/** - * One remote ACP connection upgraded to WebSocket on the Streamable HTTP endpoint: its - * agent runtime, the initialize-first rule, and a serialized, bounded frame sender. - * - * @author Kaiser Dandangi - */ -final class StreamableHttpWebSocketConnection { - - private static final Logger logger = LoggerFactory.getLogger(StreamableHttpWebSocketConnection.class); - - - private final String id; - - private final RemoteAcpConnection remoteConnection; - - private final AtomicBoolean initialized = new AtomicBoolean(false); - - private final AtomicBoolean closed = new AtomicBoolean(false); - - private final SerializedWebSocketSender outboundSender = new SerializedWebSocketSender(); - - private volatile Session session; - - private final AcpJsonMapper jsonMapper; - - private final AcpAgentFactory agentFactory; - - private final StreamableHttpAcpAgentTransportOptions options; - - private final Consumer deregister; - - StreamableHttpWebSocketConnection(String id, AcpJsonMapper jsonMapper, AcpAgentFactory agentFactory, - StreamableHttpAcpAgentTransportOptions options, Consumer deregister) { - this.id = id; - this.jsonMapper = jsonMapper; - this.agentFactory = agentFactory; - this.options = options; - this.deregister = deregister; - this.remoteConnection = new RemoteAcpConnection(id, jsonMapper, this::sendToClient); - } - - String id() { - return id; - } - - void start() { - this.remoteConnection.start(agentFactory).block(INITIALIZE_TIMEOUT); - } - - void open(Session session) { - this.session = session; - } - - void acceptFromClient(JSONRPCMessage message) { - if (!initialized.get()) { - // The WebSocket branch of the streamable endpoint has no POST - // initialize response that can create the connection first, so the first - // client-originated JSON-RPC message on the socket must be initialize. - if (!StreamableHttpRouting.isInitializeRequest(message)) { - close(StatusCode.PROTOCOL, "first ACP WebSocket message must be initialize"); - return; - } - initialized.set(true); - } - else if (message instanceof AcpSchema.JSONRPCRequest request - && AcpSchema.METHOD_INITIALIZE.equals(request.method())) { - sendToClient(new AcpSchema.JSONRPCResponse(AcpSchema.JSONRPC_VERSION, request.id(), null, - new AcpSchema.JSONRPCError(AcpErrorCodes.INVALID_REQUEST, - "Initialize not allowed on existing connection", null))); - return; - } - remoteConnection.acceptInbound(message); - } - - void sendToClient(JSONRPCMessage message) { - try { - String payload = jsonMapper.writeValueAsString(message); - logger.debug("Sending streamable ACP WebSocket message ({} characters)", payload.length()); - outboundSender.send(payload); - } - catch (Exception e) { - remoteConnection.signalException(e); - close(StatusCode.SERVER_ERROR, "failed to send ACP message"); - } - } - - Mono closeGracefully() { - return closeGracefully(StatusCode.NORMAL, "server closing"); - } - - private Mono closeGracefully(int statusCode, String reason) { - if (!closed.compareAndSet(false, true)) { - return Mono.empty(); - } - outboundSender.close(); - deregister.accept(this); - Session currentSession = this.session; - if (currentSession != null && currentSession.isOpen()) { - currentSession.close(statusCode, reason, Callback.NOOP); - } - return remoteConnection.closeGracefully(); - } - - void close() { - closeGracefully().subscribe(v -> { - }, error -> logger.warn("Error closing Streamable ACP WebSocket connection {}", id, error)); - } - - void close(int statusCode, String reason) { - closeGracefully(statusCode, reason).subscribe(v -> { - }, error -> logger.warn("Error closing Streamable ACP WebSocket connection {}", id, error)); - } - - void signalException(Throwable error) { - remoteConnection.signalException(error); - } - - private final class SerializedWebSocketSender { - - private final Object lock = new Object(); - - private final ArrayDeque queue = new ArrayDeque<>(); - - private boolean sendInProgress = false; - - void send(String payload) { - boolean shouldDrain; - synchronized (lock) { - if (closed.get()) { - throw new AcpConnectionException("Streamable ACP WebSocket connection is closed"); - } - if (queue.size() >= options.maxWebSocketPendingFrames()) { - throw new AcpConnectionException("WebSocket send queue exceeded " - + options.maxWebSocketPendingFrames() + " pending frames"); - } - queue.addLast(payload); - shouldDrain = !sendInProgress; - if (shouldDrain) { - sendInProgress = true; - } - } - if (shouldDrain) { - drain(); - } - } - - /* - * Jetty WebSocket sessions do not allow overlapping writes. Agent messages can - * be produced by concurrent prompt handlers, so this per-connection queue sends - * exactly one frame at a time and advances only after Jetty completes the - * callback for the previous frame. - */ - private void drain() { - String payload; - Session currentSession; - synchronized (lock) { - if (closed.get()) { - clear(); - return; - } - payload = queue.pollFirst(); - if (payload == null) { - sendInProgress = false; - return; - } - currentSession = session; - } - - if (currentSession == null || !currentSession.isOpen()) { - fail(new AcpConnectionException("Streamable ACP WebSocket connection is closed")); - return; - } - - try { - currentSession.sendText(payload, Callback.from(this::drain, this::fail)); - } - catch (Exception e) { - fail(e); - } - } - - private void fail(Throwable error) { - if (!closed.get()) { - remoteConnection.signalException(error); - StreamableHttpWebSocketConnection.this.close(StatusCode.SERVER_ERROR, "failed to send ACP message"); - } - } - - void close() { - clear(); - } - - private void clear() { - synchronized (lock) { - queue.clear(); - sendInProgress = false; - } - } - - } - - /** - * Jetty WebSocket endpoint for one WebSocket-upgraded ACP connection. - *

    - * Public because Jetty binds the annotated methods through - * {@code MethodHandles.publicLookup()}; a package-private endpoint class fails the - * upgrade with {@code IllegalAccessException}. - *

    - */ - @WebSocket - public static final class AcpWebSocketEndpoint { - - private final StreamableHttpWebSocketConnection connection; - - private final AcpJsonMapper jsonMapper; - - AcpWebSocketEndpoint(StreamableHttpWebSocketConnection connection, AcpJsonMapper jsonMapper) { - this.connection = connection; - this.jsonMapper = jsonMapper; - } - - @OnWebSocketOpen - public void onOpen(Session session) { - logger.info("Streamable ACP WebSocket client connected from {}", session.getRemoteSocketAddress()); - connection.open(session); - } - - @OnWebSocketMessage - public void onMessage(Session session, String message) { - logger.debug("Received streamable ACP WebSocket message ({} characters)", message.length()); - - try { - JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message); - connection.acceptFromClient(jsonRpcMessage); - } - catch (Exception e) { - logger.warn("Closing streamable ACP WebSocket connection after invalid JSON-RPC frame", e); - connection.close(StatusCode.PROTOCOL, "invalid JSON-RPC frame"); - } - } - - @OnWebSocketClose - public void onClose(Session session, int statusCode, String reason) { - logger.info("Streamable ACP WebSocket client disconnected: {} - {}", statusCode, reason); - connection.close(statusCode, reason); - } - - @OnWebSocketError - public void onError(Session session, Throwable error) { - if (error instanceof ClosedChannelException) { - logger.debug("Streamable ACP WebSocket channel closed"); - connection.close(StatusCode.NORMAL, "WebSocket channel closed"); - return; - } - logger.error("Streamable ACP WebSocket error", error); - connection.signalException(error); - connection.close(StatusCode.SERVER_ERROR, "WebSocket error"); - } - - } - -} diff --git a/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java new file mode 100644 index 00000000..b7ea41f3 --- /dev/null +++ b/acp-streamable-http-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * The Jetty Streamable HTTP agent transport (acp-streamable-http-jetty); this module's part of the package shared with acp-core. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.agent.transport; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/DefaultMapperConstructorsTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/DefaultMapperConstructorsTest.java new file mode 100644 index 00000000..d746fe4e --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/DefaultMapperConstructorsTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.ServerSocket; +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The HTTP and WebSocket transports, client and agent side, can be created without a JSON mapper + * argument: they use the default mapper, as the stdio transports do. + */ +class DefaultMapperConstructorsTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static AcpAgentFactory factory() { + return AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(r -> Mono.just(AcpSchema.InitializeResponse.ok())) + .promptHandler((r, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build()); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static void initialize(AcpSyncClient client) { + try { + assertThat(client.initialize().protocolVersion()).isEqualTo(AcpSchema.LATEST_PROTOCOL_VERSION); + } + finally { + client.close(); + } + } + + @Test + void theListenerAndBothClientTransportsUseTheDefaultMapper() throws Exception { + int port = freePort(); + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(port, factory()); + listener.start().block(TIMEOUT); + try { + initialize(AcpClient + .sync(new StreamableHttpAcpClientTransport(URI.create("http://127.0.0.1:" + port + "/acp"))) + .requestTimeout(TIMEOUT) + .build()); + initialize(AcpClient + .sync(new WebSocketAcpClientTransport(URI.create("ws://127.0.0.1:" + port + "/acp"))) + .requestTimeout(TIMEOUT) + .build()); + } + finally { + listener.closeGracefully().block(TIMEOUT); + } + } + + @Test + void theServletUsesTheDefaultMapper() throws Exception { + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(factory()); + Server server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setPort(0); + server.addConnector(connector); + ServletContextHandler context = new ServletContextHandler("/"); + ServletHolder holder = new ServletHolder(servlet); + holder.setAsyncSupported(true); + context.addServlet(holder, "/acp"); + server.setHandler(context); + server.start(); + try { + initialize(AcpClient + .sync(new StreamableHttpAcpClientTransport( + URI.create("http://127.0.0.1:" + connector.getLocalPort() + "/acp"))) + .requestTimeout(TIMEOUT) + .build()); + } + finally { + servlet.closeGracefully().block(TIMEOUT); + server.stop(); + } + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/JettyServletTckTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/JettyServletTckTest.java new file mode 100644 index 00000000..e4a1785c --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/JettyServletTckTest.java @@ -0,0 +1,58 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; + +/** + * The servlet host on Jetty 12.1 (EE 10), with Jetty's Jakarta WebSocket implementation, as an + * application's own Jetty runs it. + */ +class JettyServletTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) throws Exception { + Server server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setHost("127.0.0.1"); + connector.setPort(0); + server.addConnector(connector); + ServletContextHandler context = new ServletContextHandler("/"); + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(AcpJsonMapper.createDefault(), config.agents(), + config.options()); + ServletHolder holder = new ServletHolder(servlet); + holder.setAsyncSupported(true); + holder.setInitOrder(1); + context.addServlet(holder, "/acp"); + JakartaWebSocketServletContainerInitializer.configure(context, null); + server.setHandler(context); + server.start(); + URI endpoint = URI.create("http://127.0.0.1:" + connector.getLocalPort() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() throws Exception { + servlet.closeGracefully().block(Duration.ofSeconds(10)); + server.stop(); + } + + }; + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerBindTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerBindTest.java new file mode 100644 index 00000000..485ed578 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerBindTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.Optional; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * The listener binds the loopback interface unless told otherwise: an agent started with the + * defaults is not reachable from the network. Before, the connector set a port and no host, so + * Jetty bound every interface. + */ +class ListenerBindTest { + + static final AcpAgentFactory AGENTS = AcpAgentFactory.sync(transport -> AcpAgent.sync(transport) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .build()); + + @Test + void theDefaultListenerIsNotReachableOnANonLoopbackAddress() throws Exception { + Optional external = nonLoopbackAddress(); + assumeTrue(external.isPresent(), "this machine has no non-loopback address"); + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, AGENTS); + listener.start().block(Duration.ofSeconds(10)); + try { + assertThatThrownBy(() -> connect(external.get(), listener.getPort())).isInstanceOf(IOException.class); + // Still reachable where it should be. + connect(InetAddress.getLoopbackAddress(), listener.getPort()); + } + finally { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + } + + @Test + void theDefaultListenerAnswersOnLoopbackOverIpv4AndIpv6() throws Exception { + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, AGENTS); + listener.start().block(Duration.ofSeconds(10)); + try { + assertThat(get(URI.create("http://127.0.0.1:" + listener.getPort() + "/acp"))).isEqualTo(406); + if (ipv6LoopbackAvailable()) { + assertThat(get(URI.create("http://[::1]:" + listener.getPort() + "/acp"))).isEqualTo(406); + } + } + finally { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + } + + @Test + void anExplicitWildcardHostExposesTheListener() throws Exception { + Optional external = nonLoopbackAddress(); + assumeTrue(external.isPresent(), "this machine has no non-loopback address"); + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, "/acp", + AcpJsonMapper.createDefault(), AGENTS, + StreamableHttpAcpAgentTransportOptions.builder().host("0.0.0.0").build()); + listener.start().block(Duration.ofSeconds(10)); + try { + connect(external.get(), listener.getPort()); + } + finally { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + } + + @Test + void aNamedHostBindsThatAddressOnly() throws Exception { + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, "/acp", + AcpJsonMapper.createDefault(), AGENTS, + StreamableHttpAcpAgentTransportOptions.builder().host("127.0.0.1").build()); + listener.start().block(Duration.ofSeconds(10)); + try { + connect(InetAddress.getByName("127.0.0.1"), listener.getPort()); + if (ipv6LoopbackAvailable()) { + assertThatThrownBy(() -> connect(InetAddress.getByName("::1"), listener.getPort())) + .isInstanceOf(IOException.class); + } + } + finally { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + } + + @Test + void theHostDefaultsToLoopbackAndMustNotBeBlank() { + assertThat(StreamableHttpAcpAgentTransportOptions.defaults().host()).isNull(); + assertThatThrownBy(() -> StreamableHttpAcpAgentTransportOptions.builder().host(" ").build()) + .isInstanceOf(IllegalArgumentException.class); + } + + static void connect(InetAddress address, int port) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(address, port), 2000); + } + } + + private static int get(URI uri) throws Exception { + return HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(5)).GET().build(), + HttpResponse.BodyHandlers.discarding()) + .statusCode(); + } + + static boolean ipv6LoopbackAvailable() { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getByName("::1"))) { + return server.getLocalPort() > 0; + } + catch (IOException e) { + return false; + } + } + + /** An up, non-loopback address of this machine, IPv4 first; empty when it has none. */ + static Optional nonLoopbackAddress() throws SocketException { + InetAddress ipv6 = null; + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback()) { + continue; + } + for (InetAddress address : Collections.list(nic.getInetAddresses())) { + if (address.isLoopbackAddress() || address.isLinkLocalAddress()) { + continue; + } + if (address instanceof Inet4Address) { + return Optional.of(address); + } + if (address instanceof Inet6Address && ipv6 == null) { + ipv6 = address; + } + } + } + return Optional.ofNullable(ipv6); + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerTckTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerTckTest.java new file mode 100644 index 00000000..eb03f18f --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/ListenerTckTest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.time.Duration; + +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.test.http.AcpHttpTransportTck; + +/** + * The SDK's own listener, the embedded host: Jetty 12.1 running the servlet host with Jetty's + * Jakarta WebSocket implementation, on a socket it binds itself (loopback by default). + */ +class ListenerTckTest extends AcpHttpTransportTck { + + @Override + protected Host startHost(HostConfig config) { + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH, AcpJsonMapper.createDefault(), config.agents(), + config.options()); + listener.start().block(Duration.ofSeconds(10)); + URI endpoint = URI.create("http://127.0.0.1:" + listener.getPort() + "/acp"); + return new Host() { + + @Override + public URI endpoint() { + return endpoint; + } + + @Override + public void stop() { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + + }; + } + + @Override + protected boolean bindsItsOwnSocket() { + return true; + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginValidationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginValidationTest.java new file mode 100644 index 00000000..2af13319 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/OriginValidationTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.time.Duration; +import java.util.List; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.test.http.HttpProbes; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A browser page of another origin cannot use a local agent: the listener (its servlet for + * HTTP, its upgrade handler for WebSocket) answers a foreign {@code Origin} with 403. Before, + * any origin was served, so a web page the user visited could drive the agent on localhost. + */ +class OriginValidationTest { + + private static final String FOREIGN = "http://evil.example"; + + private static StreamableHttpAcpAgentTransport listener; + + private static URI endpoint; + + @BeforeAll + static void start() { + listener = new StreamableHttpAcpAgentTransport(0, AcpAgentFactory.sync(transport -> AcpAgent.sync(transport) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .promptHandler((request, context) -> AcpSchema.PromptResponse.endTurn()) + .build())); + listener.start().block(Duration.ofSeconds(10)); + endpoint = URI.create("http://127.0.0.1:" + listener.getPort() + "/acp"); + } + + @AfterAll + static void stop() { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + + @Test + void aForeignOriginIsRefusedOverHttp() throws Exception { + assertThat(HttpProbes.initialize(endpoint, FOREIGN).statusCode()).isEqualTo(403); + assertThat(HttpProbes.get(endpoint, FOREIGN)).isEqualTo(403); + } + + @Test + void aForeignOriginIsRefusedOnTheWebSocketHandshake() throws Exception { + assertThat(HttpProbes.webSocketHandshake(endpoint, FOREIGN)).isEqualTo(403); + } + + @ParameterizedTest + @ValueSource(strings = { "http://localhost:3000", "https://localhost", "http://127.0.0.1:8080", "http://[::1]:9" }) + void aLoopbackOriginIsServed(String origin) throws Exception { + assertThat(HttpProbes.initialize(endpoint, origin).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(endpoint, origin)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + + @Test + void noOriginIsServed() throws Exception { + assertThat(HttpProbes.initialize(endpoint, null).statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(endpoint, null)).isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + + @Test + void theListenerServesAListedOriginOverHttpAndWebSocket() throws Exception { + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, "/acp", + AcpJsonMapper.createDefault(), ListenerBindTest.AGENTS, + StreamableHttpAcpAgentTransportOptions.builder().allowedOrigins(List.of("https://app.example.com")).build()); + listener.start().block(Duration.ofSeconds(10)); + try { + URI endpoint = URI.create("http://127.0.0.1:" + listener.getPort() + "/acp"); + assertThat(HttpProbes.initialize(endpoint, "https://app.example.com").statusCode()).isEqualTo(200); + assertThat(HttpProbes.webSocketHandshake(endpoint, "https://app.example.com")) + .isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + assertThat(HttpProbes.initialize(endpoint, "https://other.example.com").statusCode()).isEqualTo(403); + assertThat(HttpProbes.webSocketHandshake(endpoint, "https://other.example.com")).isEqualTo(403); + } + finally { + listener.closeGracefully().block(Duration.ofSeconds(10)); + } + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStreamTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStreamTest.java deleted file mode 100644 index 6fa6279a..00000000 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/SseOutboundStreamTest.java +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; - -import com.agentclientprotocol.sdk.error.AcpConnectionException; -import jakarta.servlet.AsyncContext; -import jakarta.servlet.ServletOutputStream; -import jakarta.servlet.WriteListener; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * Deterministic tests of {@link SseOutboundStream}'s mailbox and backpressure rules. The - * servlet side is a mocked {@link AsyncContext} and response around a fake - * {@link ServletOutputStream} whose {@code isReady()} the test controls, so a - * backpressured subscriber can be created without depending on a real client's - * flow-control window. - */ -class SseOutboundStreamTest { - - private static final String OPEN = ": connected\n\n"; - - @Test - void eventsPushedWithoutSubscriberAreDeliveredToTheNextSubscriber() throws IOException { - SseOutboundStream stream = new SseOutboundStream(4, 4); - stream.push("{\"n\":1}"); - stream.push("{\"n\":2}"); - - Attached client = attach(stream, true); - - assertThat(client.output.written()).isEqualTo(OPEN + "data: {\"n\":1}\n\n" + "data: {\"n\":2}\n\n"); - - stream.push("{\"n\":3}"); - assertThat(client.output.written()) - .isEqualTo(OPEN + "data: {\"n\":1}\n\n" + "data: {\"n\":2}\n\n" + "data: {\"n\":3}\n\n"); - verify(client.asyncContext, never()).complete(); - } - - @Test - void mailboxOverflowThrows() { - SseOutboundStream stream = new SseOutboundStream(2, 4); - stream.push("a"); - stream.push("b"); - - assertThatThrownBy(() -> stream.push("c")).isInstanceOf(AcpConnectionException.class) - .hasMessageContaining("2 events"); - } - - @Test - void backpressuredSubscriberIsClosedAndItsUndeliveredEventsReachTheNextSubscriber() throws IOException { - SseOutboundStream stream = new SseOutboundStream(4, 2); - - // Never ready: the open comment and every event stay queued on the subscriber. - Attached slow = attach(stream, false); - stream.push("\"queued\""); - assertThat(slow.output.written()).isEmpty(); - verify(slow.asyncContext, never()).complete(); - - stream.push("\"second\""); - verify(slow.asyncContext, never()).complete(); - - // More than maxPendingSseEvents events are now waiting on a subscriber that does not - // read: it is detached, and every event stays queued for the next one. - stream.push("\"overflow\""); - verify(slow.asyncContext).complete(); - - // With no subscriber attached, later events are retained for the next one. - stream.push("\"later\""); - assertThat(slow.output.written()).isEmpty(); - - Attached next = attach(stream, true); - assertThat(next.output.written()) - .as("nothing the slow subscriber never wrote is lost, and order is kept") - .isEqualTo(OPEN + "data: \"queued\"\n\n" + "data: \"second\"\n\n" + "data: \"overflow\"\n\n" - + "data: \"later\"\n\n"); - verify(next.asyncContext, never()).complete(); - } - - /** Review probe P1: a subscriber that backs up during the replay must not lose the rest of it. */ - @Test - void replayIntoASubscriberThatCannotWriteLosesNothing() throws IOException { - SseOutboundStream stream = new SseOutboundStream(8, 2); - stream.push("\"a\""); - stream.push("\"b\""); - stream.push("\"c\""); - stream.push("\"d\""); - - attach(stream, false); - Attached next = attach(stream, true); - - assertThat(next.output.written()) - .isEqualTo(OPEN + "data: \"a\"\n\n" + "data: \"b\"\n\n" + "data: \"c\"\n\n" + "data: \"d\"\n\n"); - } - - /** - * Review probe P3: the container reports the old connection's error on its own thread - * after the client has already reconnected. The late close must not strand or reorder - * anything, and must not detach the new subscriber. - */ - @Test - void aLateErrorOnTheOldSubscriberDoesNotDisturbTheNewOne() throws IOException { - SseOutboundStream stream = new SseOutboundStream(8, 8); - Attached old = attach(stream, false); - stream.push("\"a\""); - stream.push("\"b\""); - - Attached reconnected = attach(stream, true); - stream.push("\"c\""); - old.output.listener.onError(new IOException("connection reset")); - stream.push("\"d\""); - - assertThat(reconnected.output.written()).isEqualTo( - OPEN + "data: \"a\"\n\n" + "data: \"b\"\n\n" + "data: \"c\"\n\n" + "data: \"d\"\n\n"); - verify(reconnected.asyncContext, never()).complete(); - } - - /** Review probe P2: an event pushed around a subscriber's close reaches the next subscriber. */ - @Test - void anEventPushedWhileTheSubscriberIsClosingIsKept() throws IOException { - SseOutboundStream stream = new SseOutboundStream(8, 8); - Attached first = attach(stream, false); - first.output.listener.onError(new IOException("gone")); - stream.push("\"x\""); - - Attached next = attach(stream, true); - assertThat(next.output.written()).isEqualTo(OPEN + "data: \"x\"\n\n"); - } - - @Test - void anEventWhoseWriteFailsIsKeptForTheNextSubscriber() throws IOException { - SseOutboundStream stream = new SseOutboundStream(8, 8); - Attached broken = attach(stream, true); - broken.output.failWrites = true; - stream.push("\"lost?\""); - - Attached next = attach(stream, true); - assertThat(next.output.written()).isEqualTo(OPEN + "data: \"lost?\"\n\n"); - } - - private static Attached attach(SseOutboundStream stream, boolean ready) throws IOException { - FakeOutput output = new FakeOutput(ready); - AsyncContext asyncContext = mock(AsyncContext.class); - HttpServletResponse response = mock(HttpServletResponse.class); - when(response.getOutputStream()).thenReturn(output); - stream.subscribe(asyncContext, response); - return new Attached(asyncContext, output); - } - - private record Attached(AsyncContext asyncContext, FakeOutput output) { - } - - private static final class FakeOutput extends ServletOutputStream { - - private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - - private final boolean ready; - - FakeOutput(boolean ready) { - this.ready = ready; - } - - String written() { - return bytes.toString(StandardCharsets.UTF_8); - } - - @Override - public boolean isReady() { - return ready; - } - - WriteListener listener; - - @Override - public void setWriteListener(WriteListener writeListener) { - this.listener = writeListener; - } - - boolean failWrites; - - @Override - public void write(int b) throws IOException { - if (failWrites) { - throw new IOException("broken pipe"); - } - bytes.write(b); - } - - } - -} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java index 0bae707a..eec9c257 100644 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportIntegrationTest.java @@ -17,7 +17,9 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -57,6 +59,7 @@ void delayedInitializePublishesConnectionOnlyAfterSuccess() throws Exception { return Mono.delay(Duration.ofMillis(250)).thenReturn(new AcpSchema.InitializeResponse( AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), null)); })) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build()); try (FixtureServer server = FixtureServer.start(agentFactory)) { HttpClient rawClient = HttpClient.newHttpClient(); @@ -98,6 +101,32 @@ void failedInitializeDoesNotPublishConnection() throws Exception { } } + /** + * A connection's transport errors reach the listener's exception handler. Before, the + * agent runtime installed none on the connection's transport, so they were only logged. + */ + @Test + void aConnectionErrorReachesTheListenerExceptionHandler() throws Exception { + AcpAgentFactory agentFactory = transport -> { + throw new IllegalStateException("agent creation failed"); + }; + List reported = new CopyOnWriteArrayList<>(); + try (FixtureServer server = FixtureServer.start(agentFactory)) { + server.transport.setExceptionHandler(reported::add); + HttpResponse response = HttpClient.newHttpClient() + .send(HttpRequest.newBuilder(server.endpoint()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init-failed","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """)) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + assertThat(response.statusCode()).isEqualTo(500); + assertThat(reported).singleElement().satisfies(error -> assertThat(error).hasMessage("agent creation failed")); + } + } + @Test void javaClientCanTalkToRunningJavaServer() throws Exception { try (FixtureServer server = FixtureServer.start()) { @@ -486,11 +515,21 @@ void deleteClosesSseAndRemovesConnection() throws Exception { } } + /** + * A GET for the connection stream racing the DELETE of its connection must not leave the + * stream open. Depending on where the DELETE lands, the GET is refused (404), or answered + * and then ended, or answered and then reset: the server completes the response, and a + * completion that finds the ": connected" write still unready makes Jetty cancel the + * HTTP/2 stream. The JDK client reports such a reset from the body read or, when the reset + * frame is processed before its body future completes, from {@code send()} itself. All + * three end the GET; a GET that is still open after the timeout fails the test. + */ @RepeatedTest(10) void concurrentSseOpenAndDeleteDoesNotLeaveStreamOpen() throws Exception { try (FixtureServer server = FixtureServer.start()) { HttpClient rawClient = HttpClient.newHttpClient(); String connectionId = initializeRaw(rawClient, server.endpoint()); + upgradeToHttp2(rawClient, server.endpoint()); ExecutorService executor = Executors.newFixedThreadPool(2); CountDownLatch start = new CountDownLatch(1); try { @@ -511,26 +550,8 @@ void concurrentSseOpenAndDeleteDoesNotLeaveStreamOpen() throws Exception { }); start.countDown(); - HttpResponse streamResponse = openStream.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); assertThat(delete.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS).statusCode()).isEqualTo(202); - assertThat(streamResponse.statusCode()).isIn(200, 404); - if (streamResponse.statusCode() == 200) { - try (InputStream body = streamResponse.body()) { - Future remainingBody = executor.submit(() -> { - try { - return body.readAllBytes(); - } - catch (IOException error) { - // HTTP/2 can cancel the deleted stream instead of ending it with EOF. - assertThat(error.getCause()).isInstanceOf(IOException.class) - .hasMessage("Received RST_STREAM: Stream cancelled"); - return new byte[0]; - } - }); - assertThat(new String(remainingBody.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), - StandardCharsets.UTF_8)).isIn("", ": connected\n\n"); - } - } + awaitEndOfRacedStream(openStream, executor); assertEventuallyPostStatus(rawClient, server.endpoint(), connectionId, 404); } finally { @@ -539,6 +560,58 @@ void concurrentSseOpenAndDeleteDoesNotLeaveStreamOpen() throws Exception { } } + /** + * Waits, bounded by {@link #TIMEOUT}, until the raced GET has ended: refused, ended, or + * reset by the server. Any other failure, a body other than the open comment, or a GET + * still open fails the test. + */ + private static void awaitEndOfRacedStream(Future> openStream, + ExecutorService executor) throws Exception { + HttpResponse response; + try { + response = openStream.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + catch (ExecutionException error) { + assertResetByServer(error.getCause()); + return; + } + if (response.statusCode() == 404) { + return; + } + assertThat(response.statusCode()).isEqualTo(200); + try (InputStream body = response.body()) { + Future remainingBody = executor.submit(body::readAllBytes); + try { + assertThat(new String(remainingBody.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS), + StandardCharsets.UTF_8)).isIn("", ": connected\n\n"); + } + catch (ExecutionException error) { + // The body stream fails with IOException("closed", ). + assertThat(error.getCause()).isInstanceOf(IOException.class); + assertResetByServer(error.getCause().getCause()); + } + } + } + + private static void assertResetByServer(Throwable error) { + assertThat(error).isInstanceOf(IOException.class).hasMessage("Received RST_STREAM: Stream cancelled"); + } + + /** + * The JDK client sends a POST over HTTP/1.1 without offering h2c, so after + * {@link #initializeRaw} it holds no HTTP/2 connection. A bodiless request upgrades one + * now, so that requests racing afterwards are streams of that one connection, as a real + * client's are, rather than concurrent h2c upgrades on separate connections. + */ + private static void upgradeToHttp2(HttpClient client, URI endpoint) throws Exception { + HttpResponse unknownConnection = client.send(HttpRequest.newBuilder(endpoint) + .header("Acp-Connection-Id", "no-such-connection") + .DELETE() + .build(), HttpResponse.BodyHandlers.discarding()); + assertThat(unknownConnection.statusCode()).isEqualTo(404); + assertThat(unknownConnection.version()).isEqualTo(HttpClient.Version.HTTP_2); + } + @Test void replayOverflowClosesConnectionInsteadOfDroppingMessages() throws Exception { try (FixtureServer server = FixtureServer.start()) { @@ -602,7 +675,7 @@ void forkedSessionIsUsable() throws Exception { .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(), null))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("sess-parent", null, null))) - .forkSessionHandler(request -> Mono.just(new AcpSchema.ForkSessionResponse("sess-forked", null, null))) + .forkSessionHandler(request -> Mono.just(new AcpSchema.ForkSessionResponse("sess-forked", null))) .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build()); try (FixtureServer server = FixtureServer.start(factory)) { @@ -740,6 +813,106 @@ void provisionalSessionsAreBounded() throws Exception { } } + /** + * The RFD's POST decision tree has no 404 for a session the connection does not know: a + * session-scoped POST with both headers is forwarded to the agent and accepted. The agent + * decides; session/delete of a session that never existed succeeds silently + * (session-delete.mdx). With no stream for that session, the reply comes on the + * connection stream. + */ + @Test + void sessionDeleteOfAnUnknownSessionIsForwardedAndAnsweredOnTheConnectionStream() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) { + HttpResponse accepted = postJson(rawClient, server.endpoint(), connectionId, "never-existed", + """ + {"jsonrpc":"2.0","id":"delete-unknown","method":"session/delete","params":{"sessionId":"never-existed"}} + """); + assertThat(accepted.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse response = connectionStream.nextResponse(); + assertThat(response.id()).isEqualTo("delete-unknown"); + assertThat(response.error()).isNull(); + } + } + } + + /** + * The RFD names no method as session-scoped; the Python client posts session/delete and + * session/fork without Acp-Session-Id, as it posts session/load. Like load, they are + * accepted without the header, routed by params.sessionId, and answered on the + * connection stream. Before, the server answered 400. + */ + @Test + void deleteAndForkWithoutTheSessionHeaderAreAnsweredOnTheConnectionStream() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + String sessionId = createSession(rawClient, server.endpoint(), connectionId); + try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) { + HttpResponse fork = postJson(rawClient, server.endpoint(), connectionId, null, """ + {"jsonrpc":"2.0","id":"fork-no-header","method":"session/fork","params":{"sessionId":"%s","cwd":"/workspace"}} + """.formatted(sessionId)); + assertThat(fork.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse forked = connectionStream.nextResponse(); + assertThat(forked.id()).isEqualTo("fork-no-header"); + assertThat(forked.error()).isNull(); + + HttpResponse delete = postJson(rawClient, server.endpoint(), connectionId, null, """ + {"jsonrpc":"2.0","id":"delete-no-header","method":"session/delete","params":{"sessionId":"%s"}} + """.formatted(sessionId)); + assertThat(delete.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse deleted = connectionStream.nextResponse(); + assertThat(deleted.id()).isEqualTo("delete-no-header"); + assertThat(deleted.error()).isNull(); + } + } + } + + /** A client that opened the unknown session's stream first gets the reply there. */ + @Test + void aPostForAnUnknownSessionWithAnOpenStreamIsAnsweredOnThatStream() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + try (SseReader sessionStream = SseReader.open(rawClient, server.endpoint(), connectionId, "unknown-1")) { + HttpResponse accepted = postJson(rawClient, server.endpoint(), connectionId, "unknown-1", + """ + {"jsonrpc":"2.0","id":"close-unknown","method":"session/close","params":{"sessionId":"unknown-1"}} + """); + assertThat(accepted.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse response = sessionStream.nextResponse(); + assertThat(response.id()).isEqualTo("close-unknown"); + assertThat(response.error()).isNull(); + } + } + } + + /** + * A POST whose body is a JSON object but no valid JSON-RPC request (JSON-RPC 2.0 section + * 4: method a string, id a string, number or null, jsonrpc "2.0") is accepted and + * answered -32600 on the connection stream, as the TypeScript server leaves an + * object-shaped body to the connection to validate. Before, it got a bare HTTP 400. + */ + @Test + void anInvalidRequestIsAnsweredInvalidRequestOnTheConnectionStream() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) { + HttpResponse accepted = postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":"bad-method","method":5} + """); + assertThat(accepted.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse response = connectionStream.nextResponse(); + assertThat(response.id()).isEqualTo("bad-method"); + assertThat(response.error().code()).isEqualTo(-32600); + } + } + } + private static HttpRequest sessionGet(URI endpoint, String connectionId, String sessionId) { return HttpRequest.newBuilder(endpoint) .header("Accept", "text/event-stream") @@ -761,16 +934,14 @@ void permissionResponseWithoutSessionHeaderIsAccepted() throws Exception { """ {"jsonrpc":"2.0","id":"prompt-perm","method":"session/prompt","params":{"sessionId":"%s","prompt":[{"type":"text","text":"permission please"}]}} """.formatted(sessionId)); - AcpSchema.JSONRPCMessage request = sessionStream.nextMessage(); - assertThat(request).isInstanceOf(AcpSchema.JSONRPCRequest.class); - String id = JSON_MAPPER.writeValueAsString(((AcpSchema.JSONRPCRequest) request).id()); + AcpSchema.JSONRPCRequest request = sessionStream.nextRequest(); + String id = JSON_MAPPER.writeValueAsString(request.id()); HttpResponse accepted = postJson(rawClient, server.endpoint(), connectionId, null, """ {"jsonrpc":"2.0","id":%s,"result":{"outcome":{"outcome":"selected","optionId":"allow"}}} """.formatted(id)); assertThat(accepted.statusCode()).as("response without Acp-Session-Id").isEqualTo(202); - sessionStream.nextMessage(); assertThat(sessionStream.nextResponse().id()).isEqualTo("prompt-perm"); } } @@ -795,6 +966,50 @@ void sessionLoadWithoutSessionHeaderIsAccepted() throws Exception { } } + /** + * JSON-RPC allows "id": null. The agent answers such a request with "id": null, and + * routing that answer must not take the connection down. + */ + @Test + void requestWithNullIdIsAnsweredAndTheConnectionSurvives() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + try (SseReader connectionStream = SseReader.open(rawClient, server.endpoint(), connectionId, null)) { + HttpResponse nullId = postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":null,"method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}} + """); + assertThat(nullId.statusCode()).isEqualTo(202); + AcpSchema.JSONRPCResponse nullIdResponse = connectionStream.nextResponse(); + assertThat(nullIdResponse.id()).isNull(); + + HttpResponse next = postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":"after-null","method":"session/new","params":{"cwd":"/workspace","mcpServers":[]}} + """); + assertThat(next.statusCode()).isEqualTo(202); + assertThat(connectionStream.nextResponse().id()).isEqualTo("after-null"); + } + } + } + + /** A client that cannot parse an agent request answers with "id": null (JSON-RPC 2.0, 5.1). */ + @Test + void clientErrorResponseWithNullIdIsAccepted() throws Exception { + try (FixtureServer server = FixtureServer.start()) { + HttpClient rawClient = HttpClient.newHttpClient(); + String connectionId = initializeRaw(rawClient, server.endpoint()); + HttpResponse accepted = postJson(rawClient, server.endpoint(), connectionId, null, + """ + {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}} + """); + assertThat(accepted.statusCode()).isEqualTo(202); + String sessionId = createSession(rawClient, server.endpoint(), connectionId); + assertThat(sessionId).isNotBlank(); + } + } + private static String initializeRaw(HttpClient client, URI endpoint) throws Exception { HttpResponse initialize = client.send(HttpRequest.newBuilder(endpoint) .header("Content-Type", "application/json") @@ -864,11 +1079,20 @@ static AcpAgentFactory defaultFactory() { AtomicInteger sessionCounter = new AtomicInteger(); AcpAgentFactory agentFactory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), null))) + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, + // what the handlers serve: a client calls only what is advertised + new AcpSchema.SessionCapabilities(java.util.Map.of(), null, java.util.Map.of(), java.util.Map.of(), + null, null), + new AcpSchema.McpCapabilities(), new AcpSchema.PromptCapabilities(), null, null, null), null))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( "sess-" + sessionCounter.incrementAndGet(), null, null))) .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null, null))) .resumeSessionHandler(request -> Mono.just(new AcpSchema.ResumeSessionResponse(null, null))) + // session/delete of any id succeeds, as docs/protocol/v1/session-delete.mdx asks + .deleteSessionHandler(request -> Mono.just(new AcpSchema.DeleteSessionResponse())) + .closeSessionHandler(request -> Mono.just(new AcpSchema.CloseSessionResponse())) + .forkSessionHandler(request -> Mono.just(new AcpSchema.ForkSessionResponse( + "sess-" + sessionCounter.incrementAndGet(), null))) .promptHandler((request, context) -> { Mono work = request.text().contains("permission") ? context.askPermission("fixture permission").then() @@ -951,12 +1175,25 @@ static SseReader open(HttpClient client, URI endpoint, String connectionId, Stri return new SseReader(response.body()); } + /** The next response, skipping the session updates before it. */ AcpSchema.JSONRPCResponse nextResponse() throws Exception { - return (AcpSchema.JSONRPCResponse) nextMessage(); + return (AcpSchema.JSONRPCResponse) nextNonNotification(); } + /** + * The next request, skipping the session updates before it (askPermission announces its + * tool call with a tool_call update first). + */ AcpSchema.JSONRPCRequest nextRequest() throws Exception { - return (AcpSchema.JSONRPCRequest) nextMessage(); + return (AcpSchema.JSONRPCRequest) nextNonNotification(); + } + + private AcpSchema.JSONRPCMessage nextNonNotification() throws Exception { + AcpSchema.JSONRPCMessage message = nextMessage(); + while (message instanceof AcpSchema.JSONRPCNotification) { + message = nextMessage(); + } + return message; } AcpSchema.JSONRPCMessage nextMessage() throws Exception { diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java index 7908322c..490a0b09 100644 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketIntegrationTest.java @@ -23,6 +23,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; @@ -38,8 +39,8 @@ import com.agentclientprotocol.sdk.client.AcpClient; import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; import com.agentclientprotocol.sdk.spec.AcpSchema; -import org.eclipse.jetty.websocket.api.StatusCode; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -61,7 +62,7 @@ void constructorValidatesRequiredArguments() { AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); AcpAgentFactory agentFactory = simpleAgentFactory(); - assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(0, jsonMapper, agentFactory)) + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(-1, jsonMapper, agentFactory)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Port"); assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(8080, "", jsonMapper, agentFactory)) @@ -109,15 +110,14 @@ void javaClientCanTalkToStreamableWebSocketUpgrade() throws Exception { try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { AcpAsyncClient client = AcpClient .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) - .sessionUpdateConsumer(update -> { + .sessionUpdateHandler(update -> { receivedUpdate.set(update); return Mono.empty(); }) .requestTimeout(TIMEOUT) .build(); try { - client.initialize(new AcpSchema.InitializeRequest( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + client.initialize() .block(TIMEOUT); AcpSchema.NewSessionResponse session = client .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) @@ -162,8 +162,7 @@ void permissionRequestRoundTripsOverStreamableWebSocketUpgrade() throws Exceptio .requestTimeout(TIMEOUT) .build(); try { - client.initialize(new AcpSchema.InitializeRequest( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + client.initialize() .block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); AcpSchema.PromptResponse prompt = client @@ -192,11 +191,9 @@ void supportsMultipleConcurrentWebSocketClients() throws Exception { .requestTimeout(TIMEOUT) .build(); try { - firstClient.initialize(new AcpSchema.InitializeRequest( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + firstClient.initialize() .block(TIMEOUT); - secondClient.initialize(new AcpSchema.InitializeRequest( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + secondClient.initialize() .block(TIMEOUT); AcpSchema.NewSessionResponse firstSession = firstClient @@ -235,15 +232,14 @@ void serializesConcurrentAgentMessagesOnOneWebSocketConnection() throws Exceptio try (FixtureServer server = FixtureServer.start(agentFactory)) { AcpAsyncClient client = AcpClient .async(new WebSocketAcpClientTransport(server.endpoint(), AcpJsonMapper.createDefault())) - .sessionUpdateConsumer(update -> { + .sessionUpdateHandler(update -> { receivedUpdates.incrementAndGet(); return Mono.empty(); }) .requestTimeout(TIMEOUT) .build(); try { - client.initialize(new AcpSchema.InitializeRequest( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.ClientCapabilities())) + client.initialize() .block(TIMEOUT); AcpSchema.NewSessionResponse firstSession = client .newSession(new AcpSchema.NewSessionRequest("/workspace/one", List.of())) @@ -285,11 +281,45 @@ void rejectsNonInitializeFirstMessage() throws Exception { """, true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); assertThat(listener.closeLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); - assertThat(listener.closeCode.get()).isEqualTo(StatusCode.PROTOCOL); + assertThat(listener.closeCode.get()).isEqualTo(1002); assertEventuallyNoConnections(server.transport()); } } + /** + * A frame that is not JSON is answered with -32700 and a null id, reported to the + * listener's exception handler, and skipped: the connection stays open and initialize + * still works. Before, the connection was closed. + */ + @Test + void aMalformedFrameIsAnsweredReportedAndSkipped() throws Exception { + List reported = new CopyOnWriteArrayList<>(); + try (FixtureServer server = FixtureServer.start(simpleAgentFactory())) { + server.transport().setExceptionHandler(reported::add); + MessageRecordingListener listener = new MessageRecordingListener(); + WebSocket webSocket = HttpClient.newHttpClient() + .newWebSocketBuilder() + .connectTimeout(TIMEOUT) + .buildAsync(server.endpoint(), listener) + .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(listener.openLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); + + webSocket.sendText("{not json", true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + AcpSchema.JSONRPCResponse answer = (AcpSchema.JSONRPCResponse) readJsonRpcMessage(listener); + webSocket.sendText(""" + {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """, true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + AcpSchema.JSONRPCResponse initialized = (AcpSchema.JSONRPCResponse) readJsonRpcMessage(listener); + + assertThat(answer.id()).isNull(); + assertThat(answer.error()).isNotNull(); + assertThat(answer.error().code()).isEqualTo(-32700); + assertThat(initialized.id()).isEqualTo("init-1"); + assertThat(initialized.error()).isNull(); + assertThat(reported).hasSize(1); + } + } + @Test void rejectsDuplicateInitializeWithoutForwardingItToTheAgent() throws Exception { AtomicInteger initializeCalls = new AtomicInteger(); @@ -299,6 +329,7 @@ void rejectsDuplicateInitializeWithoutForwardingItToTheAgent() throws Exception return Mono.just(new AcpSchema.InitializeResponse(AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of())); }) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) .build()); try (FixtureServer server = FixtureServer.start(agentFactory)) { @@ -332,7 +363,12 @@ void rejectsDuplicateInitializeWithoutForwardingItToTheAgent() throws Exception void listenerShutdownWaitsForWebSocketAgentShutdown() throws Exception { Sinks.One allowAgentShutdown = Sinks.one(); CountDownLatch agentShutdownStarted = new CountDownLatch(1); - AcpAgentFactory agentFactory = transport -> new BlockingCloseAgent(allowAgentShutdown, agentShutdownStarted); + CountDownLatch agentCreated = new CountDownLatch(1); + AcpAgentFactory agentFactory = transport -> { + BlockingCloseAgent agent = new BlockingCloseAgent(allowAgentShutdown, agentShutdownStarted); + agentCreated.countDown(); + return agent; + }; try (FixtureServer server = FixtureServer.start(agentFactory)) { MessageRecordingListener listener = new MessageRecordingListener(); @@ -342,6 +378,10 @@ void listenerShutdownWaitsForWebSocketAgentShutdown() throws Exception { .buildAsync(server.endpoint(), listener) .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); assertThat(listener.openLatch.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); + // The client sees the upgrade answered before the server has opened the socket and + // created its agent; a shutdown in between closes the connection with no agent. + assertThat(agentCreated.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); + assertThat(server.transport().activeConnectionCount()).isEqualTo(1); CompletableFuture shutdown = server.transport().closeGracefully().toFuture(); assertThat(agentShutdownStarted.await(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)).isTrue(); @@ -570,6 +610,21 @@ public Mono completeElicitation(AcpSchema.CompleteElicitationNotification return unsupported(); } + @Override + public Mono sendExtRequest(String method, Object params, TypeRef resultType) { + return unsupported(); + } + + @Override + public Mono sendExtRequest(String method, Object params) { + return unsupported(); + } + + @Override + public Mono sendExtNotification(String method, Object params) { + return unsupported(); + } + @Override public Mono closeGracefully() { return Mono.defer(() -> { diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketShutdownTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketShutdownTest.java new file mode 100644 index 00000000..ec008687 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpAgentTransportWebSocketShutdownTest.java @@ -0,0 +1,210 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The listener's graceful shutdown and the WebSocket close handshake: a client receives the + * going-away close (1001) the shutdown sends, also when the frame has data to wait behind, and a + * client that never answers the close does not hold the shutdown past its timeout. + */ +class StreamableHttpAcpAgentTransportWebSocketShutdownTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + /** More than the loopback socket buffers hold, with the client's receive buffer kept small. */ + private static final int LARGE_MESSAGE_CHARS = 16 * 1024 * 1024; + + private static final int GOING_AWAY = 1001; + + /** + * The close frame waits behind a large frame the client has not read yet: the listener must + * not stop Jetty, which drops the connection without a close frame (the client sees 1006), + * before the close has gone out. + */ + @Test + void aClientThatIsBehindOnReadingStillReceivesGoingAway() throws Exception { + StreamableHttpAcpAgentTransport listener = start(Duration.ofSeconds(5)); + try (RawClient client = RawClient.connect(listener.getPort())) { + client.awaitLargeMessageInFlight(); + + CompletableFuture shutdown = listener.closeGracefully().toFuture(); + // The client catches up only after the shutdown began. + Thread.sleep(500); + int closeCode = client.readUntilClose(true); + + assertThat(closeCode).isEqualTo(GOING_AWAY); + shutdown.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + finally { + listener.closeGracefully().block(TIMEOUT); + } + } + + /** Waiting for the close handshake is bounded by the shutdown timeout. */ + @Test + void aClientThatNeverAnswersTheCloseDoesNotHoldTheShutdownPastItsTimeout() throws Exception { + Duration shutdownTimeout = Duration.ofSeconds(1); + StreamableHttpAcpAgentTransport listener = start(shutdownTimeout); + try (RawClient client = RawClient.connect(listener.getPort())) { + client.awaitLargeMessageInFlight(); + + long started = System.nanoTime(); + listener.closeGracefully().block(TIMEOUT); + Duration took = Duration.ofNanos(System.nanoTime() - started); + + assertThat(took).isLessThan(shutdownTimeout.plusSeconds(2)); + } + finally { + listener.closeGracefully().block(TIMEOUT); + } + } + + private static StreamableHttpAcpAgentTransport start(Duration shutdownTimeout) { + AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + String large = "x".repeat(LARGE_MESSAGE_CHARS); + // The agent sends a large message as soon as the socket opens, before the client sends + // anything. + AcpAgentFactory agentFactory = transport -> { + transport.sendMessage(new AcpSchema.JSONRPCNotification("_test/large", Map.of("data", large))) + .subscribe(ignored -> { + }, error -> { + }); + return AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .build(); + }; + StreamableHttpAcpAgentTransport listener = new StreamableHttpAcpAgentTransport(0, "/acp", jsonMapper, + agentFactory, StreamableHttpAcpAgentTransportOptions.builder().shutdownTimeout(shutdownTimeout).build()); + listener.start().block(TIMEOUT); + return listener; + } + + /** A WebSocket client on a plain socket, so that when it reads is the test's choice. */ + private static final class RawClient implements AutoCloseable { + + private final Socket socket; + + private final DataInputStream in; + + private final OutputStream out; + + /** A byte read ahead, or -1. */ + private int pendingFirstByte = -1; + + private RawClient(Socket socket) throws IOException { + this.socket = socket; + this.in = new DataInputStream(socket.getInputStream()); + this.out = socket.getOutputStream(); + } + + static RawClient connect(int port) throws IOException { + Socket socket = new Socket(); + // Small, so the agent's large message fills the connection and stays in flight. + socket.setReceiveBufferSize(16 * 1024); + socket.connect(new InetSocketAddress("127.0.0.1", port), (int) TIMEOUT.toMillis()); + socket.setSoTimeout((int) TIMEOUT.toMillis()); + RawClient client = new RawClient(socket); + client.handshake(port); + return client; + } + + private void handshake(int port) throws IOException { + String key = Base64.getEncoder().encodeToString("0123456789abcdef".getBytes(StandardCharsets.US_ASCII)); + String request = "GET /acp HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\nUpgrade: websocket\r\n" + + "Connection: Upgrade\r\nSec-WebSocket-Key: " + key + "\r\nSec-WebSocket-Version: 13\r\n\r\n"; + out.write(request.getBytes(StandardCharsets.US_ASCII)); + out.flush(); + StringBuilder headers = new StringBuilder(); + while (!headers.toString().endsWith("\r\n\r\n")) { + int b = in.read(); + if (b < 0) { + throw new EOFException("Handshake ended early: " + headers); + } + headers.append((char) b); + } + assertThat(headers.toString()).startsWith("HTTP/1.1 101"); + } + + /** Waits until the first bytes of the large message arrive, so its write is under way. */ + void awaitLargeMessageInFlight() throws IOException { + int first = in.read(); + assertThat(first).as("first byte of the agent's message").isGreaterThanOrEqualTo(0); + pendingFirstByte = first; + } + + /** + * Reads frames, skipping data, until a close frame or the end of the stream. + * @param answer whether to answer the close, completing the handshake + * @return the close frame's code, or 1006 when the stream ended without one + */ + int readUntilClose(boolean answer) throws IOException { + try { + while (true) { + int b0 = pendingFirstByte >= 0 ? pendingFirstByte : in.readUnsignedByte(); + pendingFirstByte = -1; + int b1 = in.readUnsignedByte(); + long length = b1 & 0x7F; + if (length == 126) { + length = in.readUnsignedShort(); + } + else if (length == 127) { + length = in.readLong(); + } + int opcode = b0 & 0x0F; + if (opcode == 0x8) { + int code = length >= 2 ? in.readUnsignedShort() : 1005; + in.skipNBytes(Math.max(0, length - 2)); + if (answer) { + sendClose(code); + } + return code; + } + in.skipNBytes(length); + } + } + catch (EOFException | java.net.SocketException e) { + return 1006; + } + } + + private void sendClose(int code) throws IOException { + byte[] mask = { 1, 2, 3, 4 }; + byte[] payload = { (byte) (code >> 8), (byte) code }; + byte[] frame = { (byte) 0x88, (byte) (0x80 | payload.length), mask[0], mask[1], mask[2], mask[3], + (byte) (payload[0] ^ mask[0]), (byte) (payload[1] ^ mask[1]) }; + out.write(frame); + out.flush(); + } + + @Override + public void close() throws IOException { + socket.close(); + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServletShutdownTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServletShutdownTest.java new file mode 100644 index 00000000..eab246a2 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpAcpServletShutdownTest.java @@ -0,0 +1,188 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Shutting the servlet down in an application's own container: {@code destroy()} and + * {@code closeGracefully()} finish within the shutdown timeout, whatever the clients and + * agents still connected are doing. + */ +class StreamableHttpAcpServletShutdownTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final AcpJsonMapper JSON = AcpJsonMapper.createDefault(); + + @Test + void destroyAfterTheConnectorsStoppedReturnsPromptlyWithAClientConnected() throws Exception { + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(JSON, factory(agent -> agent)); + try (Container container = Container.start(servlet)) { + AcpAsyncClient client = connectClient(container.endpoint()); + try { + assertThat(servlet.activeConnectionCount()).isEqualTo(1); + + // The order of Spring Boot's Tomcat: the connectors go first, the servlets later. + container.connector.stop(); + long start = System.nanoTime(); + container.context.stop(); + Duration destroy = Duration.ofNanos(System.nanoTime() - start); + + assertThat(destroy).isLessThan(Duration.ofSeconds(2)); + assertThat(servlet.activeConnectionCount()).isZero(); + } + finally { + client.close(); + } + } + } + + @Test + void destroyIsBoundedByTheShutdownTimeoutWhenAnAgentNeverFinishesClosing() throws Exception { + CompletableFuture stuck = new CompletableFuture<>(); + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(JSON, factory(agent -> { + AcpAsyncAgent neverCloses = mock(AcpAsyncAgent.class, delegatesTo(agent)); + doReturn(Mono.never()).when(neverCloses).closeGracefully(); + stuck.complete(neverCloses); + return neverCloses; + }), StreamableHttpAcpAgentTransportOptions.builder().shutdownTimeout(Duration.ofMillis(500)).build()); + try (Container container = Container.start(servlet)) { + AcpAsyncClient client = connectClient(container.endpoint()); + try { + long start = System.nanoTime(); + servlet.destroy(); + Duration destroy = Duration.ofNanos(System.nanoTime() - start); + + assertThat(destroy).isGreaterThanOrEqualTo(Duration.ofMillis(500)).isLessThan(Duration.ofSeconds(3)); + // What did not close gracefully in time is closed at once. + verify(stuck.get(TIMEOUT.toSeconds(), TimeUnit.SECONDS)).close(); + assertThat(servlet.activeConnectionCount()).isZero(); + } + finally { + client.close(); + } + } + } + + @Test + void closingWhileAnInitializeIsInFlightRefusesItAndKeepsNoConnection() throws Exception { + CountDownLatch initializing = new CountDownLatch(1); + Sinks.One release = Sinks.one(); + StreamableHttpAcpServlet servlet = new StreamableHttpAcpServlet(JSON, + AcpAgentFactory.async(transport -> AcpAgent.async(transport).initializeHandler(request -> { + initializing.countDown(); + return release.asMono(); + }).promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())).build())); + try (Container container = Container.start(servlet)) { + HttpClient http = HttpClient.newHttpClient(); + CompletableFuture> initialize = http.sendAsync(HttpRequest + .newBuilder(container.endpoint()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(""" + {"jsonrpc":"2.0","id":"init-1","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} + """)) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + assertThat(initializing.await(TIMEOUT.toSeconds(), TimeUnit.SECONDS)).isTrue(); + + servlet.closeGracefully().block(TIMEOUT); + + // The initialize is answered at once, not when its handler or its timeout ends. + HttpResponse refused = initialize.get(2, TimeUnit.SECONDS); + assertThat(refused.statusCode()).isEqualTo(503); + assertThat(refused.headers().firstValue("Acp-Connection-Id")).isEmpty(); + + // An answer the agent gives afterwards opens no connection on a closed servlet. + release.tryEmitValue(new AcpSchema.InitializeResponse(AcpSchema.LATEST_PROTOCOL_VERSION, + new AcpSchema.AgentCapabilities(), List.of())); + long deadline = System.nanoTime() + Duration.ofMillis(500).toNanos(); + while (System.nanoTime() < deadline) { + assertThat(servlet.activeConnectionCount()).isZero(); + Thread.sleep(25); + } + } + } + + private static AcpAgentFactory factory(java.util.function.UnaryOperator decorate) { + return AcpAgentFactory.async(transport -> decorate.apply(AcpAgent.async(transport) + .initializeHandler(r -> Mono.just(new AcpSchema.InitializeResponse(AcpSchema.LATEST_PROTOCOL_VERSION, + new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(r -> Mono.just(new AcpSchema.NewSessionResponse("shutdown-1", null, null))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build())); + } + + /** A client holding its connection stream and one session stream open. */ + private static AcpAsyncClient connectClient(URI endpoint) { + AcpAsyncClient client = AcpClient.async(new StreamableHttpAcpClientTransport(endpoint, JSON)) + .requestTimeout(TIMEOUT) + .build(); + client.initialize().block(TIMEOUT); + client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); + return client; + } + + /** An application's own Jetty servlet container with the servlet mounted at /acp. */ + private record Container(Server server, ServerConnector connector, ServletContextHandler context) + implements AutoCloseable { + + static Container start(StreamableHttpAcpServlet servlet) throws Exception { + Server server = new Server(); + ServerConnector connector = new ServerConnector(server); + connector.setPort(0); + server.addConnector(connector); + ServletContextHandler context = new ServletContextHandler("/"); + ServletHolder holder = new ServletHolder(servlet); + holder.setAsyncSupported(true); + context.addServlet(holder, "/acp"); + server.setHandler(context); + server.start(); + return new Container(server, connector, context); + } + + URI endpoint() { + return URI.create("http://127.0.0.1:" + connector.getLocalPort() + "/acp"); + } + + @Override + public void close() throws Exception { + server.stop(); + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpClientServerIntegrationTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpClientServerIntegrationTest.java index f458be36..efc14942 100644 --- a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpClientServerIntegrationTest.java +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpClientServerIntegrationTest.java @@ -43,11 +43,17 @@ private static AcpAgentFactory agentFactory() { AtomicInteger sessionCounter = new AtomicInteger(); return AcpAgentFactory.async(transport -> AcpAgent.async(transport) .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, null, null), List.of()))) + AcpSchema.LATEST_PROTOCOL_VERSION, new AcpSchema.AgentCapabilities(true, + // what the handlers serve: a client calls only what is advertised + new AcpSchema.SessionCapabilities(java.util.Map.of(), null, java.util.Map.of(), java.util.Map.of(), + null, null), + new AcpSchema.McpCapabilities(), new AcpSchema.PromptCapabilities(), null, null, null), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse( "sess-" + sessionCounter.incrementAndGet(), null, null))) .loadSessionHandler(request -> Mono.just(new AcpSchema.LoadSessionResponse(null, null))) .resumeSessionHandler(request -> Mono.just(new AcpSchema.ResumeSessionResponse(null, null))) + .deleteSessionHandler(request -> Mono.just(new AcpSchema.DeleteSessionResponse())) + .listSessionsHandler(request -> Mono.just(new AcpSchema.ListSessionsResponse(List.of()))) .promptHandler((request, context) -> { String text = request.text(); Mono work = text.contains("permission") ? context.askPermission("fixture permission").then() @@ -83,7 +89,7 @@ private static AcpClient.AsyncSpec client(StreamableHttpAcpAgentTransport server void happyPathStreamsUpdatesAndResponses() throws Exception { StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); List updates = new CopyOnWriteArrayList<>(); - AcpAsyncClient client = client(server).sessionUpdateConsumer(notification -> { + AcpAsyncClient client = client(server).sessionUpdateHandler(notification -> { updates.add(notification.sessionId()); return Mono.empty(); }).build(); @@ -104,6 +110,28 @@ void happyPathStreamsUpdatesAndResponses() throws Exception { } } + /** + * session/delete is session-scoped (the RFD's Identity Model) and of a session that never + * existed succeeds silently (session-delete.mdx); session/list is connection-scoped. + * Before, the client inferred both routes with a warning, and the server answered the + * delete with 404. + */ + @Test + void deleteOfAnUnknownSessionAndListRoundTrip() throws Exception { + StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); + AcpAsyncClient client = client(server).build(); + try { + client.initialize().block(TIMEOUT); + assertThat(client.deleteSession(new AcpSchema.DeleteSessionRequest("never-existed")).block(TIMEOUT)) + .isNotNull(); + assertThat(client.listSessions(new AcpSchema.ListSessionsRequest("/workspace")).block(TIMEOUT).sessions()).isEmpty(); + } + finally { + client.closeGracefully().block(TIMEOUT); + server.closeGracefully().block(TIMEOUT); + } + } + @Test void permissionRequestRoundTripsOnTheSessionStream() throws Exception { StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); @@ -173,7 +201,7 @@ void twoLogicalSessionsPromptConcurrentlyOnOneConnection() throws Exception { List both = Mono.zip(firstPrompt, secondPrompt) .map(tuple -> List.of(tuple.getT1(), tuple.getT2())) .block(TIMEOUT); - assertThat(both).allMatch(response -> response.stopReason() == AcpSchema.StopReason.END_TURN); + assertThat(both).allMatch(response -> AcpSchema.StopReason.END_TURN.equals(response.stopReason())); } finally { client.closeGracefully().block(TIMEOUT); @@ -181,6 +209,46 @@ void twoLogicalSessionsPromptConcurrentlyOnOneConnection() throws Exception { } } + /** + * Port 0 asks the OS for an ephemeral port. Before start, {@code getPort()} reports the + * configured 0; once started it reports the bound port, which a real client connects to, + * and keeps reporting it after the listener closes. + */ + @Test + void portZeroBindsAnEphemeralPortThatGetPortReports() throws Exception { + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(0, AcpJsonMapper.createDefault(), + agentFactory()); + assertThat(server.getPort()).isZero(); + server.start().block(TIMEOUT); + int boundPort = server.getPort(); + assertThat(boundPort).isPositive(); + AcpAsyncClient client = client(server).build(); + try { + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .block(TIMEOUT); + assertThat(client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent("hi")))) + .block(TIMEOUT) + .stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + } + finally { + client.closeGracefully().block(TIMEOUT); + server.closeGracefully().block(TIMEOUT); + } + assertThat(server.getPort()).isEqualTo(boundPort); + } + + @Test + void portOutsideTheValidRangeIsRejected() { + assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(-1, AcpJsonMapper.createDefault(), agentFactory())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new StreamableHttpAcpAgentTransport(65536, AcpJsonMapper.createDefault(), agentFactory())) + .isInstanceOf(IllegalArgumentException.class); + } + /** * The RFD requires HTTP/2 and the listener serves h2c. Over cleartext the JDK client only * offers the h2c upgrade on a request without a body, so a bodiless request goes first; diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpJettyArchitectureTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpJettyArchitectureTest.java new file mode 100644 index 00000000..e2bb2067 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpJettyArchitectureTest.java @@ -0,0 +1,175 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.jar.JarFile; + +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.spec.AcpAgentSession; +import com.agentclientprotocol.sdk.spec.AcpClientSession; +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.library.dependencies.SliceAssignment; +import com.tngtech.archunit.library.dependencies.SliceIdentifier; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.belongToAnyOf; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-streamable-http-jetty: the agent side of the Streamable HTTP transport, as a servlet and an + * embedded Jetty listener. It is an agent transport like those in acp-core: it builds on the + * {@code spec} transport SPI and the schema, creates one agent per connection through the public + * per-connection seam ({@code AcpAgentFactory}, {@code AcpAsyncAgent}, {@code RemoteAcpConnection}), + * and never sees client code, the session implementations, other agent runtime classes, or + * anything package-private in acp-core (it shares the {@code agent.transport} package with + * acp-core, so the compiler would allow that). Within its package no two classes depend on each + * other in a cycle. {@link ServletPortabilityArchitectureTest} keeps the servlet path free of Jetty. + */ +@AnalyzeClasses(locations = StreamableHttpJettyArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class StreamableHttpJettyArchitectureTest { + + @ArchTest + static final ArchRule isFrameworkNeutral = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("org.springframework..", "io.quarkus..", "io.micronaut..", + "io.vertx..", "io.smallrye..", "jakarta.enterprise..", "jakarta.inject..", + "com.agentclientprotocol.sdk.spring..", "com.agentclientprotocol.sdk.micronaut..", + "com.agentclientprotocol.sdk.quarkus..", "com.agentclientprotocol.sdk.integration..") + .as("a framework or a framework integration")) + .because("framework integrations (the Quarkus extension mounts this module's servlet) build on the SDK, never the reverse"); + + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + static final DescribedPredicate AGENT_RUNTIME_BEYOND_THE_CONNECTION_SEAM = resideInAPackage( + "com.agentclientprotocol.sdk.agent") + .and(not(belongToAnyOf(AcpAgentFactory.class, AcpAsyncAgent.class))) + .or(resideInAnyPackage("com.agentclientprotocol.sdk.client..", "com.agentclientprotocol.sdk.capabilities..")) + .as("client or agent runtime classes other than AcpAgentFactory and AcpAsyncAgent"); + + /** + * acp-core's internals as seen from another module: the session implementations (request + * correlation and dispatch, which the agent and client runtimes own) and anything not public. + */ + static final DescribedPredicate SDK_INTERNALS = resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(belongToAnyOf(AcpAgentSession.class, AcpClientSession.class).or(not(modifier(JavaModifier.PUBLIC)))) + .as("acp-core internals (session implementations, non-public classes)"); + + @ArchTest + static final ArchRule seesOnlyTheProtocolAndTheConnectionSeam = noClasses().should() + .dependOnClassesThat(AGENT_RUNTIME_BEYOND_THE_CONNECTION_SEAM.or(SDK_INTERNALS)) + .because("a transport sits beneath the session and the runtime that use it"); + + /** + * The embedded listener is a launcher: it runs the servlet host on Jetty and holds no protocol + * rule of its own (no JSON-RPC message, routing, session or connection class). + */ + @ArchTest + static final ArchRule isOnlyALauncher = noClasses().should() + .dependOnClassesThat(resideInAnyPackage("com.agentclientprotocol.sdk.spec..", "com.agentclientprotocol.sdk.http.server..") + .or(belongToAnyOf(RemoteAcpConnection.class))) + .because("the protocol lives in acp-http-core and the I/O in the servlet host; the listener only starts Jetty"); + + static SliceAssignment topLevelClassesIn(String... packages) { + List names = Arrays.asList(packages); + return new SliceAssignment() { + + @Override + public SliceIdentifier getIdentifierOf(JavaClass javaClass) { + if (!names.contains(javaClass.getPackageName()) || !ThisModule.isFromThisModule(javaClass)) { + return SliceIdentifier.ignore(); + } + JavaClass topLevel = javaClass; + while (topLevel.getEnclosingClass().isPresent()) { + topLevel = topLevel.getEnclosingClass().get(); + } + return SliceIdentifier.of(topLevel.getName()); + } + + @Override + public String getDescription() { + return "top-level classes of " + names; + } + + }; + } + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = StreamableHttpAcpAgentTransport.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketClientTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketClientTest.java new file mode 100644 index 00000000..8e8d2a6e --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/StreamableHttpWebSocketClientTest.java @@ -0,0 +1,323 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.IOException; +import java.net.ServerSocket; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link WebSocketAcpClientTransport} against the WebSocket upgrade of + * {@link StreamableHttpAcpAgentTransport}: the client-side paths that + * {@link StreamableHttpAcpAgentTransportWebSocketIntegrationTest} does not cover — agent-to-client + * file requests (and that the client never echoes them back), a client-to-agent cancel + * notification, messages larger than Jetty's 64 KB default, and sequential prompts on one session. + */ +class StreamableHttpWebSocketClientTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + private StreamableHttpAcpAgentTransport server; + + private AcpAsyncClient client; + + @AfterEach + void tearDown() { + if (client != null) { + client.closeGracefully().block(TIMEOUT); + } + if (server != null) { + server.closeGracefully().block(TIMEOUT); + } + } + + /** + * The agent sends {@code fs/read_text_file} while handling a prompt. The client answers it + * with a response and must not send the request itself back to the agent (an + * {@code AcpClientSession} that forwarded handled inbound messages to the transport did). + */ + @Test + void agentToClientFileReadIsAnsweredAndNotEchoedBackToTheAgent() throws Exception { + CountDownLatch echoedRequestReceived = new CountDownLatch(1); + AtomicReference contentSeenByAgent = new AtomicReference<>(); + start(transport -> { + AcpAgentTransport observed = new InboundObservingTransport(transport, message -> { + if (message instanceof AcpSchema.JSONRPCRequest request + && AcpSchema.METHOD_FS_READ_TEXT_FILE.equals(request.method())) { + echoedRequestReceived.countDown(); + } + }); + AtomicReference agent = new AtomicReference<>(); + agent.set(baseAgent(observed) + .promptHandler((request, context) -> agent.get() + .readTextFile(new AcpSchema.ReadTextFileRequest(request.sessionId(), "/src/Main.java", null, null)) + .doOnNext(response -> contentSeenByAgent.set(response.content())) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + return agent.get(); + }); + + client = AcpClient.async(clientTransport()) + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, false), false)) + .requestTimeout(TIMEOUT) + .readTextFileHandler(request -> Mono.just(new AcpSchema.ReadTextFileResponse("public class Main { }"))) + .build(); + client.initialize() + .block(TIMEOUT); + AcpSchema.NewSessionResponse session = newSession(); + + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent("read file")))) + .block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(contentSeenByAgent.get()).isEqualTo("public class Main { }"); + assertThat(echoedRequestReceived.await(1, TimeUnit.SECONDS)) + .as("the client must not send an inbound agent request back to the agent") + .isFalse(); + } + + @Test + void agentToClientFileWriteReachesTheClient() throws Exception { + AtomicReference writtenPath = new AtomicReference<>(); + AtomicReference writtenContent = new AtomicReference<>(); + start(transport -> { + AtomicReference agent = new AtomicReference<>(); + agent.set(baseAgent(transport) + .promptHandler((request, context) -> agent.get() + .writeTextFile(new AcpSchema.WriteTextFileRequest(request.sessionId(), "/out.txt", "hello world")) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + return agent.get(); + }); + + client = AcpClient.async(clientTransport()) + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(false, true), false)) + .requestTimeout(TIMEOUT) + .writeTextFileHandler(request -> { + writtenPath.set(request.path()); + writtenContent.set(request.content()); + return Mono.just(new AcpSchema.WriteTextFileResponse()); + }) + .build(); + client.initialize() + .block(TIMEOUT); + AcpSchema.NewSessionResponse session = newSession(); + + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent("write a file")))) + .block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(writtenPath.get()).isEqualTo("/out.txt"); + assertThat(writtenContent.get()).isEqualTo("hello world"); + } + + @Test + void cancelDuringAPromptReachesTheAgent() throws Exception { + CountDownLatch promptStarted = new CountDownLatch(1); + AtomicReference cancelledSession = new AtomicReference<>(); + CountDownLatch cancelReceived = new CountDownLatch(1); + start(transport -> baseAgent(transport) + .promptHandler((request, context) -> { + promptStarted.countDown(); + return Mono.delay(Duration.ofSeconds(5)).thenReturn(AcpSchema.PromptResponse.endTurn()); + }) + .cancelHandler(notification -> { + cancelledSession.set(notification.sessionId()); + cancelReceived.countDown(); + return Mono.empty(); + }) + .build()); + + client = AcpClient.async(clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = newSession(); + + client.prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent("slow work")))) + .subscribe(response -> { + }, error -> { + }); + assertThat(promptStarted.await(5, TimeUnit.SECONDS)).isTrue(); + client.cancel(new AcpSchema.CancelNotification(session.sessionId())).block(TIMEOUT); + + assertThat(cancelReceived.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(cancelledSession.get()).isEqualTo(session.sessionId()); + } + + /** + * A prompt of 500,000 characters, past Jetty's 64 KB default text-message limit: agent + * messages and prompts can carry whole files. + */ + @Test + void largeMessagesCrossTheWebSocketIntact() throws Exception { + String largeText = "x".repeat(500_000); + start(transport -> baseAgent(transport) + .promptHandler((request, context) -> context.sendMessage("received " + request.text().length()) + .then(context.sendMessage(request.text())) + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + + List agentMessages = new CopyOnWriteArrayList<>(); + client = AcpClient.async(clientTransport()) + .requestTimeout(TIMEOUT) + .sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + agentMessages.add(text.text()); + } + return Mono.empty(); + }) + .build(); + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = newSession(); + + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent(largeText)))) + .block(TIMEOUT); + + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + assertThat(agentMessages).containsExactly("received 500000", largeText); + } + + @Test + void sequentialPromptsOnOneSessionArriveInOrder() throws Exception { + List receivedPrompts = new CopyOnWriteArrayList<>(); + start(transport -> baseAgent(transport) + .promptHandler((request, context) -> { + receivedPrompts.add(request.text()); + return context.sendMessage("echo: " + request.text()).thenReturn(AcpSchema.PromptResponse.endTurn()); + }) + .build()); + + client = AcpClient.async(clientTransport()).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = newSession(); + + for (int i = 1; i <= 5; i++) { + AcpSchema.PromptResponse response = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), + List.of(new AcpSchema.TextContent("prompt-" + i)))) + .block(TIMEOUT); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + } + + assertThat(receivedPrompts).containsExactly("prompt-1", "prompt-2", "prompt-3", "prompt-4", "prompt-5"); + } + + private void start(Function agentFactory) { + server = new StreamableHttpAcpAgentTransport(freePort(), jsonMapper, AcpAgentFactory.async(agentFactory)); + server.start().block(TIMEOUT); + } + + private WebSocketAcpClientTransport clientTransport() { + return new WebSocketAcpClientTransport(URI.create("ws://127.0.0.1:" + server.getPort() + "/acp"), jsonMapper); + } + + private AcpSchema.NewSessionResponse newSession() { + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .block(TIMEOUT); + assertThat(session).isNotNull(); + return session; + } + + private static AcpAgent.AsyncAgentBuilder baseAgent(AcpAgentTransport transport) { + return AcpAgent.async(transport) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono.just( + new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("ws-session", null))); + } + + private static int freePort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + catch (IOException e) { + throw new IllegalStateException("Unable to allocate a free port", e); + } + } + + /** + * Delegates to the per-connection transport and reports every inbound message before the + * agent sees it. + */ + private static final class InboundObservingTransport implements AcpAgentTransport { + + private final AcpAgentTransport delegate; + + private final Consumer observer; + + InboundObservingTransport(AcpAgentTransport delegate, Consumer observer) { + this.delegate = delegate; + this.observer = observer; + } + + @Override + public Mono start( + Function, Mono> handler) { + return delegate.start(inbound -> handler.apply(inbound.doOnNext(observer))); + } + + @Override + public void setExceptionHandler(Consumer handler) { + delegate.setExceptionHandler(handler); + } + + @Override + public Mono awaitTermination() { + return delegate.awaitTermination(); + } + + @Override + public Mono closeGracefully() { + return delegate.closeGracefully(); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + return delegate.sendMessage(message); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return delegate.unmarshalFrom(data, typeRef); + } + + @Override + public List protocolVersions() { + return delegate.protocolVersions(); + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportCloseIdempotenceTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportCloseIdempotenceTest.java new file mode 100644 index 00000000..91fc6009 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportCloseIdempotenceTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.AcpSyncClient; +import com.agentclientprotocol.sdk.client.transport.AgentParameters; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.AcpTransport; +import com.agentclientprotocol.sdk.test.InMemoryTransportPair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Hooks; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * Every shipped transport, client side and agent side, can be closed more than once, with + * {@code close()} and {@code closeGracefully()} in either order: each call completes without + * an error, and nothing is dropped to Reactor's error hook. Applications close more than once + * as a matter of course: try-with-resources after an explicit close, or a container's destroy + * callbacks. + */ +class TransportCloseIdempotenceTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(20); + + private final AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); + + private final List dropped = new CopyOnWriteArrayList<>(); + + private final List cleanups = new ArrayList<>(); + + @BeforeEach + void captureDroppedErrors() { + Hooks.onErrorDropped(this.dropped::add); + } + + @AfterEach + void cleanUp() { + Hooks.resetOnErrorDropped(); + for (int i = this.cleanups.size() - 1; i >= 0; i--) { + try { + this.cleanups.get(i).run(); + } + catch (RuntimeException e) { + // Best effort: the transport under test may have taken its peer down. + } + } + } + + enum Kind { + + WEBSOCKET_CLIENT, STREAMABLE_HTTP_CLIENT, STREAMABLE_HTTP_AGENT, STREAMABLE_HTTP_WEBSOCKET_AGENT, + STDIO_CLIENT, STDIO_AGENT, IN_MEMORY_CLIENT, IN_MEMORY_AGENT + + } + + enum Step { + + CLOSE, CLOSE_GRACEFULLY + + } + + static Stream transportsAndOrders() { + List> orders = List.of(List.of(Step.CLOSE_GRACEFULLY, Step.CLOSE_GRACEFULLY), + List.of(Step.CLOSE_GRACEFULLY, Step.CLOSE), List.of(Step.CLOSE, Step.CLOSE_GRACEFULLY), + List.of(Step.CLOSE, Step.CLOSE)); + return Stream.of(Kind.values()) + .flatMap(kind -> orders.stream().map(order -> Arguments.of(kind, order))); + } + + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("transportsAndOrders") + void closingAConnectedTransportAgainIsHarmless(Kind kind, List order) { + AcpTransport transport = connected(kind); + + for (Step step : order) { + if (step == Step.CLOSE) { + assertThatCode(transport::close).as("close()").doesNotThrowAnyException(); + } + else { + assertThatCode(() -> transport.closeGracefully().block(TIMEOUT)).as("closeGracefully()") + .doesNotThrowAnyException(); + } + } + // A last graceful close completes too, once whatever the steps started has finished. + assertThatCode(() -> transport.closeGracefully().block(TIMEOUT)).as("a last closeGracefully()") + .doesNotThrowAnyException(); + + assertThat(this.dropped).as("errors dropped to Reactor's onErrorDropped hook").isEmpty(); + } + + /** The reported symptom: the second graceful close of a WebSocket sync client returned false. */ + @Test + void aWebSocketSyncClientClosesGracefullyTwice() { + StreamableHttpAcpAgentTransport server = startServer(new AtomicReference<>()); + AcpSyncClient client = AcpClient.sync(webSocketClient(server)).requestTimeout(TIMEOUT).build(); + client.initialize(); + + assertThat(client.closeGracefully()).isTrue(); + assertThat(client.closeGracefully()).isTrue(); + client.close(); + } + + private AcpTransport connected(Kind kind) { + AtomicReference agentSide = new AtomicReference<>(); + switch (kind) { + case WEBSOCKET_CLIENT -> { + WebSocketAcpClientTransport transport = webSocketClient(startServer(agentSide)); + initialize(transport); + return transport; + } + case STREAMABLE_HTTP_CLIENT -> { + StreamableHttpAcpClientTransport transport = httpClient(startServer(agentSide)); + initialize(transport); + return transport; + } + case STREAMABLE_HTTP_AGENT -> { + initialize(httpClient(startServer(agentSide))); + return agentSide.get(); + } + case STREAMABLE_HTTP_WEBSOCKET_AGENT -> { + initialize(webSocketClient(startServer(agentSide))); + return agentSide.get(); + } + case STDIO_CLIENT -> { + StdioAcpClientTransport transport = new StdioAcpClientTransport(javaAgent(StdioCloseAgent.class)); + transport.setStdErrorHandler(line -> { + }); + initialize(transport); + return transport; + } + case STDIO_AGENT -> { + return stdioAgent(); + } + case IN_MEMORY_CLIENT, IN_MEMORY_AGENT -> { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + AcpAsyncAgent agent = agent(pair.agentTransport()); + agent.start().block(TIMEOUT); + this.cleanups.add(agent::close); + initialize(pair.clientTransport()); + return kind == Kind.IN_MEMORY_CLIENT ? pair.clientTransport() : pair.agentTransport(); + } + default -> throw new IllegalArgumentException(kind.name()); + } + } + + private StreamableHttpAcpAgentTransport startServer(AtomicReference agentSide) { + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(0, this.jsonMapper, + AcpAgentFactory.async(transport -> { + agentSide.set(transport); + return agent(transport); + })); + server.start().block(TIMEOUT); + this.cleanups.add(() -> server.closeGracefully().block(TIMEOUT)); + return server; + } + + private static AcpAsyncAgent agent(AcpAgentTransport transport) { + return AcpAgent.async(transport) + .requestTimeout(TIMEOUT) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("s-1", null))) + .promptHandler((request, context) -> Mono.just(AcpSchema.PromptResponse.endTurn())) + .build(); + } + + private WebSocketAcpClientTransport webSocketClient(StreamableHttpAcpAgentTransport server) { + return new WebSocketAcpClientTransport(URI.create("ws://127.0.0.1:" + server.getPort() + "/acp"), + this.jsonMapper); + } + + private StreamableHttpAcpClientTransport httpClient(StreamableHttpAcpAgentTransport server) { + return new StreamableHttpAcpClientTransport(URI.create("http://127.0.0.1:" + server.getPort() + "/acp"), + this.jsonMapper); + } + + /** Connects a client over the transport and initializes it, so the transport is in use. */ + private void initialize(AcpClientTransport transport) { + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + client.initialize().block(TIMEOUT); + this.cleanups.add(client::close); + } + + /** A started stdio agent transport on pipes; the client side writes nothing. */ + private AcpAgentTransport stdioAgent() { + try { + PipedOutputStream clientOutput = new PipedOutputStream(); + PipedInputStream agentInput = new PipedInputStream(clientOutput); + StdioAcpAgentTransport transport = new StdioAcpAgentTransport(this.jsonMapper, agentInput, + new ByteArrayOutputStream()); + AcpAsyncAgent agent = agent(transport); + agent.start().block(TIMEOUT); + // Ends the reader thread, which closing the transport does not interrupt. + this.cleanups.add(() -> { + try { + clientOutput.close(); + } + catch (IOException e) { + // already closed + } + }); + return transport; + } + catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static AgentParameters javaAgent(Class main) { + String java = Path.of(System.getProperty("java.home"), "bin", "java").toString(); + return AgentParameters.builder(java) + .arg("-Dlogback.configurationFile=logback-stdio-agent.xml") + .arg("-cp") + .arg(System.getProperty("java.class.path")) + .arg(main.getName()) + .build(); + } + + /** A stdio agent run as the child process of the stdio client case. */ + public static final class StdioCloseAgent { + + private StdioCloseAgent() { + } + + public static void main(String[] args) { + AcpAsyncAgent agent = agent(new StdioAcpAgentTransport()); + agent.start().then(agent.awaitTermination()).block(); + System.exit(0); + } + + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportThreadsTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportThreadsTest.java new file mode 100644 index 00000000..bc38cc67 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/TransportThreadsTest.java @@ -0,0 +1,306 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import java.net.ServerSocket; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransportOptions; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.VirtualThreads; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Given an executor of the application's, the client transports and the listener run on its + * threads and create no pools of their own; they leave that executor running when they close. + */ +class TransportThreadsTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private final AtomicInteger appTasks = new AtomicInteger(); + + private final ExecutorService appPool = Executors.newCachedThreadPool(r -> { + Thread thread = new Thread(r, "app-worker"); + thread.setDaemon(true); + return thread; + }); + + private final ExecutorService app = new java.util.concurrent.AbstractExecutorService() { + + @Override + public void execute(Runnable command) { + appTasks.incrementAndGet(); + appPool.execute(command); + } + + @Override + public void shutdown() { + appPool.shutdown(); + } + + @Override + public List shutdownNow() { + return appPool.shutdownNow(); + } + + @Override + public boolean isShutdown() { + return appPool.isShutdown(); + } + + @Override + public boolean isTerminated() { + return appPool.isTerminated(); + } + + @Override + public boolean awaitTermination(long timeout, java.util.concurrent.TimeUnit unit) throws InterruptedException { + return appPool.awaitTermination(timeout, unit); + } + + }; + + @AfterEach + void stopAppPool() { + appPool.shutdownNow(); + } + + @Test + void theHttpClientTransportRunsOnTheApplicationsExecutorAndLeavesItRunning() throws Exception { + StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); + try { + Set before = Thread.getAllStackTraces().keySet(); + var transport = new StreamableHttpAcpClientTransport(endpoint(server, "http"), + AcpJsonMapper.createDefault(), StreamableHttpAcpClientTransportOptions.builder().executor(app).build()); + roundTrip(transport); + // The JDK's HttpClient always runs one selector thread of its own; only an + // HttpClient passed to the transport avoids it. + assertThat(newThreads(before, "acp-", "HttpClient-")) + .allMatch(name -> name.matches("HttpClient-\\d+-SelectorManager")) + .hasSizeLessThanOrEqualTo(1); + assertThat(appTasks).hasPositiveValue(); + assertThat(app.isShutdown()).isFalse(); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + } + + @Test + void theWebSocketClientTransportRunsOnTheApplicationsExecutorAndLeavesItRunning() throws Exception { + StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); + try { + Set before = Thread.getAllStackTraces().keySet(); + var transport = new WebSocketAcpClientTransport(endpoint(server, "ws"), AcpJsonMapper.createDefault(), app); + roundTrip(transport); + assertThat(newThreads(before, "acp-", "HttpClient-")) + .allMatch(name -> name.matches("HttpClient-\\d+-SelectorManager")) + .hasSizeLessThanOrEqualTo(1); + assertThat(appTasks).hasPositiveValue(); + assertThat(app.isShutdown()).isFalse(); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + } + + @Test + void theListenerRunsOnTheApplicationsExecutorAndLeavesItRunning() throws Exception { + assumeTrue(VirtualThreads.isSupported(), "Jetty's VirtualThreadPool needs JDK 21"); + Set before = Thread.getAllStackTraces().keySet(); + StreamableHttpAcpAgentTransport server = startServer( + StreamableHttpAcpAgentTransportOptions.builder().executor(app).build()); + Set serverThreads; + try { + serverThreads = newThreads(before, "acp-", "qtp", "Scheduler-", "jetty-"); + // The client's own threads are not the listener's. + roundTrip(new StreamableHttpAcpClientTransport(endpoint(server, "http"), AcpJsonMapper.createDefault(), + StreamableHttpAcpClientTransportOptions.builder().executor(app).build())); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + // Jetty's VirtualThreadPool parks one platform thread for as long as the server runs, + // as Jetty's own pool would keep the JVM alive. + assertThat(serverThreads).containsExactly("jetty-virtual-thread-pool-keepalive"); + assertThat(appTasks).hasPositiveValue(); + assertThat(app.isShutdown()).isFalse(); + } + + @Test + void theListenerSchedulesItsKeepAlivesOnTheSharedTimer() throws Exception { + Set before = Thread.getAllStackTraces().keySet(); + StreamableHttpAcpAgentTransport server = startServer( + StreamableHttpAcpAgentTransportOptions.builder().keepAliveInterval(Duration.ofSeconds(1)).build()); + try { + // The servlet starts its keep-alive when the first request puts it into service. + roundTrip(new StreamableHttpAcpClientTransport(endpoint(server, "http"), AcpJsonMapper.createDefault())); + assertThat(newThreads(before, "acp-streamable-http-keepalive")).isEmpty(); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + } + + @Test + void onJdk21TheDefaultClientTransportsStartNoPlatformThreadsOfTheirOwn() throws Exception { + assumeTrue(VirtualThreads.isSupported(), "virtual threads need JDK 21"); + StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); + try { + Set before = Thread.getAllStackTraces().keySet(); + roundTrip(new StreamableHttpAcpClientTransport(endpoint(server, "http"), AcpJsonMapper.createDefault())); + roundTrip(new WebSocketAcpClientTransport(endpoint(server, "ws"), AcpJsonMapper.createDefault())); + // One JDK HttpClient selector thread per transport, gone once its client is + // unreachable; nothing of the SDK's. + assertThat(newThreads(before, "acp-", "HttpClient-")) + .allMatch(name -> name.matches("HttpClient-\\d+-SelectorManager")) + .hasSizeLessThanOrEqualTo(2); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + } + + @Test + void onJdk21TheDefaultListenerServesOnVirtualThreads() throws Exception { + assumeTrue(VirtualThreads.isSupported(), "virtual threads need JDK 21"); + Set before = Thread.getAllStackTraces().keySet(); + StreamableHttpAcpAgentTransport server = startServer(StreamableHttpAcpAgentTransportOptions.defaults()); + Set serverThreads; + try { + serverThreads = newThreads(before, "acp-", "qtp", "Scheduler-", "jetty-"); + roundTrip(new StreamableHttpAcpClientTransport(endpoint(server, "http"), AcpJsonMapper.createDefault())); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + assertThat(serverThreads).containsExactly("jetty-virtual-thread-pool-keepalive"); + } + + @Test + void withVirtualThreadsOffTheListenerAndTheHttpClientKeepTheirPlatformPools() throws Exception { + Set before = Thread.getAllStackTraces().keySet(); + StreamableHttpAcpAgentTransport server = startServer( + StreamableHttpAcpAgentTransportOptions.builder().virtualThreads(false).build()); + Set threads = new java.util.HashSet<>(); + try { + // Counted before the client closes: closing shuts its pools down. + roundTrip(new StreamableHttpAcpClientTransport(endpoint(server, "http"), AcpJsonMapper.createDefault(), + StreamableHttpAcpClientTransportOptions.builder().virtualThreads(false).build()), + () -> threads.addAll(newThreads(before, "acp-", "qtp"))); + } + finally { + server.closeGracefully().block(TIMEOUT); + } + assertThat(threads).anyMatch(name -> name.startsWith("qtp")) + .contains("acp-streamable-http-client", "acp-streamable-http-signal", "acp-streamable-http-sse"); + } + + @Test + void anExecutorForTheListenerNeedsVirtualThreads() { + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> StreamableHttpAcpAgentTransportOptions.builder() + .executor(app) + .virtualThreads(false) + .build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void anExecutorForTheListenerNeedsJdk21() { + assumeTrue(!VirtualThreads.isSupported(), "only before JDK 21"); + org.assertj.core.api.Assertions + .assertThatThrownBy(() -> new StreamableHttpAcpAgentTransport(0, "/acp", AcpJsonMapper.createDefault(), + agentFactory(), StreamableHttpAcpAgentTransportOptions.builder().executor(app).build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("JDK 21"); + } + + private static void roundTrip(AcpClientTransport transport) { + roundTrip(transport, () -> { + }); + } + + private static void roundTrip(AcpClientTransport transport, Runnable beforeClose) { + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).build(); + try { + client.initialize().block(TIMEOUT); + AcpSchema.NewSessionResponse session = client + .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .block(TIMEOUT); + AcpSchema.PromptResponse prompt = client + .prompt(new AcpSchema.PromptRequest(session.sessionId(), List.of(new AcpSchema.TextContent("hi")))) + .block(TIMEOUT); + assertThat(prompt.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + beforeClose.run(); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + + /** + * Names of the live platform threads started since {@code before} whose name starts so, + * leaving out the SDK's one JVM-wide timer ({@code acp-timeout}), which the first timeout + * in the JVM starts whichever transport sets it. + */ + private static Set newThreads(Set before, String... prefixes) { + return Thread.getAllStackTraces() + .keySet() + .stream() + .filter(thread -> !before.contains(thread)) + .map(Thread::getName) + .filter(name -> !name.equals("acp-timeout")) + .filter(name -> java.util.Arrays.stream(prefixes).anyMatch(name::startsWith)) + .collect(Collectors.toSet()); + } + + private static AcpAgentFactory agentFactory() { + return AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("sess-1", null, null))) + .promptHandler((request, context) -> context.sendMessage("hello") + .thenReturn(AcpSchema.PromptResponse.endTurn())) + .build()); + } + + private static StreamableHttpAcpAgentTransport startServer(StreamableHttpAcpAgentTransportOptions options) + throws Exception { + int port; + try (ServerSocket socket = new ServerSocket(0)) { + port = socket.getLocalPort(); + } + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(port, + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH, AcpJsonMapper.createDefault(), agentFactory(), options); + server.start().block(TIMEOUT); + return server; + } + + private static URI endpoint(StreamableHttpAcpAgentTransport server, String scheme) { + return URI.create(scheme + "://127.0.0.1:" + server.getPort() + "/acp"); + } + +} diff --git a/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketApiVersionTest.java b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketApiVersionTest.java new file mode 100644 index 00000000..b47192ae --- /dev/null +++ b/acp-streamable-http-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketApiVersionTest.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.agent.transport; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * The listener brings the Jakarta WebSocket 2.2 API, which Jetty 12.1 runs on and Tomcat 11 + * needs. Jetty's own jars bring 2.1, which lacks {@code SendResult(Session)}: in an application + * that also runs Tomcat 11 (a Spring Boot web application with this module added) Tomcat then + * failed every WebSocket send with {@code NoSuchMethodError}. + */ +class WebSocketApiVersionTest { + + @Test + void theApiOnTheClasspathIsJakartaWebSocket22() { + assertThatCode(() -> Class.forName("jakarta.websocket.SendResult") + .getConstructor(Class.forName("jakarta.websocket.Session"))).doesNotThrowAnyException(); + } + +} diff --git a/acp-streamable-http-jetty/src/test/resources/archunit.properties b/acp-streamable-http-jetty/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-streamable-http-jetty/src/test/resources/logback-stdio-agent.xml b/acp-streamable-http-jetty/src/test/resources/logback-stdio-agent.xml new file mode 100644 index 00000000..e7c0efe4 --- /dev/null +++ b/acp-streamable-http-jetty/src/test/resources/logback-stdio-agent.xml @@ -0,0 +1,14 @@ + + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/acp-test/README.md b/acp-test/README.md index 6f254562..cde55af5 100644 --- a/acp-test/README.md +++ b/acp-test/README.md @@ -1,14 +1,13 @@ # acp-test -Test utilities for unit testing ACP clients and agents without subprocesses or network connections. - -## Key Classes +Test utilities for ACP clients and agents: they run a client and an agent in one JVM, with no +process and no network. | Class | Purpose | |-------|---------| -| `InMemoryTransportPair` | Creates connected client/agent transports in-process | -| `MockAcpAgent` | Records requests, provides configurable responses | -| `MockAcpClient` | Records session updates, handles agent requests | +| `InMemoryTransportPair` | Two connected transports: build a client on `clientTransport()` and an agent on `agentTransport()` | +| `MockAcpAgent` | A scripted agent for testing client code. It answers `initialize`, `session/new` and `session/prompt` and records every request | +| `MockAcpClient` | A scripted client for testing agent code. It answers permission and file requests, records them and every session update, and drives the agent with blocking calls | ## Installation @@ -16,26 +15,104 @@ Test utilities for unit testing ACP clients and agents without subprocesses or n com.agentclientprotocol acp-test - 0.9.0 + ${acp.version} test ``` -## Usage +`acp-test` brings `acp-core` and `acp-json-jackson2`. + +## A real client and agent ```java -var pair = InMemoryTransportPair.create(); +InMemoryTransportPair pair = InMemoryTransportPair.create(); + +AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1")) + .promptHandler((request, context) -> { + context.sendMessage("Hello"); + return AcpSchema.PromptResponse.endTurn(); + }) + .build(); +agent.start(); -// Build client with one side AcpSyncClient client = AcpClient.sync(pair.clientTransport()).build(); +client.initialize(); +String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .sessionId(); +AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(sessionId, + List.of(new AcpSchema.TextContent("Hi")))); -// Build agent with the other +client.close(); +agent.close(); +``` + +Each pair carries one connection, so use a new pair for every client and agent. Messages pass +between the two as Java objects and are never written as JSON. To test what goes over the wire, +use a real transport. + +## Testing a client: `MockAcpAgent` + +```java +InMemoryTransportPair pair = InMemoryTransportPair.create(); +MockAcpAgent agent = MockAcpAgent.builder(pair.agentTransport()) + .promptResponse(request -> AcpSchema.PromptResponse.refusal()) + .build(); +agent.start(); + +AcpSyncClient client = AcpClient.sync(pair.clientTransport()).build(); +client.initialize(); +String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) + .sessionId(); // "mock-session" +client.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("Delete everything")))); + +List prompts = agent.getReceivedPrompts(); +client.close(); +agent.close(); +``` + +Without builder settings it answers `initialize` with protocol version 1 and no capabilities, +`session/new` with session ID `mock-session`, and every prompt with stop reason `end_turn`. Any +other request gets `-32601` (method not found). `sendSessionUpdate` sends updates to the client, and +`async()` returns the wrapped agent for other calls to the client. To wait for prompts, call +`expectPrompts(n)` before the client sends them, then `awaitPrompts(timeout)`. + +## Testing an agent: `MockAcpClient` + +```java +InMemoryTransportPair pair = InMemoryTransportPair.create(); AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport()) - .initializeHandler(req -> InitializeResponse.ok()) - .promptHandler((req, ctx) -> { /* ... */ }) + .initializeHandler(request -> AcpSchema.InitializeResponse.ok()) + .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1")) + .promptHandler((request, context) -> { + String source = context.readFile("/src/Main.java"); + context.sendMessage("Main.java has " + source.length() + " characters"); + return AcpSchema.PromptResponse.endTurn(); + }) .build(); +agent.start(); + +MockAcpClient client = MockAcpClient.builder(pair.clientTransport()) + .fileContent("/src/Main.java", "class Main {}") + .build(); // connects; no start() +client.initialize(); +client.newSession("/workspace"); +AcpSchema.PromptResponse response = client.prompt("Count the characters"); + +List updates = client.getReceivedUpdates(); +List reads = client.getReceivedFileReadRequests(); +client.close(); +agent.close(); ``` -## Documentation +The mock client advertises file reading and writing and nothing else. Without builder settings it +answers every permission request with option ID `allow`, whatever options the agent offered, +and every file read with `// Mock file content`, and it writes no file. Its blocking calls, prompts included, wait at most the +builder's `requestTimeout`, 10 seconds by default. When `prompt` returns, the turn's session updates +have all arrived. To wait for updates sent between prompts, call `expectUpdates(n)` before they are +sent, then `awaitUpdates(timeout)`. -- [Module 16: In-Memory Testing](https://lab.pollack.ai/docs/acp-java-sdk/tutorial/16-in-memory-testing) +The Javadoc of each class has the details. For a walkthrough, see +[Module 16: In-Memory Testing](https://github.com/markpollack/acp-java-tutorial/tree/main/module-16-in-memory-testing) +of the ACP Java Tutorial. diff --git a/acp-test/pom.xml b/acp-test/pom.xml index 6aa001ee..5131b94d 100644 --- a/acp-test/pom.xml +++ b/acp-test/pom.xml @@ -7,7 +7,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 acp-test @@ -16,7 +16,18 @@ ACP Test Utilities Test utilities for ACP SDK - InMemoryTransportPair, MockAcpAgent, MockAcpClient + + + 0.76 + 0.38 + + + + + org.jspecify + jspecify + com.agentclientprotocol @@ -28,6 +39,23 @@ acp-json-jackson2 + + + com.agentclientprotocol + acp-http-core + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + + + org.assertj + assertj-core + ${assertj.version} + + io.projectreactor @@ -41,8 +69,8 @@ test - org.assertj - assertj-core + com.tngtech.archunit + archunit-junit5 test @@ -53,4 +81,27 @@ + + + + + org.jacoco + jacoco-maven-plugin + + + coverage-gate + + + com/agentclientprotocol/sdk/test/http/AcpHttpTransportTck* + com/agentclientprotocol/sdk/test/http/Networks* + + + + + + + + diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java index 4c23befd..db9a5b26 100644 --- a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/InMemoryTransportPair.java @@ -4,8 +4,9 @@ package com.agentclientprotocol.sdk.test; -import java.time.Duration; -import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.Function; @@ -14,36 +15,57 @@ import com.agentclientprotocol.sdk.spec.AcpSchema; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.json.TypeRef; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; /** - * Creates a bidirectional in-memory transport pair for testing client ↔ agent communication - * without real processes or network connections. + * Two connected transports, {@link #clientTransport()} and {@link #agentTransport()}, that link an + * ACP client and an agent inside one JVM for tests: build a client on one and an agent on the + * other, and they talk without a process or a network. Use it with {@link MockAcpAgent} or + * {@link MockAcpClient}, or with a real client and agent from + * {@link com.agentclientprotocol.sdk.client.AcpClient} and + * {@link com.agentclientprotocol.sdk.agent.AcpAgent}. Messages pass between the two as Java objects + * and are never written as JSON, so a test of what goes over the wire needs a real transport + * instead. * - *

    - * This class provides connected client and agent transports that communicate through - * in-memory sinks, enabling: - *

    - *
      - *
    • Unit testing of protocol logic without I/O
    • - *
    • Fast, deterministic tests
    • - *
    • Testing both client and agent sides in isolation or together
    • - *
    + *

    Each side receives what the other sends, in order. A message sent before the other side has + * started waits until it starts, so the client and the agent can be built and started in either + * order. Both transports accept sends from several threads at once. Each carries one connection: a + * second connect or start fails with {@link IllegalStateException}, so use a new pair for every + * client and agent. Params and results are converted to the records the SDK expects with a mapper + * from {@link AcpJsonMapper#createDefault()}, created for each conversion, so a JSON module must be + * on the classpath; {@code acp-test} brings in {@code acp-json-jackson2}. + * + *

    Closing one side ends the other side's input, but neither transport reports that as its end: + * the agent transport's {@code awaitTermination()} completes only once the agent side is closed (so + * {@code AcpSyncAgent.run()} keeps running after the client closes), and the client transport never + * reports termination, so a request the agent leaves unanswered when its transport closes fails + * only at the client's timeout. Close both sides, or call {@link #closeGracefully()}, at the end of + * a test. * - *

    - * Example usage: - *

    *
    {@code
      * InMemoryTransportPair pair = InMemoryTransportPair.create();
      *
    - * // Use client transport in client code
    - * AcpClientTransport clientTransport = pair.clientTransport();
    + * AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport())
    + *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
    + *     .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null))
    + *     .promptHandler((request, context) -> {
    + *         context.sendMessage("Hello");
    + *         return AcpSchema.PromptResponse.endTurn();
    + *     })
    + *     .build();
    + * agent.start();
      *
    - * // Use agent transport in agent code
    - * AcpAgentTransport agentTransport = pair.agentTransport();
    + * AcpSyncClient client = AcpClient.sync(pair.clientTransport()).build();
    + * client.initialize();
    + * String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))
    + *     .sessionId();
    + * AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(sessionId,
    + *     List.of(new AcpSchema.TextContent("Hi"))));
      *
    - * // Messages sent by client arrive at agent, and vice versa
    + * client.close();
    + * agent.close();
      * }
    * * @author Mark Pollack @@ -55,41 +77,45 @@ public class InMemoryTransportPair { private final InMemoryAgentTransport agentTransport; private InMemoryTransportPair() { - // Bidirectional sinks: client→agent and agent→client - Sinks.Many clientToAgent = Sinks.many().unicast().onBackpressureBuffer(); - Sinks.Many agentToClient = Sinks.many().unicast().onBackpressureBuffer(); + Channel clientToAgent = new Channel(); + Channel agentToClient = new Channel(); this.clientTransport = new InMemoryClientTransport(clientToAgent, agentToClient); this.agentTransport = new InMemoryAgentTransport(agentToClient, clientToAgent); } /** - * Creates a new transport pair with connected client and agent transports. - * @return a new InMemoryTransportPair + * Creates a pair of connected transports, neither of them connected or started yet. + * @return a new pair */ public static InMemoryTransportPair create() { return new InMemoryTransportPair(); } /** - * Gets the client-side transport. - * @return the client transport + * Returns the client side of the pair. Pass it to {@code AcpClient.sync(...)}, + * {@code AcpClient.async(...)} or {@link MockAcpClient#builder}; it can be connected once. + * @return the client transport, the same instance on every call */ public AcpClientTransport clientTransport() { return clientTransport; } /** - * Gets the agent-side transport. - * @return the agent transport + * Returns the agent side of the pair. Pass it to {@code AcpAgent.sync(...)}, + * {@code AcpAgent.async(...)} or {@link MockAcpAgent#builder}; it can be started once. + * @return the agent transport, the same instance on every call */ public AcpAgentTransport agentTransport() { return agentTransport; } /** - * Closes both transports gracefully. - * @return a Mono that completes when both transports are closed + * Closes both transports: each stops sending to the other, and the agent transport's + * {@code awaitTermination()} completes. Closing the client and the agent built on the pair + * closes their transports as well; this method is a final clean-up for a test, and can be + * called more than once. + * @return a {@code Mono} that completes when both transports are closed */ public Mono closeGracefully() { return Mono.when(clientTransport.closeGracefully(), agentTransport.closeGracefully()); @@ -97,20 +123,56 @@ public Mono closeGracefully() { /** - * Emits on a sink that several threads share. {@code Sinks.many()} sinks reject a - * concurrent emission from another thread with {@code FAIL_NON_SERIALIZED}; the busy - * loop serialises emitters the same way the shipped transports do. + * One direction of the pair, which several threads send on. A {@code Sinks.many()} sink + * refuses a message while another thread is emitting on it ({@code FAIL_NON_SERIALIZED}), + * and that thread can hold it for as long as the receiver takes, since delivery runs on + * the emitting thread. So a send is queued, and whichever thread finds the sink free emits + * the queue in order: no message is dropped and no sender waits, including a receiver that + * sends from inside a delivery. */ - private static Mono emit(Sinks.Many sink, AcpSchema.JSONRPCMessage message, - String what) { - return Mono.fromRunnable(() -> { - try { - sink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - } - catch (Sinks.EmissionException e) { - throw new RuntimeException("Failed to send " + what + ": " + e.getReason(), e); - } - }); + private static final class Channel { + + private final Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + + private final Queue pending = new ConcurrentLinkedQueue<>(); + + private final AtomicInteger emitters = new AtomicInteger(); + + Flux asFlux() { + return sink.asFlux(); + } + + void complete() { + sink.tryEmitComplete(); + } + + Mono send(AcpSchema.JSONRPCMessage message, String what) { + return Mono.fromRunnable(() -> { + pending.offer(message); + if (emitters.getAndIncrement() != 0) { + return; // the thread emitting now emits this message too + } + // A failure means the channel is closed, so it is reported to this sender. + Sinks.EmitResult sent = Sinks.EmitResult.OK; + int missed = 1; + do { + AcpSchema.JSONRPCMessage next; + while ((next = pending.poll()) != null) { + Sinks.EmitResult result = sink.tryEmitNext(next); + if (result.isFailure()) { + sent = result; + } + } + missed = emitters.addAndGet(-missed); + } + while (missed != 0); + if (sent.isFailure()) { + throw new RuntimeException("Failed to send " + what + ": " + sent, + new Sinks.EmissionException(sent)); + } + }); + } + } /** @@ -118,26 +180,20 @@ private static Mono emit(Sinks.Many sink, AcpSch */ private static class InMemoryClientTransport implements AcpClientTransport { - private final Sinks.Many outbound; + private final Channel outbound; - private final Sinks.Many inbound; + private final Channel inbound; private volatile boolean connected = false; private Consumer exceptionHandler = t -> { }; - InMemoryClientTransport(Sinks.Many outbound, - Sinks.Many inbound) { + InMemoryClientTransport(Channel outbound, Channel inbound) { this.outbound = outbound; this.inbound = inbound; } - @Override - public List protocolVersions() { - return List.of(AcpSchema.LATEST_PROTOCOL_VERSION); - } - @Override public Mono connect(Function, Mono> handler) { if (connected) { @@ -146,21 +202,21 @@ public Mono connect(Function, Mono Mono.just(message).transform(handler)) - .doOnError(exceptionHandler::accept) + .doOnError(error -> exceptionHandler.accept(error)) .doFinally(signal -> connected = false) .then(); } @Override public Mono sendMessage(AcpSchema.JSONRPCMessage message) { - return emit(outbound, message, "message"); + return outbound.send(message, "message"); } @Override public Mono closeGracefully() { return Mono.defer(() -> { connected = false; - outbound.tryEmitComplete(); + outbound.complete(); return Mono.empty(); }); } @@ -182,9 +238,9 @@ public T unmarshalFrom(Object data, TypeRef typeRef) { */ private static class InMemoryAgentTransport implements AcpAgentTransport { - private final Sinks.Many outbound; + private final Channel outbound; - private final Sinks.Many inbound; + private final Channel inbound; private final Sinks.One terminationSink = Sinks.one(); @@ -193,17 +249,11 @@ private static class InMemoryAgentTransport implements AcpAgentTransport { private Consumer exceptionHandler = t -> { }; - InMemoryAgentTransport(Sinks.Many outbound, - Sinks.Many inbound) { + InMemoryAgentTransport(Channel outbound, Channel inbound) { this.outbound = outbound; this.inbound = inbound; } - @Override - public List protocolVersions() { - return List.of(AcpSchema.LATEST_PROTOCOL_VERSION); - } - @Override public Mono start(Function, Mono> handler) { if (started) { @@ -213,28 +263,28 @@ public Mono start(Function, Mono Mono.just(message) .transform(handler) - .flatMap(response -> emit(outbound, response, "response")) + .flatMap(response -> outbound.send(response, "response")) // A failed emission is this message's problem, not the transport's: it // must not terminate the inbound subscription and kill the agent. .onErrorResume(error -> { exceptionHandler.accept(error); return Mono.empty(); })) - .doOnError(exceptionHandler::accept) + .doOnError(error -> exceptionHandler.accept(error)) .doFinally(signal -> started = false) .then(); } @Override public Mono sendMessage(AcpSchema.JSONRPCMessage message) { - return emit(outbound, message, "message"); + return outbound.send(message, "message"); } @Override public Mono closeGracefully() { return Mono.defer(() -> { started = false; - outbound.tryEmitComplete(); + outbound.complete(); terminationSink.tryEmitValue(null); return Mono.empty(); }); diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpAgent.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpAgent.java index 7018278c..2158db7b 100644 --- a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpAgent.java +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpAgent.java @@ -16,34 +16,45 @@ import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; import com.agentclientprotocol.sdk.spec.AcpAgentTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Mono; /** - * A mock ACP agent for testing client implementations. + * A scripted ACP agent for testing client code: it answers {@code initialize}, {@code session/new} + * and {@code session/prompt} with the responses set on its {@link Builder}, and records every + * request it receives so a test can check what the client sent. Put it on the agent side of an + * {@link InMemoryTransportPair}, or on any {@link AcpAgentTransport}, and call {@link #start()}. To + * test an agent instead, use {@link MockAcpClient}. * - *

    - * This mock provides a configurable agent that: - *

      - *
    • Records all received requests for verification
    • - *
    • Allows customizing responses via response providers
    • - *
    • Supports synchronous waiting for specific operations
    • - *
    + *

    Without builder settings it answers {@code initialize} with protocol version 1, no agent + * capabilities and no authentication methods, {@code session/new} with the session ID + * {@code "mock-session"}, and every prompt with stop reason {@code end_turn}. It accepts + * {@code session/cancel} and records it. Every other request is answered {@code -32601} (method not + * found), even when a custom {@code initialize} answer advertises the capability. Send session + * updates with {@link #sendSessionUpdate}; for other calls to the client, use the agent it wraps, + * from {@link #async()}. + * + *

    The {@code getReceived...} methods return copies of what has arrived so far, oldest first. To + * wait for prompts, call {@link #expectPrompts(int)} before the client sends them, then + * {@link #awaitPrompts(Duration)}. The methods may be called from any thread while the client runs. * - *

    - * Example usage: *

    {@code
    - * InMemoryTransportPair transportPair = InMemoryTransportPair.create();
    - * MockAcpAgent mockAgent = MockAcpAgent.builder(transportPair.agentTransport())
    - *     .initializeResponse(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))
    - *     .promptResponse(request -> new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))
    + * InMemoryTransportPair pair = InMemoryTransportPair.create();
    + * MockAcpAgent agent = MockAcpAgent.builder(pair.agentTransport())
    + *     .promptResponse(request -> AcpSchema.PromptResponse.refusal())
      *     .build();
    + * agent.start();
      *
    - * mockAgent.start();
    - *
    - * // ... test client operations ...
    + * AcpSyncClient client = AcpClient.sync(pair.clientTransport()).build();
    + * client.initialize();
    + * String sessionId = client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of()))
    + *     .sessionId();                                       // "mock-session"
    + * AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest(sessionId,
    + *     List.of(new AcpSchema.TextContent("Delete everything"))));
      *
    - * assertThat(mockAgent.getReceivedPrompts()).hasSize(1);
    - * mockAgent.close();
    + * List prompts = agent.getReceivedPrompts(); // the one prompt sent
    + * client.close();
    + * agent.close();
      * }
    * * @author Mark Pollack @@ -64,118 +75,166 @@ public class MockAcpAgent { private final AtomicReference promptLatch = new AtomicReference<>(new CountDownLatch(0)); - private MockAcpAgent(AcpAsyncAgent delegate) { - this.delegate = delegate; + private MockAcpAgent(Builder builder) { + AcpSchema.InitializeResponse initializeResponse = builder.initializeResponse; + AcpSchema.NewSessionResponse newSessionResponse = builder.newSessionResponse; + Function promptResponseProvider = builder.promptResponseProvider; + // The handlers record into this mock's fields, which are initialized before this body runs + this.delegate = AcpAgent.async(builder.transport) + .requestTimeout(builder.requestTimeout) + .initializeHandler(request -> { + receivedInitRequests.add(request); + return Mono.just(initializeResponse); + }) + .newSessionHandler(request -> { + receivedNewSessionRequests.add(request); + return Mono.just(newSessionResponse); + }) + .promptHandler((request, updater) -> { + receivedPrompts.add(request); + promptLatch.get().countDown(); + return Mono.just(promptResponseProvider.apply(request)); + }) + .cancelHandler(notification -> { + receivedCancellations.add(notification); + return Mono.empty(); + }) + .build(); } /** - * Creates a builder for configuring the mock agent. - * @param transport The agent transport - * @return A new builder instance + * Starts a builder for a mock agent on the given transport, with the default responses. + * @param transport the agent transport, usually {@link InMemoryTransportPair#agentTransport()}; + * must not be null + * @return a new builder */ public static Builder builder(AcpAgentTransport transport) { return new Builder(transport); } /** - * Creates a simple mock agent with default responses. - * @param transport The agent transport - * @return A new mock agent with default behavior + * Creates a mock agent with the default responses (see the class comment), not yet started. The + * same as {@code builder(transport).build()}. + * @param transport the agent transport + * @return a new mock agent + * @throws IllegalArgumentException if {@code transport} is null */ public static MockAcpAgent createDefault(AcpAgentTransport transport) { return builder(transport).build(); } /** - * Starts the mock agent. + * Starts the agent, which starts its transport; from then on it answers the client. It returns + * once the agent is started and does not wait for the client. Call it once: a failure to start, + * such as a transport that was started before, is logged at WARN under this class's logger, not + * thrown. */ public void start() { - delegate.start().subscribe(); + delegate.start() + .subscribe(ignored -> { + }, error -> LoggerFactory.getLogger(MockAcpAgent.class).warn("Mock agent failed to start", error)); } /** - * Sets a latch to wait for a specific number of prompts. - * @param count The number of prompts to wait for + * Sets how many prompts {@link #awaitPrompts} waits for, counting from this call: prompts that + * arrived before it do not count. Call it before the client sends them. Until it is called, + * {@code awaitPrompts} returns {@code true} at once. + * @param count the number of prompts to wait for + * @throws IllegalArgumentException if {@code count} is negative */ public void expectPrompts(int count) { promptLatch.set(new CountDownLatch(count)); } /** - * Waits for expected prompts to be received. - * @param timeout The maximum time to wait - * @return true if all expected prompts were received - * @throws InterruptedException if interrupted while waiting + * Waits until the prompts set by the last {@link #expectPrompts} call have arrived, or the + * timeout passes. A prompt counts when it reaches the mock, before its answer is sent. + * @param timeout the longest to wait + * @return {@code true} if they arrived, {@code false} if the timeout passed first + * @throws InterruptedException if the thread is interrupted while waiting */ public boolean awaitPrompts(Duration timeout) throws InterruptedException { return promptLatch.get().await(timeout.toMillis(), TimeUnit.MILLISECONDS); } /** - * Gets all received initialize requests. - * @return The list of initialize requests + * Returns the {@code initialize} requests received so far, oldest first. + * @return a copy, which later requests do not change */ public List getReceivedInitRequests() { return List.copyOf(receivedInitRequests); } /** - * Gets all received new session requests. - * @return The list of new session requests + * Returns the {@code session/new} requests received so far, oldest first. + * @return a copy, which later requests do not change */ public List getReceivedNewSessionRequests() { return List.copyOf(receivedNewSessionRequests); } /** - * Gets all received prompts. - * @return The list of prompts + * Returns the {@code session/prompt} requests received so far, oldest first, from every + * session. + * @return a copy, which later prompts do not change */ public List getReceivedPrompts() { return List.copyOf(receivedPrompts); } /** - * Gets all received cancellation notifications. - * @return The list of cancellations + * Returns the {@code session/cancel} notifications received so far, oldest first. + * @return a copy, which later notifications do not change */ public List getReceivedCancellations() { return List.copyOf(receivedCancellations); } /** - * Sends a session update notification to the client. - * @param sessionId The session ID - * @param update The update to send + * Sends a {@code session/update} notification to the client, during a prompt or between + * prompts, and blocks until it is handed to the transport, at most 10 seconds. The session ID + * is not checked. + * @param sessionId the ACP session the update belongs to + * @param update the update, such as an {@code AgentMessageChunk} + * @throws IllegalStateException if the agent is not started, or the send takes longer than 10 + * seconds */ public void sendSessionUpdate(String sessionId, AcpSchema.SessionUpdate update) { delegate.sendSessionUpdate(sessionId, update).block(DEFAULT_TIMEOUT); } /** - * Returns the underlying async agent. - * @return The async agent + * Returns the agent the mock wraps, for calls the mock does not offer: permission, file, + * terminal and extension requests to the client, or the client's capabilities. + * @return the underlying agent */ public AcpAsyncAgent async() { return delegate; } /** - * Closes the mock agent gracefully. + * Closes the agent gracefully and blocks until its transport has closed, at most 10 seconds: + * requests still being handled are answered as cancelled ({@code -32800}). See + * {@link AcpAsyncAgent#closeGracefully()}. + * @throws IllegalStateException if closing takes longer than 10 seconds */ public void closeGracefully() { delegate.closeGracefully().block(DEFAULT_TIMEOUT); } /** - * Closes the mock agent immediately. + * Closes the agent and its transport at once, without waiting. See + * {@link AcpAsyncAgent#close()}. */ public void close() { delegate.close(); } /** - * Builder for MockAcpAgent. + * Configures a {@link MockAcpAgent}: its answers to {@code initialize}, {@code session/new} and + * {@code session/prompt}, and the timeout of its requests to the client. Every setting has a + * default (see {@link MockAcpAgent}). Get one from {@link MockAcpAgent#builder}; + * {@link #build()} creates the mock, not yet started. */ public static class Builder { @@ -198,9 +257,12 @@ private Builder(AcpAgentTransport transport) { } /** - * Sets the response for initialize requests. - * @param response The initialize response - * @return This builder + * Sets the answer to every {@code initialize} request. Capabilities it advertises are only + * advertised: the mock still serves only {@code initialize}, {@code session/new}, + * {@code session/prompt} and {@code session/cancel}. + * @param response the answer; must not be null. Default: protocol version 1, no + * capabilities, no authentication methods + * @return this builder */ public Builder initializeResponse(AcpSchema.InitializeResponse response) { this.initializeResponse = response; @@ -208,9 +270,10 @@ public Builder initializeResponse(AcpSchema.InitializeResponse response) { } /** - * Sets the response for new session requests. - * @param response The new session response - * @return This builder + * Sets the answer to every {@code session/new} request, so every session gets the same ID. + * @param response the answer; must not be null. Default: session ID {@code "mock-session"}, + * no modes and no config options + * @return this builder */ public Builder newSessionResponse(AcpSchema.NewSessionResponse response) { this.newSessionResponse = response; @@ -218,9 +281,13 @@ public Builder newSessionResponse(AcpSchema.NewSessionResponse response) { } /** - * Sets a function to generate prompt responses. - * @param provider The prompt response provider - * @return This builder + * Sets the function that answers each prompt, given the request. It runs after the prompt + * is recorded. An exception it throws answers the prompt with an error: an + * {@code AcpProtocolException} with its own code, anything else with {@code -32603} + * (internal error). + * @param provider the function; must not be null or return null. Default: stop reason + * {@code end_turn} + * @return this builder */ public Builder promptResponse(Function provider) { this.promptResponseProvider = provider; @@ -228,9 +295,13 @@ public Builder promptResponse(Function { - mockAgent.receivedInitRequests.add(request); - return Mono.just(initializeResponse); - }) - .newSessionHandler(request -> { - mockAgent.receivedNewSessionRequests.add(request); - return Mono.just(newSessionResponse); - }) - .promptHandler((request, updater) -> { - mockAgent.receivedPrompts.add(request); - mockAgent.promptLatch.get().countDown(); - return Mono.just(promptResponseProvider.apply(request)); - }) - .cancelHandler(notification -> { - mockAgent.receivedCancellations.add(notification); - return Mono.empty(); - }) - .build(); - - // Replace the delegate using reflection (a bit ugly but avoids circular reference issues) - try { - var field = MockAcpAgent.class.getDeclaredField("delegate"); - field.setAccessible(true); - field.set(mockAgent, delegate); - } - catch (Exception e) { - throw new RuntimeException("Failed to set delegate", e); - } - - return mockAgent; + return new MockAcpAgent(this); } } diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpClient.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpClient.java index 8158ca48..af07b66c 100644 --- a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpClient.java +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/MockAcpClient.java @@ -15,36 +15,58 @@ import com.agentclientprotocol.sdk.client.AcpClient; import com.agentclientprotocol.sdk.spec.AcpClientTransport; import com.agentclientprotocol.sdk.spec.AcpSchema; +import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; /** - * A mock ACP client for testing agent implementations. + * A scripted ACP client for testing agent code: it answers the agent's permission and file requests + * with the responses set on its {@link Builder}, records those requests and every session update it + * receives, and drives the agent with blocking calls ({@link #initialize()}, + * {@link #newSession(String)}, {@link #prompt(String)}). Put it on the client side of an + * {@link InMemoryTransportPair}, or on any {@link AcpClientTransport}. To test a client instead, + * use {@link MockAcpAgent}. * - *

    - * This mock provides: - *

      - *
    • Automatic handling of agent→client requests (permissions, file ops)
    • - *
    • Recording of all received session updates
    • - *
    • Configurable response handlers
    • - *
    • Synchronous waiting for specific operations
    • - *
    + *

    How it differs from {@link MockAcpAgent}: it needs no start, because building it connects its + * transport. It advertises file reading and writing and nothing else, so on the agent side a + * terminal or elicitation call fails with + * {@link com.agentclientprotocol.sdk.error.AcpCapabilityException} without being sent, and an + * extension request is answered {@code -32601} (method not found). Without builder settings it + * answers every permission request with the option ID {@code "allow"}, whatever options the agent + * offered; every file read with the content {@code "// Mock file content"}; and every file write + * with an empty answer, writing nothing. Its blocking calls, prompts included, wait at most the + * builder's request timeout, 10 seconds by default. + * + *

    When {@link #prompt(String)} returns, the turn's session updates have all been received, + * because the client hands them over before the prompt's answer, so a test can read + * {@link #getReceivedUpdates()} at once. To wait for updates sent between prompts, call + * {@link #expectUpdates(int)} before they are sent, then {@link #awaitUpdates(Duration)}. The + * {@code getReceived...} methods return copies, oldest first, and all methods may be called from + * any thread. * - *

    - * Example usage: *

    {@code
    - * InMemoryTransportPair transportPair = InMemoryTransportPair.create();
    - * MockAcpClient mockClient = MockAcpClient.builder(transportPair.clientTransport())
    - *     .permissionResponse(request -> new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected("allow")))
    - *     .fileContent("/path/to/file.txt", "file contents")
    + * InMemoryTransportPair pair = InMemoryTransportPair.create();
    + * AcpSyncAgent agent = AcpAgent.sync(pair.agentTransport())
    + *     .initializeHandler(request -> AcpSchema.InitializeResponse.ok())
    + *     .newSessionHandler(request -> new AcpSchema.NewSessionResponse("session-1", null, null))
    + *     .promptHandler((request, context) -> {
    + *         String source = context.readFile("/src/Main.java");
    + *         context.sendMessage("Main.java has " + source.length() + " characters");
    + *         return AcpSchema.PromptResponse.endTurn();
    + *     })
      *     .build();
    + * agent.start();
      *
    - * mockClient.initialize();
    - * mockClient.newSession("/workspace");
    - *
    - * // Agent operations will automatically receive mock responses
    + * MockAcpClient client = MockAcpClient.builder(pair.clientTransport())
    + *     .fileContent("/src/Main.java", "class Main {}")
    + *     .build();
    + * client.initialize();
    + * client.newSession("/workspace");
    + * AcpSchema.PromptResponse response = client.prompt("Count the characters");
      *
    - * assertThat(mockClient.getReceivedUpdates()).isNotEmpty();
    - * mockClient.close();
    + * List updates = client.getReceivedUpdates(); // the agent's message
    + * List reads = client.getReceivedFileReadRequests(); // one read
    + * client.close();
    + * agent.close();
      * }
    * * @author Mark Pollack @@ -55,8 +77,6 @@ public class MockAcpClient { private final AcpAsyncClient delegate; - private final AcpClientTransport transport; - private final Duration timeout; private final List receivedUpdates = new CopyOnWriteArrayList<>(); @@ -69,175 +89,240 @@ public class MockAcpClient { private volatile CountDownLatch updateLatch = new CountDownLatch(0); - private volatile String currentSessionId; - - private MockAcpClient(AcpAsyncClient delegate, AcpClientTransport transport, Duration timeout) { - this.delegate = delegate; - this.transport = transport; - this.timeout = timeout; + private volatile @Nullable String currentSessionId; + + private MockAcpClient(Builder builder) { + this.timeout = builder.requestTimeout; + Function permissionHandler = builder.permissionHandler; + Function readFileHandler = builder.readFileHandler; + Function writeFileHandler = builder.writeFileHandler; + // The handlers record into this mock's fields, which are initialized before this body runs + this.delegate = AcpClient.async(builder.transport) + // The mock client advertises what it has handlers for: file read and write. It has no + // terminal handlers, so it does not advertise terminal. + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, true), false)) + .requestTimeout(builder.requestTimeout) + .sessionUpdateHandler(notification -> { + receivedUpdates.add(notification); + updateLatch.countDown(); + return Mono.empty(); + }) + // Using typed handlers (no manual unmarshalling needed) + .requestPermissionHandler((AcpSchema.RequestPermissionRequest request) -> { + receivedPermissionRequests.add(request); + return Mono.just(permissionHandler.apply(request)); + }) + .readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> { + receivedFileReadRequests.add(request); + return Mono.just(readFileHandler.apply(request)); + }) + .writeTextFileHandler((AcpSchema.WriteTextFileRequest request) -> { + receivedFileWriteRequests.add(request); + return Mono.just(writeFileHandler.apply(request)); + }) + .build(); } /** - * Creates a builder for configuring the mock client. - * @param transport The client transport - * @return A new builder instance + * Starts a builder for a mock client on the given transport, with the default answers. + * @param transport the client transport, usually + * {@link InMemoryTransportPair#clientTransport()}; must not be null + * @return a new builder */ public static Builder builder(AcpClientTransport transport) { return new Builder(transport); } /** - * Creates a simple mock client with default handlers. - * @param transport The client transport - * @return A new mock client with default behavior + * Creates a mock client with the default answers (see the class comment) and connects its + * transport. The same as {@code builder(transport).build()}. + * @param transport the client transport + * @return a new mock client + * @throws IllegalArgumentException if {@code transport} is null + * @throws IllegalStateException if the transport was connected before */ public static MockAcpClient createDefault(AcpClientTransport transport) { return builder(transport).build(); } /** - * Initializes the connection with the agent. - * Advertises all capabilities (file read, file write, terminal) by default. - * @return The initialize response + * Sends {@code initialize} and blocks for the agent's answer. The client advertises file + * reading and writing, which the mock handles, and no terminal or elicitation. + * @return the agent's answer + * @throws com.agentclientprotocol.sdk.spec.AcpError if the agent answers with an error */ public AcpSchema.InitializeResponse initialize() { - // Mock client advertises all capabilities by default - AcpSchema.FileSystemCapability fs = new AcpSchema.FileSystemCapability(true, true); - AcpSchema.ClientCapabilities caps = new AcpSchema.ClientCapabilities(fs, true); - return delegate.initialize(new AcpSchema.InitializeRequest(1, caps)).block(timeout); + return await(delegate.initialize()); } /** - * Creates a new session with the specified working directory. - * @param cwd The working directory - * @return The new session response + * Creates a session in the given working directory, with no MCP servers, and makes it the + * current session for {@link #prompt(String)} and {@link #cancel()}. + * @param cwd the working directory, an absolute path in ACP + * @return the agent's answer, with the session ID + * @throws IllegalStateException if {@link #initialize()} has not been answered + * @throws com.agentclientprotocol.sdk.spec.AcpError if the agent answers with an error */ public AcpSchema.NewSessionResponse newSession(String cwd) { - AcpSchema.NewSessionResponse response = delegate.newSession(new AcpSchema.NewSessionRequest(cwd, List.of())) - .block(timeout); - if (response != null) { - this.currentSessionId = response.sessionId(); - } + AcpSchema.NewSessionResponse response = await( + delegate.newSession(new AcpSchema.NewSessionRequest(cwd, List.of()))); + this.currentSessionId = response.sessionId(); return response; } /** - * Sends a text prompt to the current session. - * @param text The prompt text - * @return The prompt response + * Sends a text prompt to the current session and blocks until the agent answers it, at the end + * of the turn. + * @param text the prompt text + * @return the agent's answer, with the stop reason + * @throws IllegalStateException if {@link #newSession(String)} has not been called, or no + * answer arrives within the request timeout + * @throws com.agentclientprotocol.sdk.spec.AcpError if the agent answers with an error */ public AcpSchema.PromptResponse prompt(String text) { - if (currentSessionId == null) { - throw new IllegalStateException("No session created. Call newSession() first."); - } - return delegate - .prompt(new AcpSchema.PromptRequest(currentSessionId, List.of(new AcpSchema.TextContent(text)))) - .block(timeout); + return prompt(requireSessionId(), text); } /** - * Sends a prompt to a specific session. - * @param sessionId The session ID - * @param text The prompt text - * @return The prompt response + * Sends a text prompt to the given session and blocks until the agent answers it, at the end of + * the turn. When the request timeout passes first, the call fails and the client sends the + * agent {@code $/cancel_request}, which makes a Java agent cancel the turn. It does not change + * the current session. + * @param sessionId the ACP session to prompt + * @param text the prompt text + * @return the agent's answer, with the stop reason + * @throws IllegalStateException if {@link #initialize()} has not been answered, or no answer + * arrives within the request timeout + * @throws com.agentclientprotocol.sdk.spec.AcpError if the agent answers with an error */ public AcpSchema.PromptResponse prompt(String sessionId, String text) { - return delegate.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text)))) - .block(timeout); + return await(delegate.prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent(text))))); } /** - * Cancels operations for the current session. + * Sends {@code session/cancel} for the current session and returns once it is handed to the + * transport. The agent then ends the turn and answers the waiting prompt, normally with stop + * reason {@code cancelled}. Since {@link #prompt(String)} blocks, call this from another + * thread. + * @throws IllegalStateException if {@link #newSession(String)} has not been called */ public void cancel() { - if (currentSessionId == null) { + delegate.cancel(new AcpSchema.CancelNotification(requireSessionId())).block(timeout); + } + + private String requireSessionId() { + String sessionId = this.currentSessionId; + if (sessionId == null) { throw new IllegalStateException("No session created. Call newSession() first."); } - delegate.cancel(new AcpSchema.CancelNotification(currentSessionId)).block(timeout); + return sessionId; + } + + /** Blocks for a response; a request's Mono emits its response or fails, never completes empty. */ + private T await(Mono response) { + T value = response.block(timeout); + if (value == null) { + throw new IllegalStateException("ACP request completed without a response"); + } + return value; } /** - * Sets a latch to wait for a specific number of updates. - * @param count The number of updates to wait for + * Sets how many session updates {@link #awaitUpdates} waits for, counting from this call: + * updates that arrived before it do not count. Call it before the agent sends them. Until it is + * called, {@code awaitUpdates} returns {@code true} at once. + * @param count the number of updates to wait for + * @throws IllegalArgumentException if {@code count} is negative */ public void expectUpdates(int count) { updateLatch = new CountDownLatch(count); } /** - * Waits for expected updates to be received. - * @param timeout The maximum time to wait - * @return true if all expected updates were received - * @throws InterruptedException if interrupted while waiting + * Waits until the session updates set by the last {@link #expectUpdates} call have arrived, or + * the timeout passes. + * @param timeout the longest to wait + * @return {@code true} if they arrived, {@code false} if the timeout passed first + * @throws InterruptedException if the thread is interrupted while waiting */ public boolean awaitUpdates(Duration timeout) throws InterruptedException { return updateLatch.await(timeout.toMillis(), TimeUnit.MILLISECONDS); } /** - * Gets all received session updates. - * @return The list of updates + * Returns the {@code session/update} notifications received so far, oldest first, from every + * session. + * @return a copy, which later updates do not change */ public List getReceivedUpdates() { return List.copyOf(receivedUpdates); } /** - * Gets all received permission requests. - * @return The list of permission requests + * Returns the {@code session/request_permission} requests received so far, oldest first. + * @return a copy, which later requests do not change */ public List getReceivedPermissionRequests() { return List.copyOf(receivedPermissionRequests); } /** - * Gets all received file read requests. - * @return The list of file read requests + * Returns the {@code fs/read_text_file} requests received so far, oldest first. + * @return a copy, which later requests do not change */ public List getReceivedFileReadRequests() { return List.copyOf(receivedFileReadRequests); } /** - * Gets all received file write requests. - * @return The list of file write requests + * Returns the {@code fs/write_text_file} requests received so far, oldest first, with the + * content the agent wanted written. + * @return a copy, which later requests do not change */ public List getReceivedFileWriteRequests() { return List.copyOf(receivedFileWriteRequests); } /** - * Gets the current session ID. - * @return The session ID, or null if no session created + * Returns the ID of the session the last {@link #newSession(String)} call created. + * @return the session ID, or {@code null} before the first {@code newSession} */ - public String getCurrentSessionId() { + public @Nullable String getCurrentSessionId() { return currentSessionId; } /** - * Returns the underlying async client. - * @return The async client + * Returns the client the mock wraps, for calls the mock does not offer: loading or resuming a + * session, config options, extension methods, or prompts with other content. Sessions created + * through it do not change the current session. + * @return the underlying client */ public AcpAsyncClient async() { return delegate; } /** - * Closes the mock client gracefully. + * Closes the client gracefully and blocks until its transport has closed, at most the request + * timeout. See {@link AcpAsyncClient#closeGracefully()}. + * @throws IllegalStateException if closing takes longer than the request timeout */ public void closeGracefully() { delegate.closeGracefully().block(timeout); } /** - * Closes the mock client immediately. + * Closes the client and its transport at once, without waiting. See + * {@link AcpAsyncClient#close()}. */ public void close() { delegate.close(); } /** - * Builder for MockAcpClient. + * Configures a {@link MockAcpClient}: its answers to the agent's permission and file requests, + * and how long its calls wait. Every setting has a default (see {@link MockAcpClient}). Get one + * from {@link MockAcpClient#builder}; {@link #build()} creates the mock and connects its + * transport. */ public static class Builder { @@ -261,9 +346,17 @@ private Builder(AcpClientTransport transport) { } /** - * Sets a function to handle permission requests. - * @param handler The permission handler - * @return This builder + * Sets the function that answers each {@code session/request_permission}, given the + * request; it runs after the request is recorded. To choose one of the offered options, + * read them from {@code request.options()}. The default, option ID {@code "allow"}, matches + * the option that + * {@link com.agentclientprotocol.sdk.agent.PromptContext#askPermission(String) PromptContext.askPermission} + * offers, so that returns {@code true}; {@code askChoice}, whose options are numbered, + * fails on it. An exception the function throws answers the agent with an error: an + * {@code AcpProtocolException} with its own code, anything else with {@code -32603} + * (internal error). + * @param handler the function; must not be null or return null + * @return this builder */ public Builder permissionResponse( Function handler) { @@ -272,9 +365,11 @@ public Builder permissionResponse( } /** - * Sets a function to handle file read requests. - * @param handler The file read handler - * @return This builder + * Sets the function that answers every {@code fs/read_text_file} request, replacing the + * default and any {@link #fileContent} set before. + * @param handler the function; must not be null or return null. Default: the content + * {@code "// Mock file content"} for every path + * @return this builder */ public Builder readFileResponse( Function handler) { @@ -283,10 +378,14 @@ public Builder readFileResponse( } /** - * Configures a specific file path to return specific content. - * @param path The file path - * @param content The file content - * @return This builder + * Answers reads of one path with the given content; reads of other paths go to the answer + * set before (the default, {@link #readFileResponse}, or earlier {@code fileContent} + * calls). The path is compared exactly as the agent sends it, and the whole content is + * returned whatever line and limit the request asks for. A later {@code readFileResponse} + * replaces it, so call that first. + * @param path the file path + * @param content the content to return + * @return this builder */ public Builder fileContent(String path, String content) { Function previous = this.readFileHandler; @@ -300,9 +399,10 @@ public Builder fileContent(String path, String content) { } /** - * Sets a function to handle file write requests. - * @param handler The file write handler - * @return This builder + * Sets the function that answers every {@code fs/write_text_file} request. The mock writes + * no file; the requests are recorded. + * @param handler the function; must not be null or return null. Default: an empty answer + * @return this builder */ public Builder writeFileResponse( Function handler) { @@ -311,9 +411,14 @@ public Builder writeFileResponse( } /** - * Sets the request timeout. - * @param timeout The timeout - * @return This builder + * Sets how long the mock's blocking calls wait for an answer, prompts included, and the + * client's request timeout ({@link AcpClient.AsyncSpec#requestTimeout(Duration)}). A call + * that gets no answer in time fails with an unchecked exception: + * {@link IllegalStateException} when the mock's own wait ends first, as it does for a + * prompt, or Reactor's wrapper around the client's + * {@link java.util.concurrent.TimeoutException}. + * @param timeout the timeout; default 10 seconds + * @return this builder */ public Builder requestTimeout(Duration timeout) { this.requestTimeout = timeout; @@ -321,46 +426,13 @@ public Builder requestTimeout(Duration timeout) { } /** - * Builds the mock client. - * @return The configured mock client + * Builds the mock client and connects its transport. + * @return the mock client + * @throws IllegalArgumentException if the transport or the request timeout is null + * @throws IllegalStateException if the transport was connected before */ public MockAcpClient build() { - // We need to create the mock first to capture updates - MockAcpClient mockClient = new MockAcpClient(null, transport, requestTimeout); - - AcpAsyncClient delegate = AcpClient.async(transport) - .requestTimeout(requestTimeout) - .sessionUpdateConsumer(notification -> { - mockClient.receivedUpdates.add(notification); - mockClient.updateLatch.countDown(); - return Mono.empty(); - }) - // Using typed handlers (no manual unmarshalling needed) - .requestPermissionHandler((AcpSchema.RequestPermissionRequest request) -> { - mockClient.receivedPermissionRequests.add(request); - return Mono.just(permissionHandler.apply(request)); - }) - .readTextFileHandler((AcpSchema.ReadTextFileRequest request) -> { - mockClient.receivedFileReadRequests.add(request); - return Mono.just(readFileHandler.apply(request)); - }) - .writeTextFileHandler((AcpSchema.WriteTextFileRequest request) -> { - mockClient.receivedFileWriteRequests.add(request); - return Mono.just(writeFileHandler.apply(request)); - }) - .build(); - - // Replace the delegate using reflection - try { - var field = MockAcpClient.class.getDeclaredField("delegate"); - field.setAccessible(true); - field.set(mockClient, delegate); - } - catch (Exception e) { - throw new RuntimeException("Failed to set delegate", e); - } - - return mockClient; + return new MockAcpClient(this); } } diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/AcpHttpTransportTck.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/AcpHttpTransportTck.java new file mode 100644 index 00000000..f2b5c7b8 --- /dev/null +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/AcpHttpTransportTck.java @@ -0,0 +1,799 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.test.http; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransportOptions; +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.util.AcpSchedulers; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.Timeout; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import reactor.core.scheduler.Schedulers; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * The shared transport TCK: the protocol behaviour every host of the ACP endpoint must show on + * the wire, whatever its server. Every host (the servlet on each container, the embedded + * listener, each framework integration) runs it by subclassing it and starting its server in + * {@link #startHost}; a host that fails a case reimplemented, or failed to apply, a rule of the + * endpoint's. + * + *

    The cases: the status table of the Streamable HTTP profile; a prompt round trip over + * Streamable HTTP and over WebSocket with the SDK's Java clients; SSE responses that proxies do + * not buffer ({@code X-Accel-Buffering: no}, {@code Cache-Control: no-cache}, a first frame at + * once); an SSE stream that outlives the container's async timeout; concurrent outbound + * WebSocket sends delivered whole (a container that allows one outstanding write, such as + * Tomcat, fails a second one); shutdown closing open SSE and WebSocket streams promptly (a + * closing comment, then close code 1001) without waiting out a framework's graceful timeout; + * the {@code Origin} check over HTTP and on the WebSocket handshake; the WebSocket's + * initialize-first rule, size limit, idle timeout (1001) and initialize deadline (1008); and, + * for a host that binds its own socket, the loopback default. + * + *

    The suite starts one host for most cases, configured by {@link #standardConfig()}, one + * for each WebSocket timeout case, configured by {@link #idleTimeoutConfig()} and + * {@link #initializeTimeoutConfig()}, and one for the shutdown case. + * + * @author Mark Pollack + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Timeout(60) +public abstract class AcpHttpTransportTck { + + /** The origin the standard configuration lists besides the loopback ones. */ + public static final String ALLOWED_ORIGIN = "https://allowed.example"; + + /** The inbound limit of the standard configuration. */ + public static final long MAX_MESSAGE_BYTES = 64 * 1024; + + private static final long WEBSOCKET_IDLE_TIMEOUT_MILLIS = 300; + + private static final long INITIALIZE_TIMEOUT_MILLIS = 500; + + /** The WebSocket idle timeout of {@link #idleTimeoutConfig()}. */ + public static final Duration WEBSOCKET_IDLE_TIMEOUT = Duration.ofMillis(WEBSOCKET_IDLE_TIMEOUT_MILLIS); + + /** The initialize timeout of {@link #initializeTimeoutConfig()}. */ + public static final Duration INITIALIZE_TIMEOUT = Duration.ofMillis(INITIALIZE_TIMEOUT_MILLIS); + + /** The async timeout a host should give its container, when it can set one. */ + public static final Duration CONTAINER_ASYNC_TIMEOUT = Duration.ofSeconds(1); + + private static final Duration TIMEOUT = Duration.ofSeconds(15); + + /** + * How soon a timeout case's socket must close: generous, so a loaded machine cannot fail it, + * yet far below the 30 second and 30 minute defaults. + */ + private static final Duration CLOSE_WITHIN = Duration.ofSeconds(10); + + private static final AcpJsonMapper JSON = AcpJsonMapper.createDefault(); + + private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + + /** + * The status table's client. HTTP/1.1: over HTTP/2 a server that refuses a request before + * reading its body (415, 413) may reset the stream, and the client then reports the reset + * rather than the status, whichever arrives first. The statuses are the same on both. + */ + private static final HttpClient HTTP_1_1 = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .connectTimeout(TIMEOUT) + .build(); + + /** The threads the agent sends concurrent updates from. */ + private static final Scheduler SENDERS = Schedulers.newParallel("tck-senders", 4, true); + + private @Nullable Host host; + + /** + * A running host of the endpoint. + */ + public interface Host extends AutoCloseable { + + /** + * Returns the endpoint's HTTP URI, such as {@code http://127.0.0.1:8080/acp}. + * @return the endpoint + */ + URI endpoint(); + + /** + * Stops the host as its framework does when the application stops, the endpoint's + * drain included, and returns once it has stopped. + * @throws Exception if stopping fails + */ + void stop() throws Exception; + + @Override + default void close() throws Exception { + stop(); + } + + } + + /** + * What a host is started with. + * @param agents the agent factory to serve + * @param options the endpoint's options; a framework host maps them onto its properties + * @param containerAsyncTimeout the async request timeout to give the container, where the + * host can set one (Tomcat's connector, {@code spring.mvc.async.request-timeout}); the + * endpoint must not let it cut an SSE stream + */ + public record HostConfig(AcpAgentFactory agents, StreamableHttpAcpAgentTransportOptions options, + Duration containerAsyncTimeout) { + } + + /** + * Starts the host under test, listening on an ephemeral port. A framework host whose test + * runs one application may return that application each time, configured like + * {@link #standardConfig()} with an agent that behaves like {@link #agentFactory()}, and + * with a WebSocket idle timeout and an initialize timeout of a few seconds at most, for the + * timeout cases; the suite does not stop that application before the shutdown case. + * @param config what to serve + * @return the running host + * @throws Exception if it cannot start + */ + protected abstract Host startHost(HostConfig config) throws Exception; + + /** + * Returns whether the host serves WebSocket on the endpoint's path; the WebSocket cases are + * skipped otherwise. + * @return true by default + */ + protected boolean supportsWebSocket() { + return true; + } + + /** + * Returns whether the host binds a socket of its own (the embedded listener) rather than + * mounting in a framework's server; only such a host runs the bind-default case. + * @return false by default + */ + protected boolean bindsItsOwnSocket() { + return false; + } + + /** + * The standard configuration: the TCK's agent, a 250 ms keep-alive, a 64 KB inbound limit, + * {@value #ALLOWED_ORIGIN} allowed, a 3 second shutdown timeout, and a 1 second container + * async timeout. + * @return the configuration + */ + public static HostConfig standardConfig() { + return new HostConfig(agentFactory(), standardOptions().build(), CONTAINER_ASYNC_TIMEOUT); + } + + /** + * The standard configuration with a {@value #WEBSOCKET_IDLE_TIMEOUT_MILLIS} ms WebSocket idle + * timeout, for the idle timeout case. + * @return the configuration + */ + public static HostConfig idleTimeoutConfig() { + return new HostConfig(agentFactory(), standardOptions().webSocketIdleTimeout(WEBSOCKET_IDLE_TIMEOUT).build(), + CONTAINER_ASYNC_TIMEOUT); + } + + /** + * The standard configuration with a {@value #INITIALIZE_TIMEOUT_MILLIS} ms initialize + * timeout, for the initialize deadline case. + * @return the configuration + */ + public static HostConfig initializeTimeoutConfig() { + return new HostConfig(agentFactory(), standardOptions().initializeTimeout(INITIALIZE_TIMEOUT).build(), + CONTAINER_ASYNC_TIMEOUT); + } + + /** + * The options of {@link #standardConfig()}, as a builder, for a framework host that builds + * its own options. + * @return a builder with the standard options set + */ + public static StreamableHttpAcpAgentTransportOptions.Builder standardOptions() { + return StreamableHttpAcpAgentTransportOptions.builder() + .keepAliveInterval(Duration.ofMillis(250)) + .maxPostBodyBytes(MAX_MESSAGE_BYTES) + .allowedOrigins(List.of(ALLOWED_ORIGIN)) + .shutdownTimeout(Duration.ofSeconds(3)); + } + + /** + * The TCK's agent: {@code updates:N} as a prompt sends N session updates concurrently, then + * ends the turn; {@code hold} waits a minute; any other prompt is echoed as one update. + * @return the agent factory + */ + public static AcpAgentFactory agentFactory() { + AtomicInteger sessions = new AtomicInteger(); + return AcpAgentFactory.async(transport -> AcpAgent.async(transport) + .initializeHandler(request -> Mono.just(AcpSchema.InitializeResponse.ok())) + .newSessionHandler( + request -> Mono.just(new AcpSchema.NewSessionResponse("s-" + sessions.incrementAndGet(), null, null))) + .promptHandler((request, context) -> { + String text = ((AcpSchema.TextContent) request.prompt().get(0)).text(); + if (text.startsWith("updates:")) { + int count = Integer.parseInt(text.substring("updates:".length())); + return Flux.range(0, count) + .flatMap(i -> context.sendMessage("u" + i).subscribeOn(SENDERS), count) + .then(Mono.just(AcpSchema.PromptResponse.endTurn())); + } + if (text.equals("hold")) { + return Mono.delay(Duration.ofMinutes(1), AcpSchedulers.timeouts()) + .thenReturn(AcpSchema.PromptResponse.endTurn()); + } + return context.sendMessage("echo " + text).thenReturn(AcpSchema.PromptResponse.endTurn()); + }) + .build()); + } + + @BeforeAll + void startSharedHost() throws Exception { + this.host = startHost(standardConfig()); + } + + @AfterAll + void stopSharedHost() throws Exception { + Host current = this.host; + if (current != null) { + current.stop(); + } + } + + private URI endpoint() { + Host current = this.host; + if (current == null) { + throw new IllegalStateException("The host did not start"); + } + return current.endpoint(); + } + + // The protocol + + @Test + void initializeAnswersWithAConnectionId() throws Exception { + HttpResponse response = HttpProbes.initialize(endpoint(), null); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("Acp-Connection-Id")).hasValueSatisfying(id -> assertThat(id).isNotBlank()); + assertThat(response.headers().firstValue("Content-Type")).hasValueSatisfying( + type -> assertThat(type).startsWith("application/json")); + assertThat(response.body()).contains("\"id\":\"probe\"").contains("\"result\""); + } + + @Test + void aJavaClientCompletesAPromptOverStreamableHttp() { + assertPromptRoundTrip(new StreamableHttpAcpClientTransport(endpoint(), JSON), 3); + } + + @Test + void aJavaClientCompletesAPromptOverWebSocket() { + assumeTrue(supportsWebSocket(), "the host serves no WebSocket"); + assertPromptRoundTrip(new WebSocketAcpClientTransport(webSocketUri(endpoint()), JSON), 3); + } + + @Test + void theStatusTable() throws Exception { + URI uri = endpoint(); + assertThat(post(uri, "text/plain", HttpProbes.INITIALIZE, Map.of()).statusCode()).isEqualTo(415); + assertThat(oversizedPostStatus(uri)).isEqualTo(413); + assertThat(post(uri, "application/json", "[" + HttpProbes.INITIALIZE + "]", Map.of()).statusCode()) + .isEqualTo(501); + assertThat(post(uri, "application/json", "not json", Map.of()).statusCode()).isEqualTo(400); + String notification = "{\"jsonrpc\":\"2.0\",\"method\":\"session/cancel\",\"params\":{\"sessionId\":\"s\"}}"; + assertThat(post(uri, "application/json", notification, Map.of()).statusCode()).isEqualTo(400); + assertThat(post(uri, "application/json", notification, Map.of("Acp-Connection-Id", "unknown")).statusCode()) + .isEqualTo(404); + assertThat(post(uri, "application/json", HttpProbes.INITIALIZE, Map.of("Acp-Connection-Id", "any")) + .statusCode()).isEqualTo(400); + assertThat(send(HttpRequest.newBuilder(uri).GET().header("Accept", "application/json")).statusCode()) + .isEqualTo(406); + assertThat(send(HttpRequest.newBuilder(uri).GET().header("Accept", "text/event-stream")).statusCode()) + .isEqualTo(400); + assertThat(send(HttpRequest.newBuilder(uri).DELETE().header("Acp-Connection-Id", "unknown")).statusCode()) + .isEqualTo(404); + String connectionId = initialize(uri); + assertThat(send(HttpRequest.newBuilder(uri).DELETE().header("Acp-Connection-Id", connectionId)).statusCode()) + .isEqualTo(202); + assertThat(send(HttpRequest.newBuilder(uri).DELETE().header("Acp-Connection-Id", connectionId)).statusCode()) + .isEqualTo(404); + assertThat(send(HttpRequest.newBuilder(uri).method("PUT", HttpRequest.BodyPublishers.noBody())).statusCode()) + .isEqualTo(405); + } + + // SSE + + @Test + void sseResponsesAreNotBuffered() throws Exception { + URI uri = endpoint(); + String connectionId = initialize(uri); + try (SseStream stream = SseStream.open(uri, connectionId)) { + assertThat(stream.status()).isEqualTo(200); + assertThat(stream.header("Content-Type")).hasValueSatisfying(type -> assertThat(type).startsWith("text/event-stream")); + assertThat(stream.header("Cache-Control")).hasValue("no-cache"); + assertThat(stream.header("X-Accel-Buffering")).hasValue("no"); + assertThat(stream.header("Acp-Connection-Id")).hasValue(connectionId); + // The opening comment arrives at once: nothing between the endpoint and the client + // holds the stream back (a compressing filter or a whole-body wrapper would). + assertThat(stream.nextLine(Duration.ofSeconds(2))).isEqualTo(": connected"); + } + } + + @Test + void anSseStreamOutlivesTheContainerAsyncTimeout() throws Exception { + URI uri = endpoint(); + String connectionId = initialize(uri); + try (SseStream stream = SseStream.open(uri, connectionId)) { + assertThat(stream.status()).isEqualTo(200); + long deadline = System.nanoTime() + CONTAINER_ASYNC_TIMEOUT.multipliedBy(3).toNanos(); + while (System.nanoTime() < deadline) { + assertThat(stream.nextLine(Duration.ofSeconds(2))).as("the stream is still open").isNotNull(); + } + // Still open after three async timeouts: keep-alives keep arriving. + assertThat(stream.nextLine(Duration.ofSeconds(2))).isNotNull(); + } + } + + // WebSocket + + @Test + void concurrentWebSocketSendsAreSerialized() { + assumeTrue(supportsWebSocket(), "the host serves no WebSocket"); + // 200 updates sent from parallel threads: a host that handed its container a second + // write before the first completed fails here (Tomcat: TEXT_FULL_WRITING). + assertPromptRoundTrip(new WebSocketAcpClientTransport(webSocketUri(endpoint()), JSON), 200); + } + + @Test + void aWebSocketMustOpenWithInitialize() throws Exception { + assumeTrue(supportsWebSocket(), "the host serves no WebSocket"); + try (RawWebSocket socket = RawWebSocket.open(endpoint(), null)) { + socket.send("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"session/new\",\"params\":{\"cwd\":\"/\",\"mcpServers\":[]}}"); + assertThat(socket.closeCode()).as("close code (failure: %s)", socket.error()).isEqualTo(1002); + } + } + + @Test + void anOversizedWebSocketMessageClosesWith1009() throws Exception { + assumeTrue(supportsWebSocket(), "the host serves no WebSocket"); + try (RawWebSocket socket = RawWebSocket.open(endpoint(), null)) { + socket.send(HttpProbes.INITIALIZE); + assertThat(socket.nextMessage()).contains("\"result\""); + socket.send("x".repeat((int) MAX_MESSAGE_BYTES + 1)); + assertThat(socket.closeCode()).as("close code (failure: %s)", socket.error()).isEqualTo(1009); + } + } + + @Test + @Order(Integer.MAX_VALUE - 1) + void anIdleWebSocketIsClosedWith1001() throws Exception { + // Initialize, then silence: closed once idle for the timeout, not after the 30 minute + // default. On a loaded machine the client's initialize can come after the timeout; the + // connection is then idle from the start and closes with 1001 all the same. + assertClosedByTimeout(idleTimeoutConfig(), WEBSOCKET_IDLE_TIMEOUT, true, 1001); + } + + @Test + @Order(Integer.MAX_VALUE - 1) + void aWebSocketThatNeverInitializesIsClosedWith1008() throws Exception { + assertClosedByTimeout(initializeTimeoutConfig(), INITIALIZE_TIMEOUT, false, 1008); + } + + /** + * Run after the cases on the shared host: stopping a second framework application can + * release resources the first one shares (Spring WebFlux's Reactor Netty resources). + * Opens a WebSocket on a host started with {@code config}, sends {@code initialize} or + * nothing, and asserts the endpoint closes it with {@code code}, no sooner than half the + * timeout after the socket opened and within {@link #CLOSE_WITHIN}. Stops the host unless it + * handed back the suite's shared one. + */ + private void assertClosedByTimeout(HostConfig config, Duration timeout, boolean initialize, int code) + throws Exception { + assumeTrue(supportsWebSocket(), "the host serves no WebSocket"); + Host timed = startHost(config); + try (RawWebSocket socket = RawWebSocket.open(timed.endpoint(), null)) { + long openedAt = System.nanoTime(); + if (initialize) { + socket.sendIfOpen(HttpProbes.INITIALIZE); + } + assertThat(socket.closeCode()).as("close code (failure: %s)", socket.error()).isEqualTo(code); + assertThat(Duration.ofNanos(System.nanoTime() - openedAt)).isBetween(timeout.dividedBy(2), CLOSE_WITHIN); + } + finally { + if (!timed.endpoint().equals(endpoint())) { + timed.stop(); + } + } + } + + // Origin + + @Test + void aForeignOriginIsRefused() throws Exception { + assertThat(HttpProbes.initialize(endpoint(), "http://evil.example").statusCode()).isEqualTo(403); + assertThat(HttpProbes.get(endpoint(), "http://evil.example")).isEqualTo(403); + if (supportsWebSocket()) { + assertThat(HttpProbes.webSocketHandshake(endpoint(), "http://evil.example")).isEqualTo(403); + } + } + + @Test + void loopbackListedAndAbsentOriginsAreServed() throws Exception { + for (String origin : new String[] { "http://localhost:3000", "http://127.0.0.1", "https://[::1]:8443", + ALLOWED_ORIGIN, null }) { + assertThat(HttpProbes.initialize(endpoint(), origin).statusCode()).as(String.valueOf(origin)).isEqualTo(200); + if (supportsWebSocket()) { + assertThat(HttpProbes.webSocketHandshake(endpoint(), origin)).as(String.valueOf(origin)) + .isEqualTo(HttpProbes.SWITCHING_PROTOCOLS); + } + } + } + + // Bind + + @Test + void anEmbeddedHostIsNotReachableFromTheNetworkByDefault() throws Exception { + assumeTrue(bindsItsOwnSocket(), "the host mounts in a framework's server, which binds it"); + Optional external = Networks.nonLoopbackAddress(); + assumeTrue(external.isPresent(), "this machine has no non-loopback address"); + int port = endpoint().getPort(); + assertThatThrownBy(() -> connect(external.get(), port)).isInstanceOf(IOException.class); + connect(InetAddress.getLoopbackAddress(), port); + } + + // Shutdown + + /** + * Runs last: a host that cannot start a second server (a framework test runs one + * application) may hand back its running one, which this case drains. + */ + @Test + @Order(Integer.MAX_VALUE) + void shutdownClosesOpenStreamsPromptly() throws Exception { + Host second = startHost(standardConfig()); + boolean stopped = false; + try { + URI uri = second.endpoint(); + String connectionId = initialize(uri); + SseStream stream = SseStream.open(uri, connectionId); + assertThat(stream.nextLine(Duration.ofSeconds(2))).isEqualTo(": connected"); + RawWebSocket socket = null; + if (supportsWebSocket()) { + socket = RawWebSocket.open(uri, null); + socket.send(HttpProbes.INITIALIZE); + assertThat(socket.nextMessage()).contains("\"result\""); + } + long start = System.nanoTime(); + second.stop(); + stopped = true; + Duration took = Duration.ofNanos(System.nanoTime() - start); + // A framework's graceful shutdown waits up to 30 seconds for open requests; the host + // drains its streams before that, so it never waits. + assertThat(took).isLessThan(Duration.ofSeconds(10)); + assertThat(stream.remainingLines(Duration.ofSeconds(5))).contains(": shutting down"); + stream.close(); + if (socket != null) { + assertThat(socket.closeCode()).as("close code (failure: %s)", socket.error()).isEqualTo(1001); + socket.close(); + } + } + finally { + if (!stopped) { + second.stop(); + } + } + } + + // Helpers + + private static void assertPromptRoundTrip(AcpClientTransport transport, int updates) { + List received = new CopyOnWriteArrayList<>(); + AcpAsyncClient client = AcpClient.async(transport).requestTimeout(TIMEOUT).sessionUpdateHandler(notification -> { + if (notification.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + received.add(text.text()); + } + return Mono.empty(); + }).build(); + try { + assertThat(client.initialize().block(TIMEOUT)).isNotNull(); + String sessionId = Objects + .requireNonNull(client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT)) + .sessionId(); + AcpSchema.PromptResponse response = Objects.requireNonNull(client + .prompt(new AcpSchema.PromptRequest(sessionId, List.of(new AcpSchema.TextContent("updates:" + updates)))) + .block(TIMEOUT)); + assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); + // Updates are delivered before the prompt's answer on both transports. + assertThat(received).hasSize(updates).doesNotHaveDuplicates(); + } + finally { + client.closeGracefully().block(TIMEOUT); + } + } + + private static String initialize(URI uri) throws Exception { + HttpResponse response = HttpProbes.initialize(uri, null); + assertThat(response.statusCode()).isEqualTo(200); + return response.headers().firstValue("Acp-Connection-Id").orElseThrow(); + } + + private static HttpResponse post(URI uri, String contentType, String body, Map headers) + throws Exception { + HttpRequest.Builder request = HttpRequest.newBuilder(uri) + .header("Content-Type", contentType) + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(body)); + headers.forEach(request::header); + return send(request); + } + + /** + * The status of a POST over the size limit, sent as its headers alone. The endpoint refuses + * such a body by its Content-Length without reading it, and some servers (Tomcat as a WebFlux + * server, Jetty) then close the connection while the body is still arriving. The reset that + * follows can cost a client still sending the 413, which the JDK client reports as an I/O + * error ("header parser received no bytes"). With no body sent, nothing is left unread and + * the status always arrives. + */ + private static int oversizedPostStatus(URI uri) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(uri.getHost(), uri.getPort()), (int) TIMEOUT.toMillis()); + socket.setSoTimeout((int) TIMEOUT.toMillis()); + String request = "POST " + uri.getRawPath() + " HTTP/1.1\r\n" + "Host: " + uri.getHost() + ":" + uri.getPort() + + "\r\n" + "Content-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\n" + + "Content-Length: " + (MAX_MESSAGE_BYTES + 1) + "\r\n\r\n"; + socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + String status = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII)) + .readLine(); + if (status == null || !status.startsWith("HTTP/1.1 ") || status.length() < 12) { + throw new IOException("No status line for an oversized POST: " + status); + } + return Integer.parseInt(status.substring(9, 12)); + } + } + + private static HttpResponse send(HttpRequest.Builder request) throws Exception { + return HTTP_1_1.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString()) + .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + + private static URI webSocketUri(URI endpoint) { + return URI.create(endpoint.toString().replaceFirst("^http", "ws")); + } + + private static void connect(InetAddress address, int port) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(address, port), 2000); + } + } + + /** An open SSE response, read line by line on a thread of its own. */ + private static final class SseStream implements AutoCloseable { + + private static final String EOF = "\u0000EOF"; + + private final HttpResponse response; + + private final BlockingQueue lines = new LinkedBlockingQueue<>(); + + private final Thread reader; + + private SseStream(HttpResponse response) { + this.response = response; + this.reader = new Thread(this::read, "tck-sse-reader"); + this.reader.setDaemon(true); + this.reader.start(); + } + + static SseStream open(URI uri, String connectionId) throws Exception { + HttpRequest request = HttpRequest.newBuilder(uri) + .header("Accept", "text/event-stream") + .header("Acp-Connection-Id", connectionId) + .GET() + .build(); + return new SseStream(HTTP.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) + .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)); + } + + private void read() { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { + String line; + while ((line = in.readLine()) != null) { + if (!line.isEmpty()) { + lines.add(line); + } + } + } + catch (IOException e) { + // The stream ended. + } + lines.add(EOF); + } + + int status() { + return response.statusCode(); + } + + Optional header(String name) { + return response.headers().firstValue(name); + } + + /** The next non-empty line, or null when the stream ended or nothing came in time. */ + @Nullable String nextLine(Duration timeout) throws InterruptedException { + String line = lines.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + return (line == null || line.equals(EOF)) ? null : line; + } + + /** Every line until the stream ends; fails if it does not end in time. */ + List remainingLines(Duration timeout) throws InterruptedException { + List rest = new java.util.ArrayList<>(); + long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + long left = deadline - System.nanoTime(); + String line = lines.poll(Math.max(0, left), TimeUnit.NANOSECONDS); + if (line == null) { + throw new AssertionError("The SSE stream did not end within " + timeout + "; read " + rest); + } + if (line.equals(EOF)) { + return rest; + } + rest.add(line); + } + } + + @Override + public void close() { + try { + response.body().close(); + } + catch (IOException e) { + // already closed + } + } + + } + + /** A WebSocket to the endpoint that records what it receives and how it closed. */ + private static final class RawWebSocket implements WebSocket.Listener, AutoCloseable { + + private final BlockingQueue messages = new LinkedBlockingQueue<>(); + + private final CompletableFuture closeCode = new CompletableFuture<>(); + + /** Why the socket failed, when it did: a 1006 then says what ended it. */ + private volatile @Nullable Throwable error; + + private final StringBuilder partial = new StringBuilder(); + + private @Nullable WebSocket socket; + + static RawWebSocket open(URI endpoint, @Nullable String origin) throws Exception { + RawWebSocket listener = new RawWebSocket(); + WebSocket.Builder builder = HTTP.newWebSocketBuilder(); + if (origin != null) { + builder.header("Origin", origin); + } + listener.socket = builder.buildAsync(webSocketUri(endpoint), listener) + .get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + return listener; + } + + void send(String text) throws Exception { + WebSocket current = socket; + if (current != null) { + current.sendText(text, true).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + } + + /** + * Sends a frame, unless the server closes the socket meanwhile: a send that fails is + * accepted only when the close follows within the timeout. + */ + void sendIfOpen(String text) throws Exception { + try { + send(text); + } + catch (ExecutionException e) { + closeCode(); + } + } + + String nextMessage() throws InterruptedException { + String message = messages.poll(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + if (message == null) { + throw new AssertionError("No WebSocket message within " + TIMEOUT); + } + return message; + } + + int closeCode() throws Exception { + return closeCode.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + + /** Why the socket failed, or null: reported with a close code of 1006. */ + @Nullable Throwable error() { + return error; + } + + @Override + public @Nullable CompletionStage onText(WebSocket webSocket, CharSequence data, boolean last) { + partial.append(data); + if (last) { + messages.add(partial.toString()); + partial.setLength(0); + } + webSocket.request(1); + return null; + } + + @Override + public @Nullable CompletionStage onClose(WebSocket webSocket, int statusCode, String reason) { + closeCode.complete(statusCode); + return null; + } + + @Override + public void onError(WebSocket webSocket, Throwable error) { + this.error = error; + closeCode.complete(1006); + } + + @Override + public void close() { + WebSocket current = socket; + if (current != null) { + current.abort(); + } + } + + } + +} diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/HttpProbes.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/HttpProbes.java new file mode 100644 index 00000000..a392e044 --- /dev/null +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/HttpProbes.java @@ -0,0 +1,164 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.test.http; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; + +/** + * Raw requests against an ACP endpoint, answering the HTTP status, for tests of what every host + * must answer: an {@code initialize} POST and a WebSocket handshake, each with an optional + * {@code Origin} header as a browser sends it. + * + * @author Mark Pollack + */ +public final class HttpProbes { + + /** The status a successful WebSocket handshake answers. */ + public static final int SWITCHING_PROTOCOLS = 101; + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + /** A minimal, valid {@code initialize} request. */ + public static final String INITIALIZE = """ + {"jsonrpc":"2.0","id":"probe","method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}"""; + + private static final HttpClient CLIENT = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); + + private HttpProbes() { + } + + /** + * POSTs an {@code initialize} to the endpoint. + * @param endpoint the endpoint, such as {@code http://localhost:8080/acp} + * @param origin the {@code Origin} header, or null for none + * @return the response + * @throws IOException if the request fails + * @throws InterruptedException if interrupted + */ + public static HttpResponse initialize(URI endpoint, @Nullable String origin) + throws IOException, InterruptedException { + return initialize(endpoint, origin, Map.of()); + } + + /** + * POSTs an {@code initialize} to the endpoint with extra headers, such as credentials. + * @param endpoint the endpoint, such as {@code http://localhost:8080/acp} + * @param origin the {@code Origin} header, or null for none + * @param headers more request headers + * @return the response + * @throws IOException if the request fails + * @throws InterruptedException if interrupted + */ + public static HttpResponse initialize(URI endpoint, @Nullable String origin, Map headers) + throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder(endpoint) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(INITIALIZE)); + if (origin != null) { + request.header("Origin", origin); + } + headers.forEach(request::header); + return send(request.build(), HttpResponse.BodyHandlers.ofString()); + } + + /** + * Sends a GET to the endpoint. + * @param endpoint the endpoint + * @param origin the {@code Origin} header, or null for none + * @return the status + * @throws IOException if the request fails + * @throws InterruptedException if interrupted + */ + public static int get(URI endpoint, @Nullable String origin) throws IOException, InterruptedException { + HttpRequest.Builder request = HttpRequest.newBuilder(endpoint) + .header("Accept", "text/event-stream") + .GET(); + if (origin != null) { + request.header("Origin", origin); + } + return send(request.build(), HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + /** Sends a request, bounded by the probe timeout (the JDK client has no response timeout of its own). */ + private static HttpResponse send(HttpRequest request, HttpResponse.BodyHandler handler) + throws IOException, InterruptedException { + try { + return CLIENT.sendAsync(request, handler).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } + catch (ExecutionException e) { + throw new IOException("Request failed", e.getCause()); + } + catch (TimeoutException e) { + throw new IOException("Request timed out", e); + } + } + + /** + * Opens a WebSocket to the endpoint and closes it at once. + * @param endpoint the endpoint, with an {@code http} or {@code ws} scheme + * @param origin the {@code Origin} header, or null for none + * @return {@value #SWITCHING_PROTOCOLS} when the handshake succeeded, else the status the + * server refused it with + * @throws IOException if the connection fails for another reason + * @throws InterruptedException if interrupted + */ + public static int webSocketHandshake(URI endpoint, @Nullable String origin) + throws IOException, InterruptedException { + return webSocketHandshake(endpoint, origin, Map.of()); + } + + /** + * Opens a WebSocket to the endpoint with extra handshake headers, such as credentials, and + * closes it at once. + * @param endpoint the endpoint, with an {@code http} or {@code ws} scheme + * @param origin the {@code Origin} header, or null for none + * @param headers more handshake headers + * @return {@value #SWITCHING_PROTOCOLS} when the handshake succeeded, else the status the + * server refused it with + * @throws IOException if the connection fails for another reason + * @throws InterruptedException if interrupted + */ + public static int webSocketHandshake(URI endpoint, @Nullable String origin, Map headers) + throws IOException, InterruptedException { + URI uri = URI.create(endpoint.toString().replaceFirst("^http", "ws")); + WebSocket.Builder builder = CLIENT.newWebSocketBuilder().connectTimeout(TIMEOUT); + if (origin != null) { + builder.header("Origin", origin); + } + headers.forEach(builder::header); + try { + WebSocket socket = builder.buildAsync(uri, new WebSocket.Listener() { + }).get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + socket.sendClose(WebSocket.NORMAL_CLOSURE, "probe done").get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + return SWITCHING_PROTOCOLS; + } + catch (ExecutionException | CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof WebSocketHandshakeException handshake) { + return handshake.getResponse().statusCode(); + } + throw new IOException("WebSocket handshake failed", cause); + } + catch (TimeoutException e) { + throw new IOException("WebSocket handshake timed out", e); + } + } + +} diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/Networks.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/Networks.java new file mode 100644 index 00000000..6b518870 --- /dev/null +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/Networks.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.test.http; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + + +/** + * This machine's network addresses, for tests that check what a listener is reachable on. + * + * @author Mark Pollack + */ +public final class Networks { + + private Networks() { + } + + /** + * Returns an up, non-loopback, non-link-local address of this machine, IPv4 first. + * @return the address, or empty when the machine has none + * @throws SocketException if the interfaces cannot be listed + */ + public static Optional nonLoopbackAddress() throws SocketException { + List addresses = new ArrayList<>(); + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (nic.isUp() && !nic.isLoopback()) { + addresses.addAll(Collections.list(nic.getInetAddresses())); + } + } + List routable = addresses.stream() + .filter(address -> !address.isLoopbackAddress() && !address.isLinkLocalAddress()) + .toList(); + Optional ipv4 = routable.stream().filter(Inet4Address.class::isInstance).findFirst(); + return ipv4.isPresent() ? ipv4 : routable.stream().filter(Inet6Address.class::isInstance).findFirst(); + } + +} diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/package-info.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/package-info.java new file mode 100644 index 00000000..a9abf62a --- /dev/null +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/http/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Raw HTTP and WebSocket probes of an ACP endpoint, for tests that check what a host answers on + * the wire rather than what an ACP client sees. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.test.http; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-test/src/main/java/com/agentclientprotocol/sdk/test/package-info.java b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/package-info.java new file mode 100644 index 00000000..5668cf11 --- /dev/null +++ b/acp-test/src/main/java/com/agentclientprotocol/sdk/test/package-info.java @@ -0,0 +1,16 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +/** + * Test support for ACP applications: an in-memory transport pair and mock agent and client. + * + *

    + * The package is null-marked: every type is non-null unless annotated + * {@link org.jspecify.annotations.Nullable @Nullable}. + *

    + */ +@NullMarked +package com.agentclientprotocol.sdk.test; + +import org.jspecify.annotations.NullMarked; diff --git a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java index 986a40ad..d2657a39 100644 --- a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java +++ b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/InMemoryTransportPairTest.java @@ -6,8 +6,13 @@ import java.time.Duration; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import com.agentclientprotocol.sdk.spec.AcpSchema; @@ -102,4 +107,87 @@ void closeGracefullyCompletesBothTransports() { } } + @Test + void sendIsDeliveredWhileAnotherThreadIsStillDeliveringItsMessage() throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List received = new CopyOnWriteArrayList<>(); + CountDownLatch firstInHandler = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + CountDownLatch bothReceived = new CountDownLatch(2); + + // The first message is delivered on its sender's thread and held there, so that + // thread is still emitting on the sink when the second send arrives. + pair.agentTransport().start(msg -> msg.doOnNext(m -> { + Object id = ((AcpSchema.JSONRPCRequest) m).id(); + if ("1".equals(id)) { + firstInHandler.countDown(); + awaitQuietly(releaseFirst); + } + received.add(id); + bothReceived.countDown(); + }).then(Mono.empty())).subscribe(); + + ExecutorService senders = Executors.newFixedThreadPool(2); + try { + senders.submit(() -> pair.clientTransport().sendMessage(request("1")).block(TIMEOUT)); + assertThat(firstInHandler.await(5, TimeUnit.SECONDS)).isTrue(); + + // Held well past the 100 ms a busy loop would wait before giving up. + Future second = senders.submit(() -> pair.clientTransport().sendMessage(request("2")).block(TIMEOUT)); + try { + second.get(1, TimeUnit.SECONDS); + } + catch (TimeoutException stillWaiting) { + // Waiting for the first delivery to finish is allowed; dropping is not. + } + releaseFirst.countDown(); + second.get(5, TimeUnit.SECONDS); + + assertThat(bothReceived.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(received).containsExactly("1", "2"); + } + finally { + releaseFirst.countDown(); + senders.shutdownNow(); + pair.closeGracefully().block(TIMEOUT); + } + } + + @Test + void sendFromInsideDeliveryOnTheSameSinkIsDeliveredInOrder() throws Exception { + InMemoryTransportPair pair = InMemoryTransportPair.create(); + List received = new CopyOnWriteArrayList<>(); + CountDownLatch bothReceived = new CountDownLatch(2); + + pair.agentTransport().start(msg -> msg.doOnNext(m -> { + Object id = ((AcpSchema.JSONRPCRequest) m).id(); + received.add(id); + if ("1".equals(id)) { + // Re-entrant: the sender's thread is still delivering message 1. + pair.clientTransport().sendMessage(request("2")).block(TIMEOUT); + } + bothReceived.countDown(); + }).then(Mono.empty())).subscribe(); + + pair.clientTransport().sendMessage(request("1")).block(TIMEOUT); + + assertThat(bothReceived.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(received).containsExactly("1", "2"); + pair.closeGracefully().block(TIMEOUT); + } + + private static AcpSchema.JSONRPCRequest request(String id) { + return new AcpSchema.JSONRPCRequest(AcpSchema.METHOD_INITIALIZE, id, + new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())); + } + + private static void awaitQuietly(CountDownLatch latch) { + try { + latch.await(5, TimeUnit.SECONDS); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } diff --git a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java index 55c79de6..ed611f36 100644 --- a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java +++ b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpAgentTest.java @@ -33,7 +33,7 @@ void defaultMockAgentAcceptsInitialize() throws Exception { AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); AcpSchema.InitializeResponse response = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) + .initialize() .block(TIMEOUT); assertThat(response).isNotNull(); @@ -60,7 +60,7 @@ void mockAgentRecordsPrompts() throws Exception { Thread.sleep(100); AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); mockAgent.expectPrompts(1); @@ -93,7 +93,7 @@ void mockAgentRecordsCancellations() throws Exception { Thread.sleep(100); AcpAsyncClient client = AcpClient.async(pair.clientTransport()).build(); - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); + client.initialize().block(TIMEOUT); client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); client.cancel(new AcpSchema.CancelNotification("cancel-session")).block(TIMEOUT); diff --git a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java index bed33453..5d4f91d6 100644 --- a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java +++ b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/MockAcpClientTest.java @@ -72,9 +72,7 @@ void mockClientReceivesSessionUpdates() throws Exception { .promptHandler((request, updater) -> { // Send update during prompt return updater - .sendUpdate("update-session", - new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Processing..."))) + .sendSessionUpdate(new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent("Processing..."))) .then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))); }) .build(); @@ -113,8 +111,7 @@ void mockClientHandlesPermissionRequests() throws Exception { .just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))) .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("perm-session", null, null))) .promptHandler((request, updater) -> { - AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Edit File", - AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING, null, null, null, null); + AcpSchema.ToolCallUpdate toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Edit File", AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING); List options = List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE)); diff --git a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/TestKitArchitectureTest.java b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/TestKitArchitectureTest.java new file mode 100644 index 00000000..01bca3b3 --- /dev/null +++ b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/TestKitArchitectureTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.test; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarFile; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.domain.JavaModifier; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.core.importer.Location; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.junit.LocationProvider; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.base.DescribedPredicate.not; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; +import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * acp-test: an in-memory transport pair and a mock agent and client for application tests. It + * stands where an application stands, so it may use anything public in acp-core, including the + * sessions, but nothing package-private; and it sits beneath the annotation layer, whose tests + * use it, so it never depends on acp-agent-support or acp-annotations. + */ +@AnalyzeClasses(locations = TestKitArchitectureTest.ThisModule.class, + importOptions = ImportOption.DoNotIncludeTests.class) +class TestKitArchitectureTest { + + @ArchTest + static void importsThisModuleAndNothingElse(JavaClasses classes) { + assertThat(classes.contain(ThisModule.ANCHOR)).as("this module's classes are imported").isTrue(); + assertThat(classes).allMatch(ThisModule::isFromThisModule, "comes from this module's main classes"); + } + + /** Every package is its own slice, so a cycle between sub-packages is caught too. */ + @ArchTest + static final ArchRule noPackageCyclesAtAnyDepth = slices().matching("com.agentclientprotocol.sdk.(**)") + .should() + .beFreeOfCycles(); + + @ArchTest + static final ArchRule usesOnlyPublicSdkTypes = noClasses().should() + .dependOnClassesThat(resideInAPackage("com.agentclientprotocol.sdk..") + .and(not(ThisModule.classes())) + .and(not(modifier(JavaModifier.PUBLIC))) + .as("non-public SDK classes")) + .because("the test kit is written against the API an application sees"); + + @ArchTest + static final ArchRule staysBeneathTheAnnotationLayer = noClasses().should() + .dependOnClassesThat() + .resideInAnyPackage("com.agentclientprotocol.sdk.agent.support..", "com.agentclientprotocol.sdk.annotation..") + .because("acp-agent-support's tests use the test kit"); + + /** + * Imports the directory or jar holding this module's main classes, found from {@code ANCHOR}, so + * the rules never see a dependency's classes or the tests. + */ + public static final class ThisModule implements LocationProvider { + + static final Class ANCHOR = InMemoryTransportPair.class; + + @Override + public Set get(Class testClass) { + Path root = root(); + try { + return Set.of(Files.isDirectory(root) ? Location.of(root) : Location.of(new JarFile(root.toFile()))); + } + catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + static boolean isFromThisModule(JavaClass javaClass) { + String root = root().toUri().getPath(); + return javaClass.getBaseComponentType() + .getSource() + .map(source -> source.getUri().toString().contains(root)) + .orElse(false); + } + + static DescribedPredicate classes() { + return DescribedPredicate.describe("classes of this module", ThisModule::isFromThisModule); + } + + private static Path root() { + try { + return Path.of(ANCHOR.getProtectionDomain().getCodeSource().getLocation().toURI()); + } + catch (URISyntaxException ex) { + throw new IllegalStateException(ex); + } + } + + } + +} diff --git a/acp-test/src/test/java/com/agentclientprotocol/sdk/test/http/HttpProbesTest.java b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/http/HttpProbesTest.java new file mode 100644 index 00000000..0f4dfa03 --- /dev/null +++ b/acp-test/src/test/java/com/agentclientprotocol/sdk/test/http/HttpProbesTest.java @@ -0,0 +1,82 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package com.agentclientprotocol.sdk.test.http; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The probes send what they say and report the status the server answers, against the JDK's own + * HTTP server, which refuses every WebSocket upgrade. + */ +class HttpProbesTest { + + private final List origins = new CopyOnWriteArrayList<>(); + + private HttpServer server; + + private URI endpoint; + + @BeforeEach + void start() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.createContext("/acp", exchange -> { + String origin = exchange.getRequestHeaders().getFirst("Origin"); + origins.add(String.valueOf(origin)); + byte[] body = exchange.getRequestBody().readAllBytes(); + int status = "POST".equals(exchange.getRequestMethod()) + && new String(body, StandardCharsets.UTF_8).equals(HttpProbes.INITIALIZE) ? 200 : 403; + exchange.sendResponseHeaders(status, -1); + exchange.close(); + }); + server.start(); + endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/acp"); + } + + @AfterEach + void stop() { + server.stop(0); + } + + @Test + void initializePostsTheRequestWithTheOrigin() throws Exception { + assertThat(HttpProbes.initialize(endpoint, "http://localhost").statusCode()).isEqualTo(200); + assertThat(HttpProbes.initialize(endpoint, null).statusCode()).isEqualTo(200); + assertThat(origins).containsExactly("http://localhost", "null"); + } + + @Test + void getReportsTheStatus() throws Exception { + assertThat(HttpProbes.get(endpoint, "http://evil.example")).isEqualTo(403); + assertThat(HttpProbes.get(endpoint, null)).isEqualTo(403); + } + + @Test + void aRefusedHandshakeReportsItsStatus() throws Exception { + assertThat(HttpProbes.webSocketHandshake(endpoint, "http://evil.example")).isEqualTo(403); + assertThat(HttpProbes.webSocketHandshake(endpoint, null)).isEqualTo(403); + } + + @Test + void aFailedConnectionIsAnIoException() { + URI nowhere = URI.create("http://127.0.0.1:1/acp"); + assertThatThrownBy(() -> HttpProbes.get(nowhere, null)).isInstanceOf(IOException.class); + assertThatThrownBy(() -> HttpProbes.webSocketHandshake(nowhere, null)).isInstanceOf(IOException.class); + } + +} diff --git a/acp-test/src/test/resources/archunit.properties b/acp-test/src/test/resources/archunit.properties new file mode 100644 index 00000000..560cc800 --- /dev/null +++ b/acp-test/src/test/resources/archunit.properties @@ -0,0 +1,2 @@ +# Fail a rule whose "that" clause selects no classes, so a rule cannot pass on an empty import. +archRule.failOnEmptyShould=true diff --git a/acp-websocket-jetty/README.md b/acp-websocket-jetty/README.md deleted file mode 100644 index e9da9877..00000000 --- a/acp-websocket-jetty/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# acp-websocket-jetty - -WebSocket transport for ACP agents using embedded Jetty. Allows agents to accept WebSocket connections from clients instead of using stdio. - -## Key Classes - -| Class | Purpose | -|-------|---------| -| `WebSocketAcpAgentTransport` | Embedded Jetty WebSocket server for agents | - -## Installation - -```xml - - com.agentclientprotocol - acp-websocket-jetty - 0.9.0 - -``` - -## When to Use - -Use WebSocket transport when the agent runs as a long-lived server process that multiple clients connect to over the network. For single-client CLI usage, stdio transport (`acp-core`) is simpler. - -## Documentation - -- [API Reference — Transports](https://lab.pollack.ai/docs/acp-java-sdk/reference/java#transports) diff --git a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransport.java b/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransport.java deleted file mode 100644 index be2bc8e5..00000000 --- a/acp-websocket-jetty/src/main/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransport.java +++ /dev/null @@ -1,366 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; -import java.util.function.Function; - -import com.agentclientprotocol.sdk.spec.AcpAgentTransport; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.spec.AcpSchema.JSONRPCMessage; -import com.agentclientprotocol.sdk.util.Assert; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.json.TypeRef; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.websocket.api.Callback; -import org.eclipse.jetty.websocket.api.Session; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; -import org.eclipse.jetty.websocket.api.annotations.OnWebSocketOpen; -import org.eclipse.jetty.websocket.api.annotations.WebSocket; -import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import reactor.core.publisher.Sinks; -import reactor.core.scheduler.Scheduler; -import reactor.core.scheduler.Schedulers; - -/** - * Implementation of the ACP WebSocket transport for agents that accepts WebSocket - * connections from clients. Uses the Jetty WebSocket server. - * - *

    - * This transport starts an embedded Jetty server that listens for WebSocket connections - * on the configured port. When a client connects, messages are exchanged as JSON-RPC - * messages over WebSocket text frames. - *

    - * - *

    - * Key features: - *

      - *
    • Embedded Jetty WebSocket server
    • - *
    • Thread-safe message processing with dedicated schedulers
    • - *
    • Proper resource management and graceful shutdown
    • - *
    • Backpressure support via Reactor Sinks
    • - *
    - * - *

    - * Note: This transport requires the optional Jetty WebSocket dependency. - * Add the following to your pom.xml: - *

    {@code
    - * 
    - *     org.eclipse.jetty.websocket
    - *     jetty-websocket-jetty-server
    - *     12.0.14
    - * 
    - * }
    - * - * @author Mark Pollack - * @deprecated since 0.18.0, for removal in a future release. Use - * {@code StreamableHttpAcpAgentTransport} from {@code acp-streamable-http-jetty}: it serves - * the WebSocket upgrade on the same path ({@code ws://host:port/acp}) for any number of - * clients, one agent per connection through an {@code AcpAgentFactory}, plus the - * Streamable HTTP profile. Existing {@code WebSocketAcpClientTransport} clients connect - * to it unchanged. This transport serves a single client and is no longer developed. - */ -@Deprecated(since = "0.18.0", forRemoval = true) -public class WebSocketAcpAgentTransport implements AcpAgentTransport { - - private static final Logger logger = LoggerFactory.getLogger(WebSocketAcpAgentTransport.class); - - /** Default path for ACP WebSocket endpoints */ - public static final String DEFAULT_ACP_PATH = "/acp"; - - private final AcpJsonMapper jsonMapper; - - private final int port; - - private final String path; - - private Server server; - - private final Sinks.Many inboundSink; - - private final Sinks.Many outboundSink; - - private final Sinks.One connectionReady = Sinks.one(); - - private final Sinks.One terminationSink = Sinks.one(); - - private Scheduler outboundScheduler; - - private final AtomicBoolean isClosing = new AtomicBoolean(false); - - private final AtomicBoolean isStarted = new AtomicBoolean(false); - - private Consumer exceptionHandler = t -> logger.error("Transport error", t); - - private volatile Session clientSession; - - private Duration idleTimeout = Duration.ofMinutes(30); - - /** - * Creates a new WebSocketAcpAgentTransport on the specified port with default path. - * @param port The port to listen on - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization - */ - public WebSocketAcpAgentTransport(int port, AcpJsonMapper jsonMapper) { - this(port, DEFAULT_ACP_PATH, jsonMapper); - } - - /** - * Creates a new WebSocketAcpAgentTransport on the specified port and path. - * @param port The port to listen on - * @param path The WebSocket endpoint path (e.g., "/acp") - * @param jsonMapper The JsonMapper to use for JSON serialization/deserialization - */ - public WebSocketAcpAgentTransport(int port, String path, AcpJsonMapper jsonMapper) { - Assert.isTrue(port > 0, "Port must be positive"); - Assert.hasText(path, "Path must not be empty"); - Assert.notNull(jsonMapper, "The JsonMapper can not be null"); - - this.port = port; - this.path = path; - this.jsonMapper = jsonMapper; - - this.inboundSink = Sinks.many().unicast().onBackpressureBuffer(); - this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); - // Use daemon thread so JVM can exit if closeGracefully() isn't called - this.outboundScheduler = Schedulers.fromExecutorService( - Executors.newSingleThreadExecutor(r -> { - Thread t = new Thread(r, "acp-ws-agent-outbound"); - t.setDaemon(true); - return t; - }), "ws-agent-outbound"); - } - - /** - * Sets the WebSocket idle timeout. - * @param timeout The idle timeout - * @return This transport for chaining - */ - public WebSocketAcpAgentTransport idleTimeout(Duration timeout) { - this.idleTimeout = timeout; - return this; - } - - /** - * Returns the port this transport is configured to listen on. - * @return The port number - */ - public int getPort() { - return port; - } - - @Override - public Mono start(Function, Mono> handler) { - if (!isStarted.compareAndSet(false, true)) { - return Mono.error(new IllegalStateException("Already started")); - } - - return Mono.fromCallable(() -> { - logger.info("Starting WebSocket agent server on port {} at path {}", port, path); - - // Set up inbound message handling - handleIncomingMessages(handler); - - // Create and configure Jetty server - server = new Server(); - ServerConnector connector = new ServerConnector(server); - connector.setPort(port); - server.addConnector(connector); - - // Set up WebSocket handler - WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(server, container -> { - container.setIdleTimeout(idleTimeout); - container.setMaxTextMessageSize(4 * 1024 * 1024); // 4MB — agent messages can include file contents - container.addMapping(path, (request, response, callback) -> new AcpWebSocketEndpoint()); - }); - server.setHandler(wsHandler); - - // Start server - server.start(); - startOutboundProcessing(); - - logger.info("WebSocket agent server started on port {} at path {}", port, path); - return null; - }).then(); - } - - private void handleIncomingMessages(Function, Mono> handler) { - this.inboundSink.asFlux() - .flatMap(message -> Mono.just(message).transform(handler)) - .doOnNext(response -> { - if (response != null) { - // Responses are emitted from the inbound thread while sendMessage emits - // from user threads on the same sink; both must go through the serialising - // busy-loop or a collision drops the response (FAIL_NON_SERIALIZED, #14). - try { - this.outboundSink.emitNext(response, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - } - catch (Sinks.EmissionException e) { - logger.error("Dropped response {}: {}", response, e.getReason()); - } - } - }) - .doOnTerminate(() -> { - this.outboundSink.tryEmitComplete(); - }) - .subscribe(); - } - - private void startOutboundProcessing() { - // Jetty allows one outstanding write per WebSocket session; a second sendText before - // the first completes can fail. Each frame therefore waits for Jetty's callback before - // the next is written (concatMap, one at a time). A failed send is reported and - // skipped; it never ends the outbound stream. - this.outboundSink.asFlux() - .publishOn(outboundScheduler) - .concatMap(this::sendFrame, 1) - .subscribe(); - } - - private Mono sendFrame(JSONRPCMessage message) { - return Mono.create(sink -> { - Session currentSession = clientSession; - if (message == null || isClosing.get() || currentSession == null || !currentSession.isOpen()) { - sink.success(); - return; - } - try { - String jsonMessage = jsonMapper.writeValueAsString(message); - logger.debug("Sending WebSocket message ({} characters)", jsonMessage.length()); - currentSession.sendText(jsonMessage, Callback.from(sink::success, error -> { - reportSendFailure(error); - sink.success(); - })); - } - catch (Exception e) { - reportSendFailure(e); - sink.success(); - } - }); - } - - private void reportSendFailure(Throwable error) { - if (!isClosing.get()) { - logger.error("Error sending WebSocket message", error); - exceptionHandler.accept(error); - } - } - - @Override - public Mono sendMessage(JSONRPCMessage message) { - return connectionReady.asMono().then(Mono.defer(() -> { - outboundSink.emitNext(message, - Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100))); - return Mono.empty(); - })); - } - - @Override - public Mono closeGracefully() { - return Mono.fromRunnable(() -> { - logger.debug("WebSocket agent transport closing gracefully"); - isClosing.set(true); - inboundSink.tryEmitComplete(); - outboundSink.tryEmitComplete(); - }).then(Mono.fromCallable(() -> { - Session currentSession = clientSession; - if (currentSession != null && currentSession.isOpen()) { - currentSession.close(); - } - if (server != null) { - server.stop(); - } - return null; - })).then(Mono.fromRunnable(() -> { - try { - outboundScheduler.dispose(); - logger.debug("WebSocket agent transport closed"); - } - catch (Exception e) { - logger.error("Error during graceful shutdown", e); - } - })); - } - - @Override - public void setExceptionHandler(Consumer handler) { - this.exceptionHandler = handler; - } - - @Override - public Mono awaitTermination() { - return terminationSink.asMono(); - } - - @Override - public T unmarshalFrom(Object data, TypeRef typeRef) { - return jsonMapper.convertValue(data, typeRef); - } - - /** - * Jetty WebSocket endpoint for handling client connections. - */ - @WebSocket - public class AcpWebSocketEndpoint { - - @OnWebSocketOpen - public void onOpen(Session session) { - logger.info("WebSocket client connected from {}", session.getRemoteSocketAddress()); - clientSession = session; - connectionReady.tryEmitValue(null); - } - - @OnWebSocketMessage - public void onMessage(Session session, String message) { - logger.debug("Received WebSocket message ({} characters)", message.length()); - - try { - JSONRPCMessage jsonRpcMessage = AcpSchema.deserializeJsonRpcMessage(jsonMapper, message); - if (!inboundSink.tryEmitNext(jsonRpcMessage).isSuccess()) { - if (!isClosing.get()) { - logger.error("Failed to enqueue inbound message"); - } - } - } - catch (Exception e) { - if (!isClosing.get()) { - logger.error("Error processing inbound message", e); - exceptionHandler.accept(e); - } - } - } - - @OnWebSocketClose - public void onClose(Session session, int statusCode, String reason) { - logger.info("WebSocket client disconnected: {} - {}", statusCode, reason); - clientSession = null; - isClosing.set(true); - inboundSink.tryEmitComplete(); - terminationSink.tryEmitValue(null); // Signal termination for awaitTermination() - } - - @OnWebSocketError - public void onError(Session session, Throwable error) { - if (!isClosing.get()) { - logger.error("WebSocket error", error); - exceptionHandler.accept(error); - } - isClosing.set(true); - inboundSink.tryEmitComplete(); - terminationSink.tryEmitValue(null); // Signal termination for awaitTermination() - } - - } - -} diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransportTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransportTest.java deleted file mode 100644 index 2a6486f4..00000000 --- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/agent/transport/WebSocketAcpAgentTransportTest.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package com.agentclientprotocol.sdk.agent.transport; - -import java.time.Duration; - -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Unit tests for {@link WebSocketAcpAgentTransport}. - */ -class WebSocketAcpAgentTransportTest { - - private AcpJsonMapper jsonMapper; - - @BeforeEach - void setUp() { - jsonMapper = AcpJsonMapper.createDefault(); - } - - @Test - void constructorValidatesPort() { - assertThatThrownBy(() -> new WebSocketAcpAgentTransport(0, jsonMapper)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Port"); - } - - @Test - void constructorValidatesNegativePort() { - assertThatThrownBy(() -> new WebSocketAcpAgentTransport(-1, jsonMapper)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Port"); - } - - @Test - void constructorValidatesPath() { - assertThatThrownBy(() -> new WebSocketAcpAgentTransport(8080, "", jsonMapper)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Path"); - } - - @Test - void constructorValidatesJsonMapper() { - assertThatThrownBy(() -> new WebSocketAcpAgentTransport(8080, null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("JsonMapper"); - } - - @Test - void idleTimeoutIsConfigurable() { - WebSocketAcpAgentTransport transport = new WebSocketAcpAgentTransport(8080, jsonMapper) - .idleTimeout(Duration.ofMinutes(60)); - - assertThat(transport).isNotNull(); - assertThat(transport.getPort()).isEqualTo(8080); - } - - @Test - void defaultAcpPathIsCorrect() { - assertThat(WebSocketAcpAgentTransport.DEFAULT_ACP_PATH).isEqualTo("/acp"); - } - - @Test - void getPortReturnsConfiguredPort() { - WebSocketAcpAgentTransport transport = new WebSocketAcpAgentTransport(9999, jsonMapper); - assertThat(transport.getPort()).isEqualTo(9999); - } - - @Test - void startReturnsErrorOnDoubleStart() throws Exception { - // Use a high port to avoid conflicts - WebSocketAcpAgentTransport transport = new WebSocketAcpAgentTransport(19999, "/test", jsonMapper); - - try { - // First start - should succeed - transport.start(msg -> Mono.empty()).block(Duration.ofSeconds(5)); - - // Second start should fail with IllegalStateException - assertThatThrownBy(() -> transport.start(msg -> Mono.empty()).block(Duration.ofSeconds(5))) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("Already started"); - } - finally { - // Cleanup - transport.closeGracefully().block(Duration.ofSeconds(5)); - } - } - - @Test - void closeGracefullyCompletesWithoutStart() { - WebSocketAcpAgentTransport transport = new WebSocketAcpAgentTransport(8080, jsonMapper); - - // Should complete without error even when not started - transport.closeGracefully().block(Duration.ofSeconds(5)); - } - -} diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientAgentTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientAgentTest.java deleted file mode 100644 index ec94d24c..00000000 --- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientAgentTest.java +++ /dev/null @@ -1,274 +0,0 @@ -/* - * Copyright 2025-2025 the original author or authors. - */ - -package com.agentclientprotocol.sdk.integration; - -import java.net.URI; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; - -import com.agentclientprotocol.sdk.agent.AcpAgent; -import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; -import com.agentclientprotocol.sdk.agent.transport.WebSocketAcpAgentTransport; -import com.agentclientprotocol.sdk.client.AcpAsyncClient; -import com.agentclientprotocol.sdk.client.AcpClient; -import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Integration tests for WebSocket client-agent communication. - * - *

    - * These tests verify that the WebSocket transport correctly handles the full - * ACP protocol lifecycle over a real network connection. - *

    - * - *

    - * Note: Currently disabled pending Jetty 12 WebSocket configuration fixes. - * The WebSocket transports are functional but require additional server setup. - *

    - */ -// @Disabled - Re-enabled to investigate WebSocket issues -class WebSocketClientAgentTest { - - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - private static final int TEST_PORT = 18080; // High port to avoid conflicts - - private AcpJsonMapper jsonMapper; - - private WebSocketAcpAgentTransport agentTransport; - - private WebSocketAcpClientTransport clientTransport; - - private AcpAsyncAgent agent; - - private AcpAsyncClient client; - - @BeforeEach - void setUp() { - jsonMapper = AcpJsonMapper.createDefault(); - } - - @AfterEach - void tearDown() { - if (client != null) { - try { - client.closeGracefully().block(Duration.ofSeconds(5)); - } - catch (Exception ignored) { - } - } - if (agent != null) { - try { - agent.closeGracefully().block(Duration.ofSeconds(5)); - } - catch (Exception ignored) { - } - } - } - - @Test - void initializeHandshakeSucceeds() throws Exception { - // Set up agent - agentTransport = new WebSocketAcpAgentTransport(TEST_PORT, jsonMapper); - agent = AcpAgent.async(agentTransport) - .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("test-session", null, null))) - .promptHandler((request, updater) -> Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(500); // Allow server to fully start - - // Set up client - URI serverUri = URI.create("ws://localhost:" + TEST_PORT + "/acp"); - clientTransport = new WebSocketAcpClientTransport(serverUri, jsonMapper); - client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - // Test initialize - AcpSchema.InitializeResponse response = client - .initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - - assertThat(response).isNotNull(); - assertThat(response.protocolVersion()).isEqualTo(1); - } - - @Test - void sessionCreationWorks() throws Exception { - // Set up agent - agentTransport = new WebSocketAcpAgentTransport(TEST_PORT + 1, jsonMapper); - agent = AcpAgent.async(agentTransport) - .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(request -> { - assertThat(request.cwd()).isEqualTo("/workspace"); - return Mono.just(new AcpSchema.NewSessionResponse("ws-session-123", null, null)); - }) - .promptHandler((request, updater) -> Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(200); - - // Set up client - URI serverUri = URI.create("ws://localhost:" + (TEST_PORT + 1) + "/acp"); - clientTransport = new WebSocketAcpClientTransport(serverUri, jsonMapper); - client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - // Initialize and create session - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); - - AcpSchema.NewSessionResponse session = client - .newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())) - .block(TIMEOUT); - - assertThat(session).isNotNull(); - assertThat(session.sessionId()).isEqualTo("ws-session-123"); - } - - @Test - void promptResponseFlowWorks() throws Exception { - // Set up agent - agentTransport = new WebSocketAcpAgentTransport(TEST_PORT + 2, jsonMapper); - agent = AcpAgent.async(agentTransport) - .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("prompt-session", null, null))) - .promptHandler((request, updater) -> { - // Verify we received the prompt - assertThat(request.prompt()).hasSize(1); - AcpSchema.TextContent text = (AcpSchema.TextContent) request.prompt().get(0); - assertThat(text.text()).isEqualTo("Hello from WebSocket!"); - return Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(200); - - // Set up client - URI serverUri = URI.create("ws://localhost:" + (TEST_PORT + 2) + "/acp"); - clientTransport = new WebSocketAcpClientTransport(serverUri, jsonMapper); - client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - // Full flow - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - AcpSchema.PromptResponse response = client - .prompt(new AcpSchema.PromptRequest("prompt-session", - List.of(new AcpSchema.TextContent("Hello from WebSocket!")))) - .block(TIMEOUT); - - assertThat(response).isNotNull(); - assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - } - - @Test - void sessionUpdatesStreamCorrectly() throws Exception { - // Set up agent with streaming updates - agentTransport = new WebSocketAcpAgentTransport(TEST_PORT + 3, jsonMapper); - agent = AcpAgent.async(agentTransport) - .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("stream-session", null, null))) - .promptHandler((request, updater) -> { - // Send streaming updates before response - return updater.sendUpdate("stream-session", - new AcpSchema.AgentThoughtChunk("agent_thought_chunk", - new AcpSchema.TextContent("Thinking..."))) - .then(updater.sendUpdate("stream-session", - new AcpSchema.AgentMessageChunk("agent_message_chunk", - new AcpSchema.TextContent("Hello!")))) - .then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN))); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(200); - - // Set up client with update consumer - AtomicReference receivedUpdate = new AtomicReference<>(); - URI serverUri = URI.create("ws://localhost:" + (TEST_PORT + 3) + "/acp"); - clientTransport = new WebSocketAcpClientTransport(serverUri, jsonMapper); - client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { - receivedUpdate.set(notification); - return Mono.empty(); - }) - .build(); - - // Full flow - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())).block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - client.prompt(new AcpSchema.PromptRequest("stream-session", - List.of(new AcpSchema.TextContent("Trigger updates")))).block(TIMEOUT); - - // Verify we received at least one update - assertThat(receivedUpdate.get()).isNotNull(); - } - - @Test - void agentToClientFileReadWorks() throws Exception { - // Use AtomicReference for agent self-reference - AtomicReference agentRef = new AtomicReference<>(); - - agentTransport = new WebSocketAcpAgentTransport(TEST_PORT + 4, jsonMapper); - agent = AcpAgent.async(agentTransport) - .initializeHandler(request -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(request -> Mono.just(new AcpSchema.NewSessionResponse("file-session", null, null))) - .promptHandler((request, updater) -> { - // Request file from client - return agentRef.get() - .readTextFile(new AcpSchema.ReadTextFileRequest("file-session", "/src/Main.java", null, null)) - .flatMap(fileResponse -> { - assertThat(fileResponse.content()).contains("public class Main"); - return Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN)); - }); - }) - .build(); - agentRef.set(agent); - - agent.start().block(TIMEOUT); - Thread.sleep(500); // Allow server to fully start - - // Set up client with file handler - URI serverUri = URI.create("ws://localhost:" + (TEST_PORT + 4) + "/acp"); - clientTransport = new WebSocketAcpClientTransport(serverUri, jsonMapper); - client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - .readTextFileHandler(params -> { - return Mono.just(new AcpSchema.ReadTextFileResponse("public class Main { }")); - }) - .build(); - - // Full flow - advertise file read capability - AcpSchema.FileSystemCapability fsCaps = new AcpSchema.FileSystemCapability(true, false); - AcpSchema.ClientCapabilities caps = new AcpSchema.ClientCapabilities(fsCaps, false); - client.initialize(new AcpSchema.InitializeRequest(1, caps)).block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - AcpSchema.PromptResponse response = client - .prompt(new AcpSchema.PromptRequest("file-session", - List.of(new AcpSchema.TextContent("Read a file")))) - .block(TIMEOUT); - - assertThat(response).isNotNull(); - assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - } - -} diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientInboundEchoReproTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientInboundEchoReproTest.java deleted file mode 100644 index 3178b7ef..00000000 --- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketClientInboundEchoReproTest.java +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.integration; - -import java.io.IOException; -import java.net.ServerSocket; -import java.net.URI; -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import com.agentclientprotocol.sdk.agent.transport.WebSocketAcpAgentTransport; -import com.agentclientprotocol.sdk.client.AcpAsyncClient; -import com.agentclientprotocol.sdk.client.AcpClient; -import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.json.TypeRef; -import com.agentclientprotocol.sdk.spec.AcpAgentSession; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Reproduces the WebSocket client transport echo bug on main. - * - *

    - * This test isolates the agent-request path: - *

    - *
      - *
    1. The agent receives {@code session/prompt} from the client.
    2. - *
    3. While handling that prompt, the agent sends {@code fs/read_text_file} to the - * client.
    4. - *
    5. The client has registered {@code readTextFileHandler}, so it should handle the - * request locally and send only a JSON-RPC response with the same id.
    6. - *
    7. The original {@code fs/read_text_file} request must not be sent back to the - * agent.
    8. - *
    - * - *

    - * On main, {@code AcpClientSession} wires the transport handler with - * {@code mono -> mono.doOnNext(this::handle)}. Reactor {@code doOnNext} preserves the - * original message downstream, and the WebSocket transport forwards handler-emitted - * messages back onto the socket. The result is that an inbound agent request can be - * echoed back to the agent after being handled by the client. - *

    - */ -class WebSocketClientInboundEchoReproTest { - - private static final Duration TIMEOUT = Duration.ofSeconds(5); - - @Test - void clientSessionShouldNotEchoAgentRequestsBackToAgent() throws Exception { - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - int port = findFreePort(); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AtomicReference agentSessionRef = new AtomicReference<>(); - CountDownLatch echoedRequestReceived = new CountDownLatch(1); - - AcpAgentSession agentSession = null; - AcpAsyncClient client = null; - - try { - Map> requestHandlers = new HashMap<>(); - requestHandlers.put(AcpSchema.METHOD_INITIALIZE, - params -> Mono.just(new AcpSchema.InitializeResponse(1, new AcpSchema.AgentCapabilities(), List.of()))); - requestHandlers.put(AcpSchema.METHOD_SESSION_NEW, - params -> Mono.just(new AcpSchema.NewSessionResponse("echo-session", null, null))); - - // The prompt handler deliberately sends an agent->client request. The expected - // protocol flow is: - // - // agent -> client: request id=N, method=fs/read_text_file - // client -> agent: response id=N, result={ content: "client content" } - // - // The original request is not a client->agent message and should never be - // observed by the agent's inbound request router. - requestHandlers.put(AcpSchema.METHOD_SESSION_PROMPT, params -> agentSessionRef.get() - .sendRequest(AcpSchema.METHOD_FS_READ_TEXT_FILE, - new AcpSchema.ReadTextFileRequest("echo-session", "/tmp/input.txt", null, null), - new TypeRef() { - }) - .thenReturn(AcpSchema.PromptResponse.endTurn())); - - // Trap the agent->client method on the agent side. This handler should never run: - // fs/read_text_file is a client method, so if the agent receives it here, the - // client has echoed the inbound agent request back over the WebSocket transport. - // Returning "unexpected echo" makes the trap harmless to the rest of the prompt - // flow while the latch records that the invalid path happened. - requestHandlers.put(AcpSchema.METHOD_FS_READ_TEXT_FILE, params -> { - echoedRequestReceived.countDown(); - return Mono.just(new AcpSchema.ReadTextFileResponse("unexpected echo")); - }); - - agentSession = new AcpAgentSession(TIMEOUT, agentTransport, requestHandlers, Map.of()); - agentSessionRef.set(agentSession); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - // Registering this handler means the client can satisfy fs/read_text_file - // locally. It has no reason to route the request back to the agent. - .readTextFileHandler(params -> Mono.just(new AcpSchema.ReadTextFileResponse("client content"))) - .build(); - - // Advertise the matching client capability so the agent is allowed to make the - // fs/read_text_file request during prompt handling. - client.initialize(new AcpSchema.InitializeRequest(1, - new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, false), false))) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - AcpSchema.PromptResponse response = client - .prompt(new AcpSchema.PromptRequest("echo-session", List.of(new AcpSchema.TextContent("read file")))) - .block(TIMEOUT); - - assertThat(response).isNotNull(); - assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - - // This should remain false. On main it becomes true, proving that the - // WebSocket client transport echoed the inbound fs/read_text_file request back - // to the agent. - assertThat(echoedRequestReceived.await(1, TimeUnit.SECONDS)) - .as("WebSocket client session must not send inbound agent requests back to the agent") - .isFalse(); - } - finally { - if (client != null) { - client.closeGracefully().block(TIMEOUT); - } - if (agentSession != null) { - agentSession.closeGracefully().block(TIMEOUT); - } - } - } - - private static int findFreePort() throws IOException { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } - -} diff --git a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketEdgeCaseTest.java b/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketEdgeCaseTest.java deleted file mode 100644 index 2f0f08f5..00000000 --- a/acp-websocket-jetty/src/test/java/com/agentclientprotocol/sdk/integration/WebSocketEdgeCaseTest.java +++ /dev/null @@ -1,379 +0,0 @@ -/* - * Copyright 2025-2026 the original author or authors. - */ - -package com.agentclientprotocol.sdk.integration; - -import java.io.IOException; -import java.net.ServerSocket; -import java.net.URI; -import java.time.Duration; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; - -import com.agentclientprotocol.sdk.agent.AcpAgent; -import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; -import com.agentclientprotocol.sdk.agent.transport.WebSocketAcpAgentTransport; -import com.agentclientprotocol.sdk.client.AcpAsyncClient; -import com.agentclientprotocol.sdk.client.AcpClient; -import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * WebSocket transport edge case tests. - * - * Covers: concurrent prompts, cancel mid-prompt, permission requests, - * write file requests, large messages, and multiple sequential prompts. - */ -class WebSocketEdgeCaseTest { - - private static final Duration TIMEOUT = Duration.ofSeconds(10); - - @Test - void concurrentPromptsRejected() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - CountDownLatch promptStarted = new CountDownLatch(1); - CountDownLatch promptRelease = new CountDownLatch(1); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> { - promptStarted.countDown(); - // Block until released - try { promptRelease.await(10, TimeUnit.SECONDS); } catch (InterruptedException ignored) {} - return Mono.just(AcpSchema.PromptResponse.endTurn()); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - try { - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - // Fire first prompt (will block in handler) - var firstPrompt = client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("first")))).toFuture(); - - // Wait for it to start processing - assertThat(promptStarted.await(5, TimeUnit.SECONDS)).isTrue(); - - // Second prompt should fail (either rejected with "active prompt" or timeout) - assertThatThrownBy(() -> - client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("second")))).block(Duration.ofSeconds(3)) - ).isNotNull(); - - // Release the first prompt - promptRelease.countDown(); - var result = firstPrompt.get(5, TimeUnit.SECONDS); - assertThat(result.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - @Test - void cancelDuringPromptOverWebSocket() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - CountDownLatch cancelReceived = new CountDownLatch(1); - CountDownLatch promptStarted = new CountDownLatch(1); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> { - promptStarted.countDown(); - // Simulate slow work - return Mono.delay(Duration.ofSeconds(5)) - .then(Mono.just(new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED))); - }) - .cancelHandler(notification -> { - assertThat(notification.sessionId()).isEqualTo("s1"); - cancelReceived.countDown(); - return Mono.empty(); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - try { - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - // Start prompt in background - client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("slow work")))).subscribe(); - - // Wait for prompt to start, then cancel - assertThat(promptStarted.await(5, TimeUnit.SECONDS)).isTrue(); - client.cancel(new AcpSchema.CancelNotification("s1")).block(TIMEOUT); - - // Verify cancel was received by the agent - assertThat(cancelReceived.await(5, TimeUnit.SECONDS)).isTrue(); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - @Test - void permissionRequestOverWebSocket() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - AtomicReference agentRef = new AtomicReference<>(); - AtomicReference permResponse = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> { - var toolCall = new AcpSchema.ToolCallUpdate("tool-1", "Delete File", - AcpSchema.ToolKind.DELETE, AcpSchema.ToolCallStatus.PENDING, null, null, null, null); - var options = List.of( - new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE), - new AcpSchema.PermissionOption("deny", "Deny", AcpSchema.PermissionOptionKind.REJECT_ONCE)); - return agentRef.get() - .requestPermission(new AcpSchema.RequestPermissionRequest("s1", toolCall, options)) - .doOnNext(resp -> { permResponse.set(resp); latch.countDown(); }) - .then(Mono.just(AcpSchema.PromptResponse.endTurn())); - }) - .build(); - agentRef.set(agent); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - .requestPermissionHandler(req -> { - assertThat(req.toolCall().title()).isEqualTo("Delete File"); - return Mono.just(new AcpSchema.RequestPermissionResponse( - new AcpSchema.PermissionSelected("allow"))); - }) - .build(); - - try { - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("delete something")))).block(TIMEOUT); - - assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(permResponse.get().outcome()).isInstanceOf(AcpSchema.PermissionSelected.class); - assertThat(((AcpSchema.PermissionSelected) permResponse.get().outcome()).optionId()) - .isEqualTo("allow"); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - @Test - void writeFileRequestOverWebSocket() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - AtomicReference agentRef = new AtomicReference<>(); - AtomicReference writtenPath = new AtomicReference<>(); - AtomicReference writtenContent = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> - agentRef.get() - .writeTextFile(new AcpSchema.WriteTextFileRequest("s1", "/out.txt", "hello world")) - .doOnSuccess(v -> latch.countDown()) - .then(Mono.just(AcpSchema.PromptResponse.endTurn()))) - .build(); - agentRef.set(agent); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - .writeTextFileHandler(req -> { - writtenPath.set(req.path()); - writtenContent.set(req.content()); - return Mono.just(new AcpSchema.WriteTextFileResponse()); - }) - .build(); - - try { - var caps = new AcpSchema.ClientCapabilities( - new AcpSchema.FileSystemCapability(false, true), false); - client.initialize(new AcpSchema.InitializeRequest(1, caps)).block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("write a file")))).block(TIMEOUT); - - assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(writtenPath.get()).isEqualTo("/out.txt"); - assertThat(writtenContent.get()).isEqualTo("hello world"); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - @Test - void largeMessageOverWebSocket() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - - // Build a large prompt (~500KB) to test beyond the old 64KB default - String largeText = "x".repeat(500_000); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> { - // Verify the large text arrived intact - String text = req.text(); - assertThat(text).hasSize(500_000); - assertThat(text).startsWith("xxx"); - return ctx.sendMessage("received " + text.length()) - .then(Mono.just(AcpSchema.PromptResponse.endTurn())); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - AtomicReference agentResponse = new AtomicReference<>(); - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport) - .requestTimeout(TIMEOUT) - .sessionUpdateConsumer(notification -> { - if (notification.update() instanceof AcpSchema.AgentMessageChunk msg) { - agentResponse.set(((AcpSchema.TextContent) msg.content()).text()); - } - return Mono.empty(); - }) - .build(); - - try { - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - AcpSchema.PromptResponse response = client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent(largeText)))).block(TIMEOUT); - - assertThat(response.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - assertThat(agentResponse.get()).isEqualTo("received 500000"); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - @Test - void multipleSequentialPromptsOverWebSocket() throws Exception { - int port = findFreePort(); - AcpJsonMapper jsonMapper = AcpJsonMapper.createDefault(); - CopyOnWriteArrayList receivedPrompts = new CopyOnWriteArrayList<>(); - - WebSocketAcpAgentTransport agentTransport = new WebSocketAcpAgentTransport(port, jsonMapper); - AcpAsyncAgent agent = AcpAgent.async(agentTransport) - .requestTimeout(TIMEOUT) - .initializeHandler(req -> Mono.just(new AcpSchema.InitializeResponse( - 1, new AcpSchema.AgentCapabilities(), List.of()))) - .newSessionHandler(req -> Mono.just(new AcpSchema.NewSessionResponse("s1", null, null))) - .promptHandler((req, ctx) -> { - receivedPrompts.add(req.text()); - return ctx.sendMessage("echo: " + req.text()) - .then(Mono.just(AcpSchema.PromptResponse.endTurn())); - }) - .build(); - - agent.start().block(TIMEOUT); - Thread.sleep(300); - - WebSocketAcpClientTransport clientTransport = new WebSocketAcpClientTransport( - URI.create("ws://localhost:" + port + "/acp"), jsonMapper); - AcpAsyncClient client = AcpClient.async(clientTransport).requestTimeout(TIMEOUT).build(); - - try { - client.initialize(new AcpSchema.InitializeRequest(1, new AcpSchema.ClientCapabilities())) - .block(TIMEOUT); - client.newSession(new AcpSchema.NewSessionRequest("/workspace", List.of())).block(TIMEOUT); - - // Send 5 sequential prompts over the same WebSocket connection - for (int i = 1; i <= 5; i++) { - AcpSchema.PromptResponse resp = client.prompt(new AcpSchema.PromptRequest("s1", - List.of(new AcpSchema.TextContent("prompt-" + i)))).block(TIMEOUT); - assertThat(resp.stopReason()).isEqualTo(AcpSchema.StopReason.END_TURN); - } - - assertThat(receivedPrompts).containsExactly( - "prompt-1", "prompt-2", "prompt-3", "prompt-4", "prompt-5"); - } - finally { - client.closeGracefully().block(TIMEOUT); - agent.closeGracefully().block(TIMEOUT); - } - } - - private static int findFreePort() throws IOException { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } - -} diff --git a/config/errorprone/reactor-ignorable-results.txt b/config/errorprone/reactor-ignorable-results.txt new file mode 100644 index 00000000..1d77b7a7 --- /dev/null +++ b/config/errorprone/reactor-ignorable-results.txt @@ -0,0 +1,35 @@ +# Reactor methods whose result the Error Prone build may drop (CheckReturnValue:ApiExclusionList). +# The errorprone profile makes every reactor.core.publisher result must-use, so that an operator +# whose Mono or Flux is dropped (and so never subscribed) fails the build. One signature per line, +# erased parameter types; a line starting with # matches no method. +# +# subscribe: fire-and-forget subscriptions; the Disposable is not needed to keep them running. +reactor.core.publisher.Mono#subscribe() +reactor.core.publisher.Mono#subscribe(java.util.function.Consumer) +reactor.core.publisher.Mono#subscribe(java.util.function.Consumer,java.util.function.Consumer) +reactor.core.publisher.Mono#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable) +reactor.core.publisher.Mono#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable,java.util.function.Consumer) +reactor.core.publisher.Mono#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable,reactor.util.context.Context) +reactor.core.publisher.Flux#subscribe() +reactor.core.publisher.Flux#subscribe(java.util.function.Consumer) +reactor.core.publisher.Flux#subscribe(java.util.function.Consumer,java.util.function.Consumer) +reactor.core.publisher.Flux#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable) +reactor.core.publisher.Flux#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable,java.util.function.Consumer) +reactor.core.publisher.Flux#subscribe(java.util.function.Consumer,java.util.function.Consumer,java.lang.Runnable,reactor.util.context.Context) +# Terminal signals: a failed emit almost always means the sink already terminated, which is the +# outcome the caller wants. A racing emission can also refuse it (FAIL_NON_SERIALIZED); where a +# lost completion leaves someone waiting, the caller must handle that (see NotificationQueue). +# tryEmitNext is NOT listed: dropping its result silently loses a message. +reactor.core.publisher.Sinks.Empty#tryEmitEmpty() +reactor.core.publisher.Sinks.Empty#tryEmitError(java.lang.Throwable) +reactor.core.publisher.Sinks.One#tryEmitValue(java.lang.Object) +reactor.core.publisher.Sinks.Many#tryEmitComplete() +reactor.core.publisher.Sinks.Many#tryEmitError(java.lang.Throwable) +# Fluent callback registration: returns the same sink. +reactor.core.publisher.FluxSink#onCancel(reactor.core.Disposable) +reactor.core.publisher.FluxSink#onDispose(reactor.core.Disposable) +reactor.core.publisher.MonoSink#onCancel(reactor.core.Disposable) +reactor.core.publisher.MonoSink#onDispose(reactor.core.Disposable) +reactor.core.publisher.FluxSink#onRequest(java.util.function.LongConsumer) +# A FluxSink emission returns the same sink; FluxSink buffers or signals on its own. +reactor.core.publisher.FluxSink#next(java.lang.Object) diff --git a/config/pmd/ruleset.xml b/config/pmd/ruleset.xml new file mode 100644 index 00000000..fa6fb448 --- /dev/null +++ b/config/pmd/ruleset.xml @@ -0,0 +1,126 @@ + + + + + Method and class size, complexity and parameter count for the ACP Java SDK. + + + + + + + + + + + + + + + + + + + + + + + + + + + + A lambda body with 30 or more statements, counted the way NCSS counts a method's. + 3 + + + = 30] +]]> + + + + + + + + + + + + + + diff --git a/config/spotbugs/spotbugs-exclude.xml b/config/spotbugs/spotbugs-exclude.xml new file mode 100644 index 00000000..550e212d --- /dev/null +++ b/config/spotbugs/spotbugs-exclude.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/config/spotbugs/spotbugs-include.xml b/config/spotbugs/spotbugs-include.xml new file mode 100644 index 00000000..360a5183 --- /dev/null +++ b/config/spotbugs/spotbugs-include.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-testing/.gitignore b/integration-testing/.gitignore index 66d88b70..beba47a0 100644 --- a/integration-testing/.gitignore +++ b/integration-testing/.gitignore @@ -3,7 +3,12 @@ # Per-scenario logs (uploaded as a CI artifact) logs/ programs/java/target/ +programs/micronaut/target/ programs/rust/target/ programs/rust/Cargo.lock # The repository .gitignore ignores README.md (for plans/ research checkouts); keep ours. !README.md +# Self-pair cells are generated locally (GenConfigs.java --self) and never committed. +configs/self-*.json +programs/kotlin/build/ +programs/kotlin/.gradle/ diff --git a/integration-testing/GenConfigs.java b/integration-testing/GenConfigs.java new file mode 100644 index 00000000..cb8faed0 --- /dev/null +++ b/integration-testing/GenConfigs.java @@ -0,0 +1,901 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//DEPS com.fasterxml.jackson.core:jackson-databind:2.17.1 +//JAVA 17+ + +import com.fasterxml.jackson.core.util.DefaultIndenter; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.core.util.Separators; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Generates the cross-SDK cell configs from steps.json, matrix.json and expectations/*.json. + * + *
    + *   cd integration-testing
    + *   jbang GenConfigs.java                      # (re)write configs/x-*.json for the enabled languages
    + *   jbang GenConfigs.java --check              # exit 1 if any generated config is stale, missing or extra
    + *   jbang GenConfigs.java --list               # print the cells and their step counts, write nothing
    + *   jbang GenConfigs.java --enable typescript --self --steps all   # local development (never commit the result)
    + * 
    + * + * Options: {@code --enable } treats languages as enabled; {@code --self} also writes + * self-<lang>-<transport> cells (git-ignored); {@code --steps seed|all|} overrides + * matrix.json's stepSelection. Every written file carries a {@code comment} marker; --check + * compares them with what the committed inputs produce, ignoring self-* cells. + * + * The output is ordinary runner configs: RunScenario, Checks, Scenario and Proc are unchanged. + */ +public class GenConfigs { + + static final ObjectMapper M = new ObjectMapper(); + + static final String MARKER = "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate."; + + static final String SMOKE_MARKER = "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate."; + + static final Pattern SEE = Pattern.compile("^(P\\d+b?|peer:[a-z0-9-]+ \\S.*|spec:\\S.*)$"); + + static final Set WHEN_KEYS = Set.of("client", "agent", "transport", "profile"); + + final Path root; + + final JsonNode steps; + + final JsonNode matrix; + + /** The framework smoke matrix (smoke.json), or a missing node. */ + final JsonNode smoke; + + final List expectations = new ArrayList<>(); + + final Set enabled = new LinkedHashSet<>(); + + final List problems = new ArrayList<>(); + + String stepSelection; + + boolean self; + + record Expectation(String file, int index, String step, Map> when, String contains, String see, + String reason) { + + boolean matches(Cell c) { + for (Map.Entry> e : when.entrySet()) { + String v = switch (e.getKey()) { + case "client" -> c.client; + case "agent" -> c.agent; + case "transport" -> c.transport; + default -> c.profile; + }; + if (!e.getValue().contains(v)) { + return false; + } + } + return true; + } + + } + + record Cell(String name, String client, String agent, String transport, String profile) { + } + + public static void main(String... args) throws Exception { + boolean check = false; + boolean list = false; + String steps = null; + List enable = new ArrayList<>(); + boolean self = false; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--check" -> check = true; + case "--list" -> list = true; + case "--self" -> self = true; + case "--steps" -> steps = args[++i]; + case "--enable" -> enable.addAll(List.of(args[++i].split(","))); + case "-h", "--help" -> { + System.out.println("Usage: jbang GenConfigs.java [--check | --list] [--enable ] [--self] [--steps seed|all|]"); + return; + } + default -> { + System.err.println("Unknown option " + args[i]); + System.exit(2); + } + } + } + if (check && (self || steps != null || !enable.isEmpty())) { + System.err.println("--check compares with the committed inputs only; drop --self, --steps and --enable"); + System.exit(2); + } + GenConfigs g = new GenConfigs(root()); + g.self = self; + g.stepSelection = steps != null ? steps : g.matrix.path("stepSelection").asText("seed"); + for (String l : enable) { + if (!g.matrix.path("languages").has(l)) { + System.err.println("--enable: unknown language " + l); + System.exit(2); + } + g.enabled.add(l); + } + g.validate(); + if (!g.problems.isEmpty()) { + g.problems.forEach(p -> System.err.println("ERROR " + p)); + System.exit(2); + } + Map generated = g.generate(); + if (list) { + return; + } + System.exit(check ? g.check(generated) : g.write(generated)); + } + + static Path root() { + Path cwd = Path.of("").toAbsolutePath(); + for (Path p : List.of(cwd, cwd.resolve("integration-testing"))) { + if (Files.exists(p.resolve("GenConfigs.java")) && Files.exists(p.resolve("steps.json"))) { + return p; + } + } + throw new IllegalStateException("Run from integration-testing/ or the repository root"); + } + + GenConfigs(Path root) throws Exception { + this.root = root; + this.steps = M.readTree(root.resolve("steps.json").toFile()); + this.matrix = M.readTree(root.resolve("matrix.json").toFile()); + Path smokeFile = root.resolve("smoke.json"); + this.smoke = Files.exists(smokeFile) ? M.readTree(smokeFile.toFile()) : M.missingNode(); + matrix.path("languages").fields().forEachRemaining(e -> { + if (e.getValue().path("enabled").asBoolean(false)) { + enabled.add(e.getKey()); + } + }); + Path dir = root.resolve("expectations"); + if (Files.isDirectory(dir)) { + try (Stream s = Files.list(dir)) { + for (Path f : s.filter(p -> p.toString().endsWith(".json")).sorted().toList()) { + readExpectations(f); + } + } + } + } + + void readExpectations(Path f) throws Exception { + String file = "expectations/" + f.getFileName(); + JsonNode doc = M.readTree(f.toFile()); + JsonNode arr = doc.path("expectations"); + if (!arr.isArray()) { + problems.add(file + ": needs an \"expectations\" array"); + return; + } + for (int i = 0; i < arr.size(); i++) { + JsonNode e = arr.get(i); + String where = file + "[" + i + "]"; + Map> when = new TreeMap<>(); + e.path("when").fields().forEachRemaining(w -> { + List vals = new ArrayList<>(); + if (w.getValue().isArray()) { + w.getValue().forEach(v -> vals.add(v.asText())); + } + else { + vals.add(w.getValue().asText()); + } + when.put(w.getKey(), vals); + }); + for (Iterator it = e.fieldNames(); it.hasNext();) { + String k = it.next(); + if (!Set.of("step", "when", "contains", "see", "reason", "comment").contains(k)) { + problems.add(where + ": unknown field " + k); + } + } + for (String k : List.of("step", "contains", "see", "reason")) { + if (!e.path(k).isTextual()) { + problems.add(where + ": \"" + k + "\" must be a string" + (k.equals("contains") ? " (\"\" for any)" : "")); + } + } + expectations.add(new Expectation(file, i, e.path("step").asText(), when, e.path("contains").asText(), + e.path("see").asText(), e.path("reason").asText())); + } + } + + // ---------------------------------------------------------------- validation + + void validate() { + Set ids = new LinkedHashSet<>(); + Set langs = new LinkedHashSet<>(); + matrix.path("languages").fieldNames().forEachRemaining(langs::add); + Set transports = new LinkedHashSet<>(); + matrix.path("transports").forEach(t -> transports.add(t.asText())); + Set profiles = new LinkedHashSet<>(); + matrix.path("profiles").fieldNames().forEachRemaining(profiles::add); + for (JsonNode s : steps.path("steps")) { + String id = s.path("id").asText(""); + if (id.isEmpty() || !id.matches("[a-z][a-z0-9_.-]*")) { + problems.add("steps.json: bad step id '" + id + "'"); + } + if (!ids.add(id)) { + problems.add("steps.json: duplicate step id " + id); + } + if (!s.path("pass").path("client").isTextual() || !s.path("client").isTextual()) { + problems.add("steps.json: step " + id + " needs \"client\" and \"pass.client\""); + } + if (!profiles.contains(profile(s))) { + problems.add("steps.json: step " + id + " has unknown profile " + profile(s)); + } + for (String t : stepTransports(s)) { + if (!transports.contains(t)) { + problems.add("steps.json: step " + id + " has unknown transport " + t); + } + } + s.path("only").fields().forEachRemaining(o -> { + if (!Set.of("client", "agent").contains(o.getKey())) { + problems.add("steps.json: step " + id + " has unknown only." + o.getKey()); + } + o.getValue().forEach(v -> { + if (!langs.contains(v.asText())) { + problems.add("steps.json: step " + id + " names unknown language " + v.asText()); + } + }); + }); + } + String first = steps.path("conventions").path("first").asText(); + String last = steps.path("conventions").path("last").asText(); + if (!ids.contains(first) || !ids.contains(last)) { + problems.add("steps.json: conventions.first/last must name steps"); + } + if (!Set.of("seed", "all").contains(stepSelection)) { + for (String id : stepSelection.split(",")) { + if (!ids.contains(id)) { + problems.add("--steps: unknown step " + id); + } + } + } + for (JsonNode p : matrix.path("pairs")) { + for (JsonNode l : p) { + if (!langs.contains(l.asText())) { + problems.add("matrix.json: pair names unknown language " + l.asText()); + } + } + } + matrix.path("self").forEach(l -> { + if (!langs.contains(l.asText())) { + problems.add("matrix.json: self names unknown language " + l.asText()); + } + }); + matrix.path("facts").forEach(f -> checkWhen("matrix.json fact", f.path("when"), langs, transports, profiles)); + validateSmoke(ids, langs, transports); + List universe = cells(true, true); + for (Expectation x : expectations) { + String where = x.file + "[" + x.index + "] (" + x.step + ")"; + String id = x.step.startsWith("agent.") ? x.step.substring(6) : x.step; + JsonNode s = step(id); + if (s == null) { + problems.add(where + ": no step " + id + " in steps.json"); + continue; + } + if (x.step.startsWith("agent.") && !s.path("pass").path("agent").isTextual()) { + problems.add(where + ": step " + id + " has no agent-side assertion"); + } + ObjectNode w = M.createObjectNode(); + x.when.forEach((k, v) -> { + ArrayNode a = w.putArray(k); + v.forEach(a::add); + }); + checkWhen(where, w, langs, transports, profiles); + if (!SEE.matcher(x.see).matches()) { + problems.add(where + ": see must be a P-item (P3, P1b), \"peer: \" or \"spec:\", got '" + + x.see + "'"); + } + if (x.reason.isBlank()) { + problems.add(where + ": reason is empty"); + } + if (universe.stream().noneMatch(c -> x.matches(c) && cellSteps(c, "all").contains(id))) { + problems.add(where + ": matches no cell of the full matrix (check when and the step's transports/only)"); + } + } + } + + void checkWhen(String where, JsonNode when, Set langs, Set transports, Set profiles) { + when.fields().forEachRemaining(w -> { + if (!WHEN_KEYS.contains(w.getKey())) { + problems.add(where + ": unknown when." + w.getKey()); + return; + } + Set valid = switch (w.getKey()) { + case "client", "agent" -> langs; + case "transport" -> transports; + default -> profiles; + }; + List vals = new ArrayList<>(); + if (w.getValue().isArray()) { + w.getValue().forEach(v -> vals.add(v.asText())); + } + else { + vals.add(w.getValue().asText()); + } + for (String v : vals) { + if (!valid.contains(v)) { + problems.add(where + ": when." + w.getKey() + " has unknown value " + v); + } + } + }); + } + + // ---------------------------------------------------------------- cells + + /** Every cell of the matrix; enabled languages only unless {@code all}. */ + List cells(boolean all, boolean withSelf) { + List out = new ArrayList<>(); + List pairs = new ArrayList<>(); + for (JsonNode p : matrix.path("pairs")) { + pairs.add(new String[] { p.get(0).asText(), p.get(1).asText(), "x-" + p.get(0).asText() + "-" + p.get(1).asText() }); + } + if (withSelf) { + for (JsonNode l : matrix.path("self")) { + pairs.add(new String[] { l.asText(), l.asText(), "self-" + l.asText() }); + } + } + for (String[] p : pairs) { + if (!all && !(enabled.contains(p[0]) && enabled.contains(p[1]))) { + continue; + } + for (JsonNode t : matrix.path("transports")) { + String tr = t.asText(); + if (!lang(p[0]).path("transports").toString().contains("\"" + tr + "\"") + || !lang(p[1]).path("transports").toString().contains("\"" + tr + "\"")) { + continue; + } + for (Iterator> it = matrix.path("profiles").fields(); it.hasNext();) { + Map.Entry pr = it.next(); + out.add(new Cell(p[2] + "-" + tr + pr.getValue().path("suffix").asText(), p[0], p[1], tr, pr.getKey())); + } + } + } + return out; + } + + /** The step ids a cell runs, in catalogue order; empty if the profile adds nothing. */ + List cellSteps(Cell c, String selection) { + String first = steps.path("conventions").path("first").asText(); + String last = steps.path("conventions").path("last").asText(); + Set picked = selection.equals("seed") || selection.equals("all") ? null + : new LinkedHashSet<>(List.of(selection.split(","))); + List out = new ArrayList<>(); + boolean any = false; + for (JsonNode s : steps.path("steps")) { + String id = s.path("id").asText(); + boolean mandatory = id.equals(first) || id.equals(last); + boolean selected = mandatory || switch (selection) { + case "all" -> true; + case "seed" -> s.path("seed").asBoolean(false); + default -> picked.contains(id); + }; + if (!selected || !(mandatory || profile(s).equals(c.profile)) || !stepTransports(s).contains(c.transport)) { + continue; + } + JsonNode only = s.path("only"); + if (only.has("client") && !only.path("client").toString().contains("\"" + c.client + "\"") + || only.has("agent") && !only.path("agent").toString().contains("\"" + c.agent + "\"")) { + continue; + } + out.add(id); + any |= !mandatory; + } + return any ? out : List.of(); + } + + Map generate() throws Exception { + Map out = new TreeMap<>(); + for (Cell c : cells(false, self)) { + List ids = cellSteps(c, stepSelection); + if (ids.isEmpty()) { + continue; + } + ObjectNode cfg = config(c, ids); + out.put(c.name, write(cfg)); + System.out.printf(" %-40s %3d steps, %d expected failure(s)%n", c.name, ids.size(), + cfg.path("assertions").path("expectedFailures").size()); + } + generateSmoke(out); + return out; + } + + ObjectNode config(Cell c, List ids) { + boolean stdio = c.transport.equals("stdio"); + String agentProc = stdio ? "client" : "agent"; + ObjectNode cfg = M.createObjectNode(); + cfg.put("name", c.name); + cfg.put("description", c.client + " client -> " + c.agent + " agent over " + c.transport + ", " + c.profile + + " profile, " + ids.size() + " steps (generated)"); + int clientTimeout = 120 + 5 * ids.size(); + cfg.put("timeoutSec", clientTimeout + 120); + ArrayNode peers = cfg.putArray("peers"); + Set peerSet = new LinkedHashSet<>(); + lang(c.client).path("peers").forEach(p -> peerSet.add(p.asText())); + lang(c.agent).path("peers").forEach(p -> peerSet.add(p.asText())); + peerSet.forEach(peers::add); + + Map env = new LinkedHashMap<>(); + lang(c.client).path("env").fields().forEachRemaining(e -> env.put(e.getKey(), e.getValue().asText())); + lang(c.agent).path("env").fields().forEachRemaining(e -> env.put(e.getKey(), e.getValue().asText())); + + ArrayNode procs = cfg.putArray("processes"); + if (!stdio) { + ObjectNode a = procs.addObject(); + a.put("name", "agent"); + a.put("language", c.agent); + a.put("role", "server"); + a.put("dir", "${ROOT}/" + lang(c.agent).path("dir").asText()); + a.put("build", build(c.agent)); + a.put("run", launcher(c.agent, "agent") + " --transport " + c.transport + " --port ${PORT}"); + ObjectNode aenv = a.putObject("env"); + env.forEach(aenv::put); + ObjectNode ready = a.putObject("ready"); + ready.put("line", "READY"); + ready.put("timeoutSec", lang(c.agent).path("readyTimeoutSec").asInt(60)); + } + ObjectNode cl = procs.addObject(); + cl.put("name", "client"); + cl.put("language", c.client); + cl.put("role", "client"); + cl.put("dir", "${ROOT}/" + lang(c.client).path("dir").asText()); + String build = build(c.client); + if (stdio && !c.agent.equals(c.client)) { + build = build + " && " + build(c.agent); + } + cl.put("build", build); + String url = switch (c.transport) { + case "http" -> " --url http://127.0.0.1:${PORT}/acp"; + case "ws" -> " --url ws://127.0.0.1:${PORT}/acp"; + default -> ""; + }; + cl.put("run", launcher(c.client, "client") + " --transport " + c.transport + url); + ObjectNode cenv = cl.putObject("env"); + cenv.put("STEPS", String.join(",", ids)); + if (stdio) { + cenv.put("AGENT_CMD", launcher(c.agent, "agent") + " --transport stdio"); + } + env.forEach(cenv::put); + cl.put("timeoutSec", clientTimeout); + + // Expected failures: per step key (client step id or agent.), the most specific entry wins. + Map chosen = new LinkedHashMap<>(); + for (String id : ids) { + for (String key : List.of(id, "agent." + id)) { + Expectation best = null; + for (Expectation x : expectations) { + if (x.step.equals(key) && x.matches(c) && (best == null || rank(x, c) > rank(best, c))) { + best = x; + } + } + if (best != null) { + chosen.put(key, best); + } + } + } + ObjectNode as = cfg.putObject("assertions"); + Map> required = new LinkedHashMap<>(); + Map> forbidden = new LinkedHashMap<>(); + Map> patterns = new LinkedHashMap<>(); + for (String id : ids) { + JsonNode s = step(id); + if (s.path("pass").path("agent").isTextual() && !chosen.containsKey(id) && !chosen.containsKey("agent." + id)) { + required.computeIfAbsent(agentProc, k -> new LinkedHashSet<>()).add("STEP agent." + id + " PASS"); + } + } + for (JsonNode f : matrix.path("facts")) { + if (!factMatches(f.path("when"), c)) { + continue; + } + mergeFact(f.path("requiredOutput"), required, agentProc); + mergeFact(f.path("forbiddenOutput"), forbidden, agentProc); + mergeFact(f.path("requiredPatterns"), patterns, agentProc); + } + putLines(as, "requiredOutput", required); + putLines(as, "forbiddenOutput", forbidden); + putLines(as, "requiredPatterns", patterns); + long clientXfail = chosen.keySet().stream().filter(k -> !k.startsWith("agent.")).count(); + ArrayNode checks = as.putArray("checks"); + checks.add("client RESULT pass == " + (ids.size() - clientXfail)); + checks.add("client RESULT fail == " + clientXfail); + ArrayNode xf = as.putArray("expectedFailures"); + chosen.forEach((key, x) -> { + ObjectNode o = xf.addObject(); + o.put("process", key.startsWith("agent.") ? agentProc : "client"); + o.put("step", key); + o.put("contains", x.contains); + o.put("reason", x.reason); + o.put("see", x.see + " [" + x.file + "]"); + }); + ObjectNode report = as.putObject("report"); + report.putArray("client").add("TIMEOUT"); + cfg.put("comment", MARKER); + return cfg; + } + + // ---------------------------------------------------------------- framework smoke matrix + + void validateSmoke(Set ids, Set langs, Set transports) { + if (smoke.isMissingNode()) { + return; + } + if (!langs.contains(smoke.path("peer").asText())) { + problems.add("smoke.json: peer must name a matrix.json language"); + } + for (String list : List.of("agentSteps", "clientSteps")) { + if (!smoke.path(list).isArray() || smoke.path(list).isEmpty()) { + problems.add("smoke.json: " + list + " must be a non-empty array"); + } + smoke.path(list).forEach(id -> { + if (!ids.contains(id.asText())) { + problems.add("smoke.json: " + list + " names unknown step " + id.asText()); + } + }); + } + smoke.path("frameworks").fields().forEachRemaining(f -> { + for (String key : List.of("agentTransports", "clientTransports", "pendingAgentTransports")) { + f.getValue().path(key).forEach(t -> { + if (!transports.contains(t.asText())) { + problems.add("smoke.json: " + f.getKey() + "." + key + " names unknown transport " + t.asText()); + } + }); + } + if (!Files.exists(root.resolve(f.getValue().path("dir").asText()).resolve("launch/agent.sh"))) { + problems.add("smoke.json: " + f.getKey() + ".dir has no launch/agent.sh"); + } + for (String key : List.of("agentArgs", "agentName")) { + if (f.getValue().has(key) && !f.getValue().path(key).isTextual()) { + problems.add("smoke.json: " + f.getKey() + "." + key + " must be a string"); + } + } + }); + } + + /** The steps of a smoke list that apply to a transport, in catalogue order, first and last included. */ + List smokeSteps(String list, String transport) { + Set picked = new LinkedHashSet<>(); + smoke.path(list).forEach(id -> picked.add(id.asText())); + picked.add(steps.path("conventions").path("first").asText()); + picked.add(steps.path("conventions").path("last").asText()); + List out = new ArrayList<>(); + for (JsonNode s : steps.path("steps")) { + String id = s.path("id").asText(); + if (picked.contains(id) && stepTransports(s).contains(transport)) { + out.add(id); + } + } + return out; + } + + void generateSmoke(Map out) throws Exception { + if (smoke.isMissingNode()) { + return; + } + String peer = smoke.path("peer").asText(); + for (Iterator> it = smoke.path("frameworks").fields(); it.hasNext();) { + Map.Entry f = it.next(); + for (JsonNode t : f.getValue().path("agentTransports")) { + List ids = smokeSteps("agentSteps", t.asText()); + String name = "fw-" + f.getKey() + "-agent-" + t.asText(); + out.put(name, write(smokeCell(name, f.getKey(), f.getValue(), peer, t.asText(), ids, true))); + System.out.printf(" %-40s %3d steps%n", name, ids.size()); + } + for (JsonNode t : f.getValue().path("clientTransports")) { + List ids = smokeSteps("clientSteps", t.asText()); + String name = "fw-" + f.getKey() + "-client-" + t.asText(); + out.put(name, write(smokeCell(name, f.getKey(), f.getValue(), peer, t.asText(), ids, false))); + System.out.printf(" %-40s %3d steps%n", name, ids.size()); + } + if (smoke.has("load")) { + String name = "load-" + smoke.path("load").path("clients").asInt() + "-fw-" + f.getKey(); + out.put(name, write(smokeLoad(name, f.getKey(), f.getValue()))); + System.out.printf(" %-40s load%n", name); + } + } + } + + /** The framework program's extra agent flags (smoke.json agentArgs), with a leading space, or none. */ + static String agentArgs(JsonNode fw) { + String args = fw.path("agentArgs").asText("").trim(); + return args.isEmpty() ? "" : " " + args; + } + + String fwBuild(JsonNode fw) { + return "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/" + + fw.path("dir").asText() + "/launch/build.sh"; + } + + /** + * A smoke cell: the peer's client against the framework-hosted agent ({@code agentSide}), or + * the framework-built client against the peer's agent. + */ + ObjectNode smokeCell(String name, String framework, JsonNode fw, String peer, String transport, List ids, + boolean agentSide) { + boolean stdio = transport.equals("stdio"); + String agentProc = stdio ? "client" : "agent"; + String agentDir = agentSide ? fw.path("dir").asText() : lang(peer).path("dir").asText(); + String clientDir = agentSide ? lang(peer).path("dir").asText() : fw.path("dir").asText(); + String agentBuild = agentSide ? fwBuild(fw) : build(peer); + String clientBuild = agentSide ? build(peer) : fwBuild(fw); + ObjectNode cfg = M.createObjectNode(); + cfg.put("name", name); + cfg.put("description", (agentSide ? peer + " client -> " + framework + "-hosted java agent" + : framework + "-built java client -> " + peer + " agent") + " over " + transport + ", " + + ids.size() + " smoke steps (generated from smoke.json)"); + int clientTimeout = 120 + 5 * ids.size(); + cfg.put("timeoutSec", clientTimeout + 180); + ArrayNode peers = cfg.putArray("peers"); + lang(peer).path("peers").forEach(peers::add); + Map env = new LinkedHashMap<>(); + lang(peer).path("env").fields().forEachRemaining(e -> env.put(e.getKey(), e.getValue().asText())); + ArrayNode procs = cfg.putArray("processes"); + if (!stdio) { + ObjectNode a = procs.addObject(); + a.put("name", "agent"); + a.put("language", agentSide ? "java" : peer); + a.put("role", "server"); + a.put("dir", "${ROOT}/" + agentDir); + a.put("build", agentBuild); + a.put("run", "${ROOT}/" + agentDir + "/launch/agent.sh" + (agentSide ? agentArgs(fw) : "") + " --transport " + + transport + " --port ${PORT}"); + ObjectNode aenv = a.putObject("env"); + env.forEach(aenv::put); + ObjectNode ready = a.putObject("ready"); + ready.put("line", "READY"); + ready.put("timeoutSec", agentSide ? fw.path("readyTimeoutSec").asInt(90) : lang(peer).path("readyTimeoutSec").asInt(60)); + } + ObjectNode cl = procs.addObject(); + cl.put("name", "client"); + cl.put("language", agentSide ? peer : "java"); + cl.put("role", "client"); + cl.put("dir", "${ROOT}/" + clientDir); + cl.put("build", stdio ? clientBuild + " && " + agentBuild : clientBuild); + String url = switch (transport) { + case "http" -> " --url http://127.0.0.1:${PORT}/acp"; + case "ws" -> " --url ws://127.0.0.1:${PORT}/acp"; + default -> ""; + }; + cl.put("run", "${ROOT}/" + clientDir + "/launch/client.sh --transport " + transport + url); + ObjectNode cenv = cl.putObject("env"); + cenv.put("STEPS", String.join(",", ids)); + if (stdio) { + cenv.put("AGENT_CMD", "${ROOT}/" + agentDir + "/launch/agent.sh" + (agentSide ? agentArgs(fw) : "") + + " --transport stdio"); + } + env.forEach(cenv::put); + cl.put("timeoutSec", clientTimeout); + + ObjectNode as = cfg.putObject("assertions"); + Map> required = new LinkedHashMap<>(); + for (String id : ids) { + if (step(id).path("pass").path("agent").isTextual()) { + required.computeIfAbsent(agentProc, k -> new LinkedHashSet<>()).add("STEP agent." + id + " PASS"); + } + } + putLines(as, "requiredOutput", required); + if (agentSide) { + // The framework-hosted agent logs nothing at WARN or ERROR in a passing run (over stdio + // its stderr reaches the relaying client), and initialize carries its agentInfo. + Map> forbidden = new LinkedHashMap<>(); + forbidden.put(agentProc, new LinkedHashSet<>(List.of(" ERROR ", " WARN "))); + putLines(as, "forbiddenOutput", forbidden); + Map> patterns = new LinkedHashMap<>(); + patterns.put("client", new LinkedHashSet<>(List.of("STEP init\\.initialize PASS .*\"name\":\"interop-" + + fw.path("agentName").asText(framework) + "-agent\""))); + putLines(as, "requiredPatterns", patterns); + } + ArrayNode checks = as.putArray("checks"); + checks.add("client RESULT pass == " + ids.size()); + checks.add("client RESULT fail == 0"); + as.putArray("expectedFailures"); + as.putObject("report").putArray("client").add("TIMEOUT"); + cfg.put("comment", SMOKE_MARKER); + return cfg; + } + + /** A small load run against the framework-hosted agent over HTTP: the Java load clients. */ + ObjectNode smokeLoad(String name, String framework, JsonNode fw) { + JsonNode load = smoke.path("load"); + int clients = load.path("clients").asInt(); + int prompts = load.path("prompts").asInt(); + ObjectNode cfg = M.createObjectNode(); + cfg.put("name", name); + cfg.put("description", clients + " connections x " + prompts + " prompts against the " + framework + + "-hosted agent over http, two streamed updates each (generated from smoke.json)"); + cfg.put("timeoutSec", 420); + ArrayNode procs = cfg.putArray("processes"); + ObjectNode a = procs.addObject(); + a.put("name", "server"); + a.put("language", "java"); + a.put("role", "server"); + a.put("dir", "${ROOT}/" + fw.path("dir").asText()); + a.put("build", fwBuild(fw)); + a.put("run", "${ROOT}/" + fw.path("dir").asText() + "/launch/agent.sh" + agentArgs(fw) + + " --transport http --port ${PORT}"); + a.putObject("env").put("JAVA_OPTS", load.path("serverJavaOpts").asText("-Xmx512m")); + ObjectNode ready = a.putObject("ready"); + ready.put("line", "READY"); + ready.put("timeoutSec", fw.path("readyTimeoutSec").asInt(90)); + ObjectNode c = procs.addObject(); + c.put("name", "clients"); + c.put("language", "java"); + c.put("role", "client"); + c.put("dir", "${ROOT}/programs/java"); + c.put("build", build("java")); + c.put("run", "java -Xmx1g -Dacp.client.level=INFO -cp target/classes:$(cat target/classpath.txt) load.LoadClients http://127.0.0.1:${PORT}/acp " + + clients + " " + prompts + " perclient"); + c.put("timeoutSec", 300); + ObjectNode as = cfg.putObject("assertions"); + as.putObject("requiredOutput").putArray("clients").add("PEAK_REACHED").add("CLOSED"); + ObjectNode forbidden = as.putObject("forbiddenOutput"); + forbidden.putArray("clients").add("ERROR ").add("CLOSE_ERROR"); + forbidden.putArray("server").add(" ERROR "); + ArrayNode checks = as.putArray("checks"); + load.path("checks").forEach(checks::add); + cfg.put("comment", SMOKE_MARKER); + return cfg; + } + + /** Higher is more specific: more when keys, then the client's file, then the agent's file, then file order. */ + static int rank(Expectation x, Cell c) { + int r = x.when.size() * 10_000; + if (x.file.equals("expectations/" + c.client + ".json")) { + r += 2_000; + } + else if (x.file.equals("expectations/" + c.agent + ".json")) { + r += 1_000; + } + return r - x.index; + } + + static boolean factMatches(JsonNode when, Cell c) { + for (Iterator> it = when.fields(); it.hasNext();) { + Map.Entry w = it.next(); + String v = switch (w.getKey()) { + case "client" -> c.client; + case "agent" -> c.agent; + case "transport" -> c.transport; + default -> c.profile; + }; + boolean ok = w.getValue().isArray() ? w.getValue().toString().contains("\"" + v + "\"") + : w.getValue().asText().equals(v); + if (!ok) { + return false; + } + } + return true; + } + + static void mergeFact(JsonNode byRole, Map> into, String agentProc) { + byRole.fields().forEachRemaining(e -> { + String proc = e.getKey().equals("agent") ? agentProc : e.getKey(); + e.getValue().forEach(v -> into.computeIfAbsent(proc, k -> new LinkedHashSet<>()).add(v.asText())); + }); + } + + static void putLines(ObjectNode as, String field, Map> lines) { + if (lines.isEmpty()) { + return; + } + ObjectNode o = as.putObject(field); + lines.forEach((proc, set) -> { + ArrayNode a = o.putArray(proc); + set.forEach(a::add); + }); + } + + String build(String language) { + StringBuilder sb = new StringBuilder("IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\""); + lang(language).path("env").fields().forEachRemaining(e -> sb.append(' ').append(e.getKey()).append("=\"") + .append(e.getValue().asText()).append('"')); + return sb.append(" ${ROOT}/").append(lang(language).path("dir").asText()).append("/launch/build.sh").toString(); + } + + String launcher(String language, String role) { + return "${ROOT}/" + lang(language).path("dir").asText() + "/launch/" + role + ".sh"; + } + + JsonNode lang(String l) { + return matrix.path("languages").path(l); + } + + JsonNode step(String id) { + for (JsonNode s : steps.path("steps")) { + if (s.path("id").asText().equals(id)) { + return s; + } + } + return null; + } + + static String profile(JsonNode s) { + return s.path("profile").asText("stable"); + } + + List stepTransports(JsonNode s) { + List out = new ArrayList<>(); + (s.has("transports") ? s.path("transports") : matrix.path("transports")).forEach(t -> out.add(t.asText())); + return out; + } + + // ---------------------------------------------------------------- output + + static String write(ObjectNode cfg) throws Exception { + DefaultPrettyPrinter pp = new DefaultPrettyPrinter() + .withSeparators(Separators.createDefaultInstance() + .withObjectFieldValueSpacing(Separators.Spacing.AFTER) + .withArrayEmptySeparator("") + .withObjectEmptySeparator("")); + pp.indentArraysWith(DefaultIndenter.SYSTEM_LINEFEED_INSTANCE.withLinefeed("\n")); + pp.indentObjectsWith(DefaultIndenter.SYSTEM_LINEFEED_INSTANCE.withLinefeed("\n")); + return M.writer(pp).writeValueAsString(cfg) + "\n"; + } + + Set generatedOnDisk() throws Exception { + Set out = new LinkedHashSet<>(); + try (Stream s = Files.list(root.resolve("configs"))) { + for (Path p : s.filter(p -> p.toString().endsWith(".json")).sorted().toList()) { + String text = Files.readString(p); + if (text.contains(MARKER) || text.contains(SMOKE_MARKER)) { + out.add(p.getFileName().toString().replaceFirst("\\.json$", "")); + } + } + } + return out; + } + + int write(Map generated) throws Exception { + for (Map.Entry e : generated.entrySet()) { + Files.writeString(root.resolve("configs").resolve(e.getKey() + ".json"), e.getValue()); + } + for (String name : generatedOnDisk()) { + if (!generated.containsKey(name)) { + Files.delete(root.resolve("configs").resolve(name + ".json")); + System.out.println(" removed stale configs/" + name + ".json"); + } + } + System.out.println("Wrote " + generated.size() + " config(s) to " + root.resolve("configs")); + return 0; + } + + int check(Map generated) throws Exception { + List stale = new ArrayList<>(); + for (Map.Entry e : generated.entrySet()) { + Path p = root.resolve("configs").resolve(e.getKey() + ".json"); + if (!Files.exists(p)) { + stale.add("missing configs/" + e.getKey() + ".json"); + } + else if (!Files.readString(p).equals(e.getValue())) { + stale.add("stale configs/" + e.getKey() + ".json"); + } + } + for (String name : generatedOnDisk()) { + if (!generated.containsKey(name) && !name.startsWith("self-")) { + stale.add("extra configs/" + name + ".json (not produced by the committed inputs)"); + } + } + if (stale.isEmpty()) { + System.out.println("Generated configs are up to date (" + generated.size() + ")"); + return 0; + } + stale.forEach(s -> System.out.println(" " + s)); + System.out.println("Generated configs are out of date: run 'jbang GenConfigs.java' in integration-testing/ and commit"); + return 1; + } + +} diff --git a/integration-testing/README.md b/integration-testing/README.md index 655c319f..881b5e40 100644 --- a/integration-testing/README.md +++ b/integration-testing/README.md @@ -1,28 +1,45 @@ # Cross-SDK and load scenarios -Interop tests of the Streamable HTTP transport against the TypeScript, Rust and Python ACP SDKs, -plus load runs of the Java listener. Each scenario starts real processes in several languages, -checks what they print, and tears them all down. +Interop tests of the Java SDK against the TypeScript, Rust, Python and Kotlin ACP SDKs over stdio, +Streamable HTTP and WebSocket, plus load runs of the Java listener. Each scenario starts real +processes in several languages, checks what they print, and tears them all down. This directory is **not** a Maven module and the root `pom.xml` does not reference it, so `./mvnw verify` and the release build never run it. It runs locally with the scripts below, and in -CI through `.github/workflows/cross-sdk.yml` (manual dispatch plus nightly; never on push or pull -request). +CI through `.github/workflows/cross-sdk.yml` (per peer; manual dispatch plus nightly), +`.github/workflows/interop-java.yml` (the Java<->Java cells on every push and pull request) and +`.github/workflows/framework-smoke.yml` (the [framework smoke matrix](#framework-smoke-matrix), the +release gate: every push to `main`, nightly, and on dispatch for any branch). + +There are four kinds of scenarios: + +- **Generated cells**, `configs/x---[-unstable].json`: one + feature-driven agent and one client program per language, a shared step catalogue + (`steps.json`), and configs generated from `matrix.json` by `GenConfigs.java`. The + [Contracts](#contracts) section is everything a language package needs to take part. +- **Conformance scenarios**, `configs/conf-java-*.json`: the raw JSON-RPC driver + (`programs/raw`) against the Java agent and client, generated by `programs/raw/gen_conf.py`. +- **Framework smoke cells**, `configs/fw-*.json` and `configs/load-10-fw-*.json`: the SDK hosted + by Spring Boot (servlet and WebFlux), Micronaut and Quarkus against the TypeScript SDK, generated from `smoke.json` by + `GenConfigs.java` ([Framework smoke matrix](#framework-smoke-matrix)). +- **Load scenarios**, `configs/load-*.json`, hand-written (except `load-10-fw-*`). ## Prerequisites | Tool | Version | Used for | |---|---|---| | JDK (with `jcmd`) | 17+ | the SDK, the Java programs, the runner, heap and thread samples | -| [JBang](https://www.jbang.dev/download/) | 0.135+ | `RunScenario.java` | +| JDK | 21 | the Kotlin SDK's Gradle toolchain (Kotlin cells only) | +| [JBang](https://www.jbang.dev/download/) | 0.135+ | `RunScenario.java`, `GenConfigs.java` | | Node.js and npm | 20 | the TypeScript SDK and its programs | | Rust (`cargo`) | stable | the Rust SDK and its programs | | Python | 3.12 (`python3 -m venv`) | the Python SDK (plus Hypercorn) and its programs | | git | any | cloning the peer SDKs | -The first run clones the three peer SDKs from GitHub and builds them (npm, cargo, a venv), which -takes a few minutes. Later runs reuse the checkouts in `.cache/` and rebuild a peer only when its -ref resolves to a new commit. +`run-all.sh` checks only the tools the selected scenarios need. The first run clones the peer SDKs +it needs from GitHub and builds them (npm, cargo, a venv, Gradle), which takes a few minutes. +Later runs reuse the checkouts in `.cache/` and rebuild a peer only when its ref resolves to a new +commit. ## Running @@ -33,18 +50,38 @@ One scenario (from `integration-testing/`): ```bash cd integration-testing -jbang RunScenario.java interop-ts-server # installs the SDK first +jbang RunScenario.java x-java-java-stdio # installs the SDK first jbang RunScenario.java load-300 --skip-sdk-install # reuse the SDK already installed jbang RunScenario.java --list ``` -All scenarios, with a pass/fail table at the end (exits non-zero on any unexpected failure): +A set of scenarios, with a pass/fail table at the end (exits non-zero on any unexpected failure): ```bash -integration-testing/scripts/run-all.sh -integration-testing/scripts/run-all.sh --only interop-java-java,load-50 +integration-testing/scripts/run-all.sh # the default set +integration-testing/scripts/run-all.sh --only x-java-java-http,load-50 # names +integration-testing/scripts/run-all.sh --only 'x-*-python-*,x-python-*' # globs +integration-testing/scripts/run-all.sh --tag python # name tokens +integration-testing/scripts/run-all.sh --tag stdio --exclude 'x-java-*' --list ``` +- `--only` takes comma-separated names or globs; `--tag` takes comma-separated tags, where a + scenario's tags are the dash-separated tokens of its name (`x-java-python-stdio` has `x`, `java`, + `python`, `stdio`). Both together select the union; `--exclude` (globs) removes from it. + `--list` prints the selection and exits. +- With no `--only`/`--tag`, every config runs except `self-*`, `smoke-*` and `*-unstable`. Those + run only when a pattern or tag targets them (`--only 'self-*'`, `--only '*-unstable'`, + `--tag self`, `--tag unstable`) or a name selects one exactly. +- An empty selection fails unless `--allow-empty` is given; a name with no config is an error. +- **Parallel by default.** `run-all.sh` installs the SDK once, then prepares every peer checkout + and program build the selection needs, once and serially (`RunScenario --prepare`). It then runs + the scenarios `--jobs` at a time (default: half the CPUs, at most 8; `--jobs 1` for one at a + time), each with `--prepared`: no git, no build, its own `logs//`, its own temp + directory and its own port range. `load-*` scenarios run afterwards, alone and serially, since + they measure throughput. With more than one job each scenario's console goes to + `logs/run-all/.console.log` and a line is printed as each finishes; the table at the end + is the same. `--prepare-only` stops after the preparation (CI warms its caches with it). + Against a tag or other ref of the peer SDKs (the default, from `peers.json`, is `main`): ```bash @@ -52,48 +89,366 @@ Against a tag or other ref of the peer SDKs (the default, from `peers.json`, is integration-testing/scripts/run-all.sh --peers-ref v1.0.0 # one peer at a tag, the others at main integration-testing/scripts/run-all.sh --peer typescript-sdk=v0.5.0 -jbang RunScenario.java interop-python-server --peer python-sdk=1.0.0rc2 +jbang RunScenario.java x-java-python-http --peer python-sdk=1.0.0rc2 ``` A ref can be a branch, a tag or a commit SHA. Each ref gets its own checkout, `.cache/peers/@`, so switching refs does not throw away a build. -Logs for each run go to `logs//`: one file per process (`server.log`, `client.log`, -`clients.log`), per build (`build-*.log`, `peer-*.log`) and `result.txt`. `run-all.sh` also writes -`logs/run-all/summary.txt`. CI uploads the whole `logs/` directory as an artifact. +Logs for each run go to `logs//`: one file per process (`agent.log`, `server.log`, +`client.log`, `clients.log`), per build (`build-*.log`, `peer-*.log`) and `result.txt`. +`run-all.sh` also writes `logs/run-all/summary.txt`. CI uploads the whole `logs/` directory as an +artifact. ## Scenarios | Scenario | What runs | |---|---| -| `interop-java-java` | Java client -> Java server; HTTP/2 (h2c) on every request | -| `interop-ts-server` | Java client -> TypeScript server, wired as the SDK's example ships it (HTTP handler plus WebSocket upgrade listener); HTTP/1.1 | -| `interop-rust-server` | Java client -> Rust server (axum); HTTP/1.1 | -| `interop-python-server` | Java client -> Python server (Hypercorn, h2c); HTTP/2; one **expected failure**, below | -| `interop-ts-client` | TypeScript client -> Java server; HTTP/1.1 | -| `interop-rust-client` | Rust client -> Java server; HTTP/1.1 | -| `interop-python-client` | Python client -> Java server; HTTP/1.1 | +| `x---` | generated: the client program of one language against the agent program of another over one transport, running every stable catalogue step (`steps.json`). Pairs are Java<->Java and Java<->X for X in TypeScript, Rust, Python and Kotlin; transports are stdio, Streamable HTTP and WebSocket (Kotlin: stdio and WebSocket) | +| `x----unstable` | the same pair running the `unstable` steps (`session.fork`); on demand only | +| `conf-java-agent-`, `conf-java-client-`, `conf-java-client-catalogue-` | the raw driver probing the Java agent, the Java client's raw mode, and the Java client running catalogue steps against the raw agent ([programs/raw/gen_conf.py](programs/raw/gen_conf.py)) | +| `quarkus-typescript-http`, `quarkus-typescript-ws` | hand-written: the TypeScript client program against the Quarkus-hosted smoke agent (`programs/quarkus`: the shared `programs/framework` handlers as an `@AcpAgent` bean served by `acp-quarkus` on the Quarkus HTTP server), running the catalogue steps that agent implements (initialize, sessions, echo chunks, stop reasons, -32601, 1 MB and 8 MB prompts and updates). Run in the `typescript-http`/`typescript-ws` legs of `cross-sdk.yml` | | `load-50`, `load-300`, `load-1000` | N Java clients on their own connections, each: initialize, session/new, then 10 (or 5) prompts streaming two updates each | | `load-shared-300` | 300 clients sharing one HttpClient, so one HTTP/2 connection | +| `fw--agent-`, `fw--client-`, `load-10-fw-` | generated from `smoke.json`: the [framework smoke matrix](#framework-smoke-matrix) | -The interop steps are the same in every cell: `initialize`, `session/new`, two prompts that each -stream two updates, a prompt that triggers a permission round trip, `session/load` on the same -connection, a prompt after the load, and the DELETE on close (checked in the server's request log). -The Java client then reconnects on a new connection, loads the same session and prompts again. - -**Expected failure.** Java client -> Python server: reconnect-then-load gets a 404. The RFD -contradicts itself: its reconnect diagram opens the session stream before `session/load`, and it -also says an `Acp-Session-Id` the connection does not know is a 404. The Python server follows the -second rule; the TypeScript, Rust and Java servers accept the pre-open. The scenario declares the -two affected steps under `expectedFailures`. The suite stays green and shows them as XFAIL. If the -Python server starts accepting the pre-open, the steps pass, the scenario **fails**, and the -expectation has to be updated. +Over Streamable HTTP the cells also assert, through `matrix.json` facts, what the retired +hand-written `interop-*` scenarios checked: the negotiated HTTP version on both sides (h2c between +the Java client and the Java and Python agents, HTTP/1.1 everywhere else), and the `DELETE` on +close answered 202. `http.reconnect`, in every HTTP cell, loads and prompts a session on a new +connection. **Load thresholds** (first baselines, from the 2026-09-25 pre-release run): `errors == 0`, every prompt answered (`ok == expected`), every update delivered (`updates == 2*ok`), at most 4 SDK threads (`acp-*`) on the server at peak and after close, server heap after a full GC at most 256 MB at peak and 64 MB after close. p50/p99 latency is recorded in the results and never -asserted. +asserted. The load scenarios measure the server, so the load generator's client readers use +platform threads (`virtualThreads(false)`) on every JDK; the SDK client's default stays virtual +threads on JDK 21 and later. + +## Framework smoke matrix + +The release gate for the framework integrations: the SDK as an application uses it, hosted by +Spring Boot (`acp-spring-boot-starter`), Micronaut (`acp-micronaut`) and Quarkus (`acp-quarkus`), +against the TypeScript SDK. **A release candidate must have a green framework smoke matrix** on +the commit being released (`framework-smoke.yml`, or the local command below), next to the usual +`./mvnw clean verify`; the full cross-SDK matrix (`cross-sdk.yml`) should be green on it too. + +`smoke.json` defines it once; `GenConfigs.java` writes its configs (and `--check` keeps them in step): + +- **`agentSteps`**, run by the TypeScript client against each framework-hosted agent on each of + that framework's transports (`fw--agent-`): `init.initialize` (with the agentInfo + the `@AcpAgent` annotation declares), `init.agent-capabilities` (capabilities *derived* from + the handler annotations: the agent has no `@Initialize` method), `init.agent-info`, + `session.new`, `session.load`, `session.multi`, `update.agent_message_chunk`, `perm.selected`, + `elicit.form`, `cancel.prompt`, `ext.client-request` (an `@ExtRequest` method), + `error.method-not-found`, `http.reconnect` (HTTP only), `stdio.eof-exit` (stdio only) and + `conn.close`: 14 steps per cell, 13 over WebSocket. The agent-side assertions + (`STEP agent.perm.selected`, `agent.elicit.form`, `agent.cancel.prompt`) are required, and the + agent must log nothing at WARN or ERROR. +- **`clientSteps`**, run by the client bean each framework builds from its own configuration + (transport URI and advertised capabilities; the handlers come from an `AcpClientCustomizer` + bean) against the TypeScript agent (`fw--client-`): `init.initialize`, + `init.agent-info`, `session.new`, `update.agent_message_chunk`, `perm.selected`, `fs.read`, + `elicit.form`, `cancel.prompt`, `ext.client-request`, `conn.close`. +- **`load`**: 10 Java load clients x 10 prompts against each framework-hosted agent over HTTP + (`load-10-fw-`): no errors, every prompt and update delivered, clean close, no + `ERROR` on the server. Latency is recorded, not asserted. + +| Framework | Agent transports | Client | Program | +|---|---|---|---| +| Spring Boot 4.1 | stdio (`spring.main.keep-alive=true`), HTTP and WebSocket (the SDK servlet, `acp-http-servlet`, on `server.port` in a servlet web application) | HTTP | [programs/spring](programs/spring) | +| Spring Boot 4.1, WebFlux (`spring-webflux`) | HTTP and WebSocket (the SDK's WebFlux host, `acp-http-webflux`, routed on `server.port` by Reactor Netty in a reactive web application) | none | [programs/spring](programs/spring), `--web reactive` | +| Micronaut 4 | stdio, HTTP, WebSocket (the SDK listener) | HTTP | [programs/micronaut](programs/micronaut) | +| Quarkus | stdio, HTTP, WebSocket (the Quarkus HTTP server); one package per build-time transport | HTTP | [programs/quarkus](programs/quarkus) | + +The agent handlers ([programs/framework/.../SmokeAgent.java](programs/framework/src/main/java/interop/framework/SmokeAgent.java)) +and the client steps ([SmokeClient.java](programs/framework/src/main/java/interop/framework/SmokeClient.java)) +are shared sources that every framework program compiles in; each framework adds only an +`@AcpAgent` subclass with its own bean annotation and a main class. A framework entry in +`smoke.json` may reuse another's program with `agentArgs` (flags its `agent.sh` gets before +`--transport`, as `spring-webflux` runs `programs/spring` with `--web reactive`) and `agentName` +(the `@AcpAgent` name the `init.initialize` step expects, `interop--agent`). To run the whole matrix, +locally or against another branch: + +```bash +git checkout # the candidate under test +integration-testing/scripts/run-all.sh --tag fw # every fw-* and load-10-fw-* scenario +integration-testing/scripts/run-all.sh --only 'fw-spring-*,load-10-fw-spring' # one framework +gh workflow run framework-smoke.yml --ref # the same in CI, on that branch +``` + +It needs JDK 17+, Node 20 and JBang (the TypeScript SDK is cloned and built on the first run); on a +warm machine it takes about 3 minutes. + +## Contracts + +What a language package (an agent and a client program for one SDK) must implement to take part +in the generated cells. Nothing here needs the runner's source. The step catalogue, `steps.json`, +is the normative list of steps; this section defines everything around it. + +### 1. Files a language package owns + +``` +programs//launch/build.sh build the programs (may do nothing) +programs//launch/agent.sh exec the agent +programs//launch/client.sh exec the client +programs//... sources, project files +expectations/.json the package's expected failures (expectations/README.md) +``` + +`` directories are those of `matrix.json`: `java`, `node` (typescript), `rust`, `python`, +`kotlin`. Do not name a directory `bin/`: the repository `.gitignore` ignores it. + +The three scripts are bash, executable, and work from any working directory (resolve paths from +`$(dirname "${BASH_SOURCE[0]}")`). `agent.sh` and `client.sh` end in `exec "$@"`, so the +process the runner (or a stdio client) starts is the program itself and a kill reaches it; they +print nothing themselves. They read the environment below and nothing else. + +| Variable | Set for | Value | +|---|---|---| +| `MVNW`, `ACP_VERSION`, `CACHE`, `IT_ROOT` | `build.sh` | the Maven wrapper (pinned to `IT_M2_REPO`), the SDK version under test, `integration-testing/.cache`, `integration-testing/` | +| the language's `env` in `matrix.json` | `build.sh`, and every process of a cell that uses the language | e.g. `TS_SDK`, `PY_SDK`, `RUST_SDK` + `CARGO_TARGET_DIR`, `KOTLIN_SDK`: the peer checkout (and build dir) for the ref under test | +| `STEPS` | client | comma-separated step ids, in order | +| `AGENT_CMD` | client, stdio only | a shell command line that starts the agent | +| `STEP_TIMEOUT_MS` | client, optional | per-step timeout, default 15000 | + +In a stdio cell both languages' variables are on the client process, and the client's environment +is inherited by the agent it spawns. The build runs once per cell before any process starts +(`build.sh` of both languages in a stdio cell); it must be idempotent and fast when nothing +changed. + +### 2. Command lines and the transport flag + +``` +agent.sh --transport stdio +agent.sh --transport http --port +agent.sh --transport ws --port +client.sh --transport stdio (AGENT_CMD in the environment) +client.sh --transport http --url http://127.0.0.1:/acp +client.sh --transport ws --url ws://127.0.0.1:/acp +``` + +- **Agent, `http`/`ws`.** Listen on 127.0.0.1:`` (0 means any), serve ACP at `/acp`, and print + `READY ` on stdout once accepting connections. `ws` is a `GET /acp` with + `Upgrade: websocket` on that endpoint; an SDK whose one server handles both (Java, TypeScript, + Rust, Python) may serve both transports for either flag. Run until killed (SIGTERM, then + SIGKILL after 5 s). Optionally log one `[http] -> ...` line + per request, which `matrix.json` facts can assert on. +- **Agent, `stdio`.** Newline-delimited JSON-RPC on stdin/stdout. Stdout carries nothing else: + every log line, diagnostic and `STEP agent.*` line goes to **stderr** (logging frameworks + included). Exit when stdin reaches EOF (any exit code), or on SIGTERM. +- **Client, `stdio`.** Spawn the agent as `bash -c "exec $AGENT_CMD"` with the client's environment, + using the SDK's own process transport where it has one (Java `StdioAcpClientTransport`, Rust + `AcpAgent`, Python `spawn_agent_process`; TypeScript `child_process.spawn` + `ndJsonStream`; + Kotlin the sample's `createProcessStdioTransport`). **Relay the child's stderr** to the client's + stdout, line by line: a line that starts with `STEP ` verbatim, every other line prefixed + `agent| `. The runner's `STEP` matcher is anchored at the start of the line, so this is how the + agent's assertions reach it in a stdio cell. End the child when the run ends. +- An unknown flag or a missing required value: print usage to stderr and exit 2. + +### 3. What the programs print + +**Client**, on stdout, one line per step in `STEPS` order, then one `RESULT` line, then exit 0 +(whatever the step outcomes; a non-zero exit means the program itself broke): + +``` +STEP PASS ( ms) -> +STEP FAIL ( ms) -> +RESULT pass= fail= updates_total= upd_= ... +``` + +- `` is one line. A step that timed out has `TIMEOUT` in its detail; the runner reports + their count as a hang count, a distinct kind of finding. +- A step id the program does not implement prints `STEP FAIL (0 ms) -> unknown-step`, so a + program that lags the catalogue shows up rather than passing silently. +- `pass` and `fail` count the client's own steps only. `updates_total` counts every + `session/update` received on any connection, and `upd_` the ones of each `sessionUpdate` + value (`upd_agent_message_chunk=8`); unknown kinds count as `upd_other`. +- Other output (updates, requests, logs) is free-form, but must not start with `STEP ` or + `RESULT `. + +**Agent**: its own assertions, on stderr, never `RESULT`: + +``` +STEP agent. PASS ( ms) -> +STEP agent. FAIL ( ms) -> +``` + +An agent prints `STEP agent.` exactly when it handles that step's directive, for the steps +whose `pass.agent` is set in `steps.json`. The generated config requires the PASS line (on the +`agent` process for HTTP/WS, on the relaying `client` for stdio), and any undeclared FAIL fails +the scenario. + +### 4. The step catalogue, `steps.json` + +- `conventions`: the step timeout (15 s), the grace for updates that trail a response (1 s) and for + load replay (2 s), placeholders, and the mandatory `first` (`init.initialize`) and `last` + (`conn.close`) steps, which every cell runs. +- `fixtures`: the exact values both sides use: the client and agent `initialize` profiles, + modes, config options, permission options and the client's selection rule, `#emit` payloads, + file contents, extension method names, `_meta`, elicitation schemas. Programs hard-code these. +- `directives`: what the agent does for each prompt text. The agent keeps no script: the client + drives it through the prompt. If the first text block of a prompt starts with `#`, it is + `# ` (single spaces; for `#fs write` and `#len` the last argument is the rest of the + line); otherwise it is plain text, answered with the chunks `"echo: "` and the text. An unknown + directive is answered with a JSON-RPC error `-32602`, message `unknown directive: #`. +- `steps`, in run order. Each has `id`, `group`, `client` (what the client does), `pass.client` + (when its `STEP ` passes) and optionally `prompt` (the prompt text it sends), `agent` and + `pass.agent` (the agent behaviour and its `STEP agent.` rule), `requires` (capabilities the + step depends on: `client.` in the client's `initialize`, `agent.` in the agent's), + `transports` (default all three), `only` (`{"client": [...], "agent": [...]}`: languages the + step applies to, e.g. Java SDK policy), `profile` (`stable` default, or `unstable`), `phaseB` + (the open Java Phase B item that blocks it, exactly the steps with `see: "P"` entries in + `expectations/java.json`; the item's commit removes both; informative) and `seed` (implemented by the WP0 Java seed). + +Rules for every step: + +- **Self-contained.** A step opens its own session (or connection) when it needs state; one + failure must not cascade. Only `init.initialize` opens the main connection, and only + `conn.close` closes it. +- `{dir}` in a prompt is the client's scratch directory: absolute, created once per run, no spaces. + Agent and client always run on the same host. +- A program advertises in `initialize` only what it really implements, taking the values from + `fixtures.client` / `fixtures.agent`. An agent obeys the client's capabilities: when a directive + needs one the client did not advertise, it prints `STEP agent. FAIL` and answers without + calling the client. +- Updates may trail the prompt response (nothing orders their dispatch on HTTP): wait up to + `updateGraceMs` before failing on a missing update, `replayGraceMs` for a load replay, and accept + the replay on either side of the response. + +Step ids are stable. A new behaviour is a new id; a changed `pass` rule is a contract change that +every package must follow, so it goes through review like an API change. + +### 5. Cells, `matrix.json` and `GenConfigs.java` + +A cell is one generated config, `configs/x---[-unstable].json`: + +- **pairs** are J<->J, J->X and X->J for X in TypeScript, Rust, Python and Kotlin; +- **transports** are those both languages support (Kotlin has no Streamable HTTP: stdio and ws); +- the **profile** is `stable`, or `unstable` (the `unstable` steps only, plus the mandatory first + and last; suffix `-unstable`, run on demand). + +`matrix.json` per language: `enabled` (cells are generated only when both languages are enabled; +all five are), `dir`, `peers` (from `peers.json`), `env`, `transports`, `readyTimeoutSec`. +`stepSelection` is `all` (`seed`, only the `"seed": true` steps, or a comma-separated id list +also work). `facts` add transport assertions to the cells their `when` matches, with roles +`client` and `agent` mapped to processes: the HTTP version each HTTP pair negotiates (h2c between +the Java client and the Java and Python agents, HTTP/1.1 otherwise, on both sides), DELETE -> 202 +on every agent, the WebSocket 101 on the Java agent. + +Each cell is an ordinary runner config: on HTTP/WS an `agent` process (role `server`, `ready` on +`READY`) and a `client` process; on stdio a single `client` process with `AGENT_CMD`. The checks are +`client RESULT pass == ` and `client RESULT fail == `, plus the required agent `STEP` lines, the facts and the expected failures from +`expectations/*.json` ([expectations/README.md](expectations/README.md)). `RunScenario`, `Checks`, +`Scenario` and `Proc` are unchanged. + +```bash +cd integration-testing +jbang GenConfigs.java # write the cells of the enabled languages (commit them) +jbang GenConfigs.java --check # CI: exit 1 if a committed cell is stale, missing or extra +jbang GenConfigs.java --list # print cells and step counts, write nothing +# local development of a language package: its cells and self-pairs, every catalogue step +jbang GenConfigs.java --enable python --self --steps all +scripts/run-all.sh --only 'self-python-*' +scripts/run-all.sh --only 'x-*-python-*,x-python-*' +``` + +`--self` writes `self--` cells (the language against itself: develop a package +before the Java side is ready, and separate a peer SDK bug from a Java one); they are git-ignored +and never part of the nightly. **Language packages never commit generated configs**: only the +integration step regenerates and commits them, after enabling the languages. `--check` reports +locally generated extra cells as "extra", which is the reminder. + +### 6. The Java programs and the expectations + +The Java programs, `programs/java` (`interop.Agent`, `interop.Client`), are every package's +partner: every cell has a Java client or a Java agent. They implement the whole catalogue on all +three transports and advertise the full `fixtures.client` / `fixtures.agent` profiles. The `seed` +flag marks the eight steps the first Java seed implemented; `matrix.json` now selects every step +(`"stepSelection": "all"`), and `--steps seed` still generates the seed subset. + +A step that fails in a cell is either fixed or declared in exactly one expectations file +([expectations/README.md](expectations/README.md)), chosen by whose code has to change: + +- **A Java gap** goes in `expectations/java.json`, with `when.client: java` or `when.agent: java` + and `see: "P"`, the open Phase B item that closes it. The step's `phaseB` in `steps.json` + names the same item. Today there is none: every Phase B item the catalogue + depends on has landed, and `java.json` is empty. +- **A peer gap** goes in the peer's own file (`typescript.json`, `rust.json`, `python.json`, + `kotlin.json`), with `see: "peer: "` in that SDK, or `see: "spec:"` when + the RFD or schema contradicts itself and the peer and Java read it differently. +- A spec disagreement that only a Java pair shows goes in `java.json` with `see: "spec:..."` + (none today). + +The Kotlin package has stdio and WebSocket only: the Kotlin SDK has no Streamable HTTP transport, +so `matrix.json` lists `"transports": ["stdio", "ws"]` for it and no `x-*-kotlin-http` cell exists. +Kotlin's Gradle build needs JDK 21 while everything else stays on 17. + +`expectations/raw.json` belongs to the raw driver, which is no matrix language: its +`expectations` array stays empty (`GenConfigs.java` reads it like every other file), and its +`conformance` array holds the expected failures of the `conf-java-*` scenarios, which only +`programs/raw/gen_conf.py` reads (its header documents the fields: `case`, `when.side`, +`when.transport`, `contains`, `see`, `reason`). `gen_conf.py --check` runs next to +`GenConfigs.java --check` in CI. + +### 7. Isolation: every scenario must be safe to run next to any other + +`run-all.sh` runs scenarios concurrently, so a program must assume that other cells, of its own +language too, run on the same host at the same time. + +- **Ports.** Never a fixed port. An agent binds exactly the `--port` it is given: the runner takes + `${PORT}` from a range reserved for the scenario (`IT_PORT_RANGE`, 100 ports per scenario from + the run's port block, below the Linux ephemeral range). `--port 0` must also work (print the + bound port in `READY`). A client connects only to the `--url` it is given. +- **Concurrent runs: ports.** Several checkouts may run `run-all.sh` on one host at once. Each + run-all takes an exclusive 10,000-port block for its lifetime: the first of 20000, 30000, 40000 + and 50000 whose lock file (`${XDG_RUNTIME_DIR:-/tmp}/acp-it-ports-.lock`) it can `flock`, + held by a file descriptor until the run and everything it started exit (a kill releases it + too); with all four taken it waits. `IT_PORT_BASE=` overrides the choice (no lock). Scenario + `i` gets ` + 100 * (i mod 100)`, 100 ports wide. `RunScenario.java` run directly, without + `IT_PORT_RANGE`, uses an OS-chosen ephemeral port. +- **Concurrent runs: Maven repository.** One knob, `IT_M2_REPO`, is the local repository for + everything the suite resolves or installs: the SDK install, every program build (`${MVNW}` is + `scripts/mvnw.sh`, the checkout's `./mvnw` with `-Dmaven.repo.local=$IT_M2_REPO`) and JBang + (`JBANG_REPO` is set to it). Unset, it is `integration-testing/.cache/m2` (per checkout, + git-ignored), so concurrent checkouts never see each other's `0.19.0-SNAPSHOT`; with `CI=true` + it is `~/.m2/repository`, which the CI cache keeps. A cold per-checkout repository downloads + the SDK's third-party dependencies once (a few minutes); copying another checkout's + `.cache/m2` warms it, since the SNAPSHOT is reinstalled by every run. Never call `./mvnw` or + `mvn` directly from a build; use `${MVNW}`. +- **Files.** Never a fixed path. Temporary files go under `$TMPDIR`, which the runner sets for every + process to `logs//tmp/`, or under the client's `{dir}` (created with a unique name). + Nothing is written into `programs//` at run time. +- **Logs.** Only stdout and stderr; the runner files them under `logs//`. No log files + elsewhere. +- **Builds.** Only `build.sh` builds, fetches or installs, and it runs before any scenario starts + (serially, once per distinct build in a parallel run). `agent.sh` and `client.sh` never build; + in a `--prepared` run nothing else does either. +- **Processes.** No daemons and no detached children: everything a program starts dies with it + (a stdio client ends its agent; the runner kills each process tree at the end of a scenario). +- **No shared mutable state** in programs: no lock files, fixed sockets or named pipes, no global caches + written at run time. + +### 8. CI + +- `.github/workflows/interop-java.yml`, on every push to `main` and every pull request: JDK 17 + only, `GenConfigs.java --check` and `programs/raw/gen_conf.py --check`, then the `x-java-java-*` + cells on all three transports. +- `.github/workflows/cross-sdk.yml`, nightly and on manual dispatch (optionally for one peer): + a `prepare ` job per peer SDK fetches and builds the peer and its programs once + (`run-all.sh --prepare-only`) and caches them under the peer's resolved commit + (`scripts/peer-sha.sh`); then one job per peer and transport (`typescript-stdio`, + `typescript-http`, ..., `kotlin-ws`) restores that build, installs only its own toolchain, and + runs `run-all.sh --only 'x-java--*,x--java-*'`. A `java` job runs the Java + pair, conformance, load and both `--check`s. Each job uploads `logs/` as `cross-sdk-logs-`; `fail-fast` is off. +- `.github/workflows/framework-smoke.yml`, on every push to `main`, nightly and on manual dispatch + for any branch: `GenConfigs.java --check`, then `run-all.sh --tag fw` on JDK 17. This is the + release gate ([Framework smoke matrix](#framework-smoke-matrix)); `release.yml` does not check it + itself, so the maintainer confirms it is green on the release commit before dispatching a + release. +- Neither `interop-java.yml` nor `cross-sdk.yml` gates a release. ## How a scenario is described @@ -101,25 +456,27 @@ asserted. ```jsonc { - "name": "interop-ts-server", - "timeoutSec": 240, + "name": "x-java-typescript-http", + "timeoutSec": 565, "peers": ["typescript-sdk"], // prepared from peers.json before anything is built "processes": [ - { "name": "server", "role": "server", "language": "node", - "dir": "${ROOT}/programs/node", "build": null, "run": "node ts-server.mjs", - "env": { "TS_SDK": "${peer.typescript-sdk}", "PORT": "${PORT}" }, + { "name": "agent", "role": "server", "language": "typescript", + "dir": "${ROOT}/programs/node", "build": "... ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { "TS_SDK": "${peer.typescript-sdk}" }, "ready": { "line": "READY", "timeoutSec": 60 } }, { "name": "client", "role": "client", "language": "java", - "dir": "${ROOT}/programs/java", "build": "${MVNW} -q -B ... compile ...", - "run": "java -cp ... interop.JavaClientMain http://127.0.0.1:${PORT}/acp", "timeoutSec": 120 } + "dir": "${ROOT}/programs/java", "build": "... ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { "STEPS": "init.initialize,...,conn.close" }, "timeoutSec": 445 } ], "assertions": { - "requiredOutput": { "client": ["STEP initialize PASS", "negotiated HTTP_1_1"] }, + "requiredOutput": { "agent": ["STEP agent.fs.write PASS"], "client": ["negotiated HTTP_1_1"] }, "forbiddenOutput": { "client": ["negotiated HTTP_2"] }, - "requiredPatterns": { "server": ["\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202"] }, - "checks": ["client RESULT fail == 0", "client RESULT prompt1_updates >= 2"], - "expectedFailures": [ { "process": "client", "step": "...", "contains": "404", "reason": "...", "see": "..." } ], - "report": { "clients": ["GOAWAY received"] } + "requiredPatterns": { "agent": ["\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202"] }, + "checks": ["client RESULT pass == 65", "client RESULT fail == 0"], + "expectedFailures": [], // from expectations/*.json, e.g. { "process": "client", "step": "...", "contains": "-32601", "reason": "...", "see": "peer:..." } + "report": { "client": ["TIMEOUT"] } } } ``` @@ -128,10 +485,11 @@ asserted. order, and each must print its `ready` line in time. Clients then run in order under their `timeoutSec` and must exit with `exitCode` (default 0). Every process tree is killed in a `finally`, including on Ctrl-C. -- **Variables.** `${PORT}` (a free port, one per scenario), `${ROOT}` (this directory), `${REPO}`, - `${MVNW}`, `${ACP_VERSION}` (from the root pom), `${CACHE}`, `${LOG_DIR}`, and for each peer - `${peer.}` (checkout path), `${peerRef.}` and `${peerKey.}`. An unknown - variable is an error. +- **Variables.** `${PORT}` (a free port, one per scenario; from `IT_PORT_RANGE` when set), + `${ROOT}` (this directory), `${REPO}`, `${MVNW}`, `${ACP_VERSION}` (from the root pom), + `${CACHE}`, `${LOG_DIR}`, `${TMP}` (`logs//tmp`, also every process's `TMPDIR`), and + for each peer `${peer.}` (checkout path), `${peerRef.}` and `${peerKey.}`. + An unknown variable is an error. - **Samples.** A server can list `samples`, which are jcmd samples taken `on` `ready`, on a `line` another process prints, or on another process's `exit` (after `delayMs`). Each sample is a full GC, then heap used, total threads and SDK threads. It is recorded as @@ -146,18 +504,21 @@ asserted. without asserting on it. It is for known, timing-dependent behaviour: in `load-shared-300`, Jetty's HTTP/2 rate control can send GOAWAY when all 300 clients close at once. -The programs live in `programs//`. Each prints `READY ` when it is listening (the -servers), `STEP PASS|FAIL` per step and a final `RESULT key=value ...` line (the clients), -and one `[http]` line per request with the negotiated HTTP version (the servers). ## Layout ``` -RunScenario.java JBang entry point +RunScenario.java JBang entry point: runs one configs/.json +GenConfigs.java JBang: generates configs/x-*.json from the three inputs below, and fw-* from smoke.json +steps.json the step catalogue (Contracts) +matrix.json languages, pairs, transports, profiles, transport facts +smoke.json the framework smoke matrix: step subsets, frameworks and transports, load +expectations/ .json expected failures, one file per language package jbang-lib/ config model, peer checkouts, processes, jcmd sampler, assertions peers.json peer SDK git URLs, default refs and build commands -configs/ one JSON file per scenario -programs/java|node|rust|python the interop and load programs -scripts/run-all.sh every scenario plus a summary table +configs/ one JSON file per scenario (x-*, fw-*, load-10-fw-* generated, the rest hand-written) +programs// each language's programs; launch/{build,agent,client}.sh for the cells +programs// spring, micronaut, quarkus: the smoke programs, built on programs/framework +scripts/run-all.sh a selection of scenarios plus a summary table .cache/, logs/ generated, git-ignored ``` diff --git a/integration-testing/RunScenario.java b/integration-testing/RunScenario.java index 0ce2e9f3..84557690 100644 --- a/integration-testing/RunScenario.java +++ b/integration-testing/RunScenario.java @@ -34,10 +34,12 @@ * *
      *   cd integration-testing
    - *   jbang RunScenario.java interop-ts-server
    - *   jbang RunScenario.java interop-ts-server --peer typescript-sdk=v0.5.0
    + *   jbang RunScenario.java x-java-typescript-http
    + *   jbang RunScenario.java x-java-typescript-http --peer typescript-sdk=v0.5.0
      *   jbang RunScenario.java load-300 --skip-sdk-install
      *   jbang RunScenario.java --list
    + *   jbang RunScenario.java --prepare x-java-java-http x-java-rust-ws   (peers and builds only)
    + *   jbang RunScenario.java x-java-rust-ws --prepared                  (no install, fetch or build)
      * 
    * * Flow: install the SDK from this checkout (unless --skip-sdk-install), prepare the peer SDKs @@ -46,12 +48,25 @@ * tear down every process tree (always, in a finally) and evaluate the assertions. Logs go to * logs/<scenario>/. Exit 0 = pass (declared expected failures included), 1 = fail, * 2 = the scenario could not be set up. + * + *

    Parallel runs (run-all.sh --jobs) split this in two: {@code --prepare} clones, fetches and + * builds every peer and process of a list of scenarios once, serially; each scenario then runs + * with {@code --prepared}, which touches no shared state (no install, no git, no build). Each + * scenario has its own log directory, its own {@code ${TMP}} (also {@code TMPDIR} for every + * process), and takes {@code ${PORT}} from {@code IT_PORT_RANGE} (e.g. 20000-20099) when set, so + * concurrent scenarios given disjoint ranges never race for a port (unset: an OS-chosen ephemeral + * port). Every Maven call goes through scripts/mvnw.sh, so the SDK SNAPSHOT is installed into and + * resolved from {@code IT_M2_REPO} only (default: integration-testing/.cache/m2, or + * ~/.m2/repository when CI=true). */ public class RunScenario { public static void main(String... args) throws Exception { String scenario = null; boolean skipInstall = false; + boolean prepared = false; + boolean prepare = false; + List scenarios = new ArrayList<>(); String peersRef = null; Map peerRefs = new HashMap<>(); for (int i = 0; i < args.length; i++) { @@ -66,6 +81,8 @@ public static void main(String... args) throws Exception { return; } case "--skip-sdk-install" -> skipInstall = true; + case "--prepared" -> prepared = true; + case "--prepare" -> prepare = true; case "--peers-ref" -> peersRef = args[++i]; case "--peer" -> { String[] kv = args[++i].split("=", 2); @@ -87,6 +104,7 @@ else if (a.startsWith("-")) { } else { scenario = a; + scenarios.add(a); } } } @@ -95,7 +113,13 @@ else if (a.startsWith("-")) { usage(); System.exit(2); } - System.exit(new RunScenario(root(), scenario).run(skipInstall, peersRef, peerRefs)); + if (prepare) { + System.exit(prepareAll(root(), scenarios, peersRef, peerRefs)); + } + if (scenarios.size() > 1) { + throw new IllegalArgumentException("One scenario at a time (several only with --prepare): " + scenarios); + } + System.exit(new RunScenario(root(), scenario).run(skipInstall || prepared, prepared, peersRef, peerRefs)); } static void usage() { @@ -105,7 +129,9 @@ static void usage() { Options: --peer = test peer SDK (see peers.json) at (branch, tag or SHA) --peers-ref test every peer SDK at (a --peer entry wins) - --skip-sdk-install do not run ./mvnw install first (run-all.sh installs once) + --skip-sdk-install do not install the SDK first (run-all.sh installs once) + --prepare ... only prepare the peers and run the builds of the scenarios (no install) + --prepared the scenario was prepared: no install, no git, no build (parallel runs) --list list scenarios """); } @@ -150,7 +176,7 @@ static Path root() { this.logDir = root.resolve("logs").resolve(scenario); } - int run(boolean skipInstall, String peersRef, Map peerRefs) throws Exception { + int run(boolean skipInstall, boolean prepared, String peersRef, Map peerRefs) throws Exception { long t0 = System.nanoTime(); Scenario.Config cfg = Scenario.load(root.resolve("configs").resolve(scenario + ".json")); resetLogDir(); @@ -161,14 +187,16 @@ int run(boolean skipInstall, String peersRef, Map peerRefs) thro String acpVersion = sdkVersion(); if (!skipInstall) { step("Installing the SDK from " + repo + " (" + acpVersion + ")"); - exec(logDir.resolve("sdk-install.log"), repo, 900, "./mvnw", "-q", "-B", "-DskipTests", "install"); + exec(logDir.resolve("sdk-install.log"), repo, 900, mvnw(root), "-q", "-B", "-DskipTests", "install"); } vars.put("ROOT", root.toString()); vars.put("REPO", repo.toString()); - vars.put("MVNW", repo.resolve("mvnw").toString()); + vars.put("MVNW", mvnw(root)); vars.put("ACP_VERSION", acpVersion); vars.put("CACHE", root.resolve(".cache").toString()); vars.put("LOG_DIR", logDir.toString()); + Path tmp = Files.createDirectories(logDir.resolve("tmp")); + vars.put("TMP", tmp.toString()); vars.put("PORT", Integer.toString(freePort())); if (!cfg.peers().isEmpty()) { step("Preparing peer SDKs"); @@ -179,7 +207,7 @@ int run(boolean skipInstall, String peersRef, Map peerRefs) thro throw new IllegalArgumentException("Peer '" + name + "' is not in peers.json"); } String ref = peerRefs.getOrDefault(name, peersRef != null ? peersRef : peer.ref()); - Peers.Checkout c = Peers.prepare(name, peer, ref, root.resolve(".cache"), logDir); + Peers.Checkout c = Peers.prepare(name, peer, ref, root.resolve(".cache"), logDir, prepared); vars.put("peer." + name, c.dir().toString()); vars.put("peerRef." + name, ref); vars.put("peerKey." + name, c.dir().getFileName().toString()); @@ -191,7 +219,9 @@ int run(boolean skipInstall, String peersRef, Map peerRefs) thro System.out.println(" note: --peer " + name + " ignored; this scenario does not use it"); } } - build(cfg, vars); + if (!prepared) { + build(cfg, vars, logDir, new LinkedHashSet<>()); + } } catch (Exception e) { outcome.failures.add("setup: " + e.getMessage()); @@ -216,17 +246,29 @@ int run(boolean skipInstall, String peersRef, Map peerRefs) thro return finish(cfg, t0, peerSummary, outcome.failures.isEmpty() ? 0 : 1); } - void build(Scenario.Config cfg, Map vars) throws Exception { - Set done = new LinkedHashSet<>(); + void build(Scenario.Config cfg, Map vars, Path buildLogs, Set done) throws Exception { for (Scenario.ProcessSpec p : cfg.processes()) { if (p.build() == null || p.build().isBlank()) { continue; } Path dir = Path.of(Scenario.subst(p.dir(), vars)); String cmd = Scenario.subst(p.build(), vars); - if (done.add(dir + "\0" + cmd)) { + String key = dir + "\0" + cmd; + // A build that failed fails every scenario that needs it, not only the first: the + // others would otherwise run whatever an earlier build left behind. + if (done.contains(key + "\0failed")) { + throw new IllegalStateException("build of " + p.name() + " (" + p.language() + ") failed earlier in this run"); + } + if (done.add(key)) { step("Building " + p.name() + " (" + p.language() + ")"); - exec(logDir.resolve("build-" + p.name() + ".log"), dir, 1200, "bash", "-c", cmd); + try { + exec(buildLogs.resolve("build-" + (buildLogs.equals(logDir) ? "" : scenario + "-") + p.name() + ".log"), + dir, 1200, "bash", "-c", cmd); + } + catch (Exception e) { + done.add(key + "\0failed"); + throw e; + } } } } @@ -332,6 +374,7 @@ void execute(Scenario.Config cfg, Map vars) throws Exception { void start(Proc proc, Scenario.ProcessSpec p, Map vars) throws Exception { Map env = new LinkedHashMap<>(); + env.put("TMPDIR", vars.get("TMP")); p.env().forEach((k, v) -> env.put(k, Scenario.subst(v, vars))); proc.start(Scenario.subst(p.run(), vars), Path.of(Scenario.subst(p.dir(), vars)), env); } @@ -457,13 +500,91 @@ String sdkVersion() throws Exception { return m.group(1).trim(); } + /** + * The Maven wrapper every build uses: scripts/mvnw.sh, the checkout's ./mvnw pinned to the + * suite's local repository ({@code -Dmaven.repo.local=$IT_M2_REPO}, see scripts/m2-repo.sh). + */ + static String mvnw(Path root) { + return root.resolve("scripts").resolve("mvnw.sh").toString(); + } + + /** + * A free port: the first one that binds in {@code IT_PORT_RANGE} ({@code -}, set by + * run-all.sh so concurrent scenarios use disjoint ranges), else one the OS picks. + */ static int freePort() throws Exception { + String range = System.getenv("IT_PORT_RANGE"); + if (range != null && !range.isBlank()) { + String[] r = range.trim().split("-"); + for (int port = Integer.parseInt(r[0]); port <= Integer.parseInt(r[1]); port++) { + try (ServerSocket s = new ServerSocket(port)) { + return port; + } + catch (java.io.IOException inUse) { + // try the next one + } + } + throw new IllegalStateException("No free port in IT_PORT_RANGE " + range); + } try (ServerSocket s = new ServerSocket(0)) { s.setReuseAddress(true); return s.getLocalPort(); } } + /** + * Prepare several scenarios for parallel runs: every peer checkout (fetched and built once per + * peer) and every process build (once per distinct directory and command), serially. Logs go + * to logs/run-all/prepare/. Returns 0, or 2 if anything failed. + */ + static int prepareAll(Path root, List scenarios, String peersRef, Map peerRefs) + throws Exception { + Path logs = Files.createDirectories(root.resolve("logs").resolve("run-all").resolve("prepare")); + Map known = Peers.load(root.resolve("peers.json")); + Map checkouts = new LinkedHashMap<>(); + Set builds = new LinkedHashSet<>(); + int code = 0; + for (String name : scenarios) { + RunScenario r = new RunScenario(root, name); + try { + Scenario.Config cfg = Scenario.load(root.resolve("configs").resolve(name + ".json")); + Map vars = new LinkedHashMap<>(); + vars.put("ROOT", root.toString()); + vars.put("REPO", r.repo.toString()); + vars.put("MVNW", mvnw(root)); + vars.put("ACP_VERSION", r.sdkVersion()); + vars.put("CACHE", root.resolve(".cache").toString()); + vars.put("LOG_DIR", r.logDir.toString()); + vars.put("TMP", r.logDir.resolve("tmp").toString()); + vars.put("PORT", "0"); + for (String peerName : cfg.peers()) { + Peers.Peer peer = known.get(peerName); + if (peer == null) { + throw new IllegalArgumentException("Peer '" + peerName + "' is not in peers.json"); + } + String ref = peerRefs.getOrDefault(peerName, peersRef != null ? peersRef : peer.ref()); + Peers.Checkout c = checkouts.get(peerName + "@" + ref); + if (c == null) { + step("Preparing peer " + peerName + " @ " + ref); + c = Peers.prepare(peerName, peer, ref, root.resolve(".cache"), logs, false); + checkouts.put(peerName + "@" + ref, c); + } + vars.put("peer." + peerName, c.dir().toString()); + vars.put("peerRef." + peerName, ref); + vars.put("peerKey." + peerName, c.dir().getFileName().toString()); + } + r.build(cfg, vars, logs, builds); + } + catch (Exception e) { + System.out.println(" FAIL preparing " + name + ": " + e.getMessage()); + code = 2; + } + } + System.out.println("Prepared " + scenarios.size() + " scenario(s): " + checkouts.size() + " peer checkout(s), " + + builds.size() + " build(s); logs: " + logs); + return code; + } + void exec(Path log, Path dir, int timeoutSec, String... cmd) throws Exception { int code; try (OutputStream out = Files.newOutputStream(log)) { diff --git a/integration-testing/configs/conf-java-agent-http.json b/integration-testing/configs/conf-java-agent-http.json new file mode 100644 index 00000000..646a9230 --- /dev/null +++ b/integration-testing/configs/conf-java-agent-http.json @@ -0,0 +1,52 @@ +{ + "name": "conf-java-agent-http", + "description": "raw client -> java agent over http: 37 raw JSON-RPC cases (generated)", + "timeoutSec": 425, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": {}, + "timeoutSec": 305 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 36", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "raw.http.unknown-session-stream", + "contains": "held open", + "reason": "RFD gap B: the RFD answers an unknown Acp-Session-Id with 404 (:268, :301) but its reconnect flow opens the session stream before session/load (:237-244); the Java server keeps it open as a provisional stream.", + "see": "spec:docs/rfds/streamable-http-websocket-transport.mdx:268 [expectations/raw.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-agent-stdio.json b/integration-testing/configs/conf-java-agent-stdio.json new file mode 100644 index 00000000..ea03ff5f --- /dev/null +++ b/integration-testing/configs/conf-java-agent-stdio.json @@ -0,0 +1,33 @@ +{ + "name": "conf-java-agent-stdio", + "description": "raw client -> java agent over stdio: 33 raw JSON-RPC cases (generated)", + "timeoutSec": 405, + "peers": [], + "processes": [ + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/client.sh --transport stdio", + "env": { + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio" + }, + "timeoutSec": 285 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 33", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-agent-ws.json b/integration-testing/configs/conf-java-agent-ws.json new file mode 100644 index 00000000..4a16b75f --- /dev/null +++ b/integration-testing/configs/conf-java-agent-ws.json @@ -0,0 +1,44 @@ +{ + "name": "conf-java-agent-ws", + "description": "raw client -> java agent over ws: 30 raw JSON-RPC cases (generated)", + "timeoutSec": 390, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": {}, + "timeoutSec": 270 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 30", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-catalogue-http.json b/integration-testing/configs/conf-java-client-catalogue-http.json new file mode 100644 index 00000000..2baf43b8 --- /dev/null +++ b/integration-testing/configs/conf-java-client-catalogue-http.json @@ -0,0 +1,79 @@ +{ + "name": "conf-java-client-catalogue-http", + "description": "java client -> raw agent over http: 19 catalogue steps, 29 raw cases on the client (generated)", + "timeoutSec": 335, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/agent.sh --transport http --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 30 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.new,session.load,update.agent_message_chunk,update.unknown,enum.tool_call,enum.plan,enum.stop,enum.audience,config.grouped,perm.selected,fs.write,error.method-not-found,mode.set,auth.authenticate,auth.logout,session.delete,http.reconnect,conn.close" + }, + "timeoutSec": 215 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.raw.parse-error PASS", + "STEP agent.raw.invalid-request.empty-object PASS", + "STEP agent.raw.invalid-request.not-object PASS", + "STEP agent.raw.invalid-request.null PASS", + "STEP agent.raw.invalid-request.empty-batch PASS", + "STEP agent.raw.invalid-request.method-type PASS", + "STEP agent.raw.invalid-request.id-type PASS", + "STEP agent.raw.invalid-request.version PASS", + "STEP agent.raw.method-not-found PASS", + "STEP agent.raw.method-not-found.ext PASS", + "STEP agent.raw.notification.unknown PASS", + "STEP agent.raw.notification.unknown-ext PASS", + "STEP agent.raw.notification.bad-params PASS", + "STEP agent.raw.update.unknown-session PASS", + "STEP agent.raw.id.null PASS", + "STEP agent.raw.id.string PASS", + "STEP agent.raw.id.zero PASS", + "STEP agent.raw.id.large PASS", + "STEP agent.raw.invalid-params PASS", + "STEP agent.raw.stray-response.unknown-id PASS", + "STEP agent.raw.stray-response.null-id PASS", + "STEP agent.raw.stray-response.null-id-error PASS", + "STEP agent.raw.update.unknown PASS", + "STEP agent.enum.audience PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.fs.write PASS", + "STEP agent.mode.set PASS", + "STEP agent.auth.authenticate PASS" + ] + }, + "checks": [ + "client RESULT pass == 19", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-catalogue-stdio.json b/integration-testing/configs/conf-java-client-catalogue-stdio.json new file mode 100644 index 00000000..8260a417 --- /dev/null +++ b/integration-testing/configs/conf-java-client-catalogue-stdio.json @@ -0,0 +1,67 @@ +{ + "name": "conf-java-client-catalogue-stdio", + "description": "java client -> raw agent over stdio: 19 catalogue steps, 29 raw cases on the client (generated)", + "timeoutSec": 335, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && ${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.new,session.load,update.agent_message_chunk,update.unknown,enum.tool_call,enum.plan,enum.stop,enum.audience,config.grouped,perm.selected,fs.write,error.method-not-found,mode.set,auth.authenticate,auth.logout,session.delete,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/raw/launch/agent.sh --transport stdio" + }, + "timeoutSec": 215 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.raw.parse-error PASS", + "STEP agent.raw.invalid-request.empty-object PASS", + "STEP agent.raw.invalid-request.not-object PASS", + "STEP agent.raw.invalid-request.null PASS", + "STEP agent.raw.invalid-request.empty-batch PASS", + "STEP agent.raw.invalid-request.method-type PASS", + "STEP agent.raw.invalid-request.id-type PASS", + "STEP agent.raw.invalid-request.version PASS", + "STEP agent.raw.method-not-found PASS", + "STEP agent.raw.method-not-found.ext PASS", + "STEP agent.raw.notification.unknown PASS", + "STEP agent.raw.notification.unknown-ext PASS", + "STEP agent.raw.notification.bad-params PASS", + "STEP agent.raw.update.unknown-session PASS", + "STEP agent.raw.id.null PASS", + "STEP agent.raw.id.string PASS", + "STEP agent.raw.id.zero PASS", + "STEP agent.raw.id.large PASS", + "STEP agent.raw.invalid-params PASS", + "STEP agent.raw.stray-response.unknown-id PASS", + "STEP agent.raw.stray-response.null-id PASS", + "STEP agent.raw.stray-response.null-id-error PASS", + "STEP agent.raw.update.unknown PASS", + "STEP agent.enum.audience PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.fs.write PASS", + "STEP agent.mode.set PASS", + "STEP agent.auth.authenticate PASS" + ] + }, + "checks": [ + "client RESULT pass == 19", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-catalogue-ws.json b/integration-testing/configs/conf-java-client-catalogue-ws.json new file mode 100644 index 00000000..7022c986 --- /dev/null +++ b/integration-testing/configs/conf-java-client-catalogue-ws.json @@ -0,0 +1,79 @@ +{ + "name": "conf-java-client-catalogue-ws", + "description": "java client -> raw agent over ws: 18 catalogue steps, 29 raw cases on the client (generated)", + "timeoutSec": 330, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/agent.sh --transport ws --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 30 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.new,session.load,update.agent_message_chunk,update.unknown,enum.tool_call,enum.plan,enum.stop,enum.audience,config.grouped,perm.selected,fs.write,error.method-not-found,mode.set,auth.authenticate,auth.logout,session.delete,conn.close" + }, + "timeoutSec": 210 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.raw.parse-error PASS", + "STEP agent.raw.invalid-request.empty-object PASS", + "STEP agent.raw.invalid-request.not-object PASS", + "STEP agent.raw.invalid-request.null PASS", + "STEP agent.raw.invalid-request.empty-batch PASS", + "STEP agent.raw.invalid-request.method-type PASS", + "STEP agent.raw.invalid-request.id-type PASS", + "STEP agent.raw.invalid-request.version PASS", + "STEP agent.raw.method-not-found PASS", + "STEP agent.raw.method-not-found.ext PASS", + "STEP agent.raw.notification.unknown PASS", + "STEP agent.raw.notification.unknown-ext PASS", + "STEP agent.raw.notification.bad-params PASS", + "STEP agent.raw.update.unknown-session PASS", + "STEP agent.raw.id.null PASS", + "STEP agent.raw.id.string PASS", + "STEP agent.raw.id.zero PASS", + "STEP agent.raw.id.large PASS", + "STEP agent.raw.invalid-params PASS", + "STEP agent.raw.stray-response.unknown-id PASS", + "STEP agent.raw.stray-response.null-id PASS", + "STEP agent.raw.stray-response.null-id-error PASS", + "STEP agent.raw.update.unknown PASS", + "STEP agent.enum.audience PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.fs.write PASS", + "STEP agent.mode.set PASS", + "STEP agent.auth.authenticate PASS" + ] + }, + "checks": [ + "client RESULT pass == 18", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-http.json b/integration-testing/configs/conf-java-client-http.json new file mode 100644 index 00000000..cb9b1199 --- /dev/null +++ b/integration-testing/configs/conf-java-client-http.json @@ -0,0 +1,46 @@ +{ + "name": "conf-java-client-http", + "description": "java client (--mode raw) -> raw agent over http: 6 steps (generated)", + "timeoutSec": 270, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/agent.sh --transport http --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 30 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp --mode raw", + "env": { + "STEPS": "init.initialize,raw.error-no-message,raw.null-id-response,raw.unknown-update,raw.null-result,conn.close" + }, + "timeoutSec": 150 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 6", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-stdio.json b/integration-testing/configs/conf-java-client-stdio.json new file mode 100644 index 00000000..08fd8411 --- /dev/null +++ b/integration-testing/configs/conf-java-client-stdio.json @@ -0,0 +1,34 @@ +{ + "name": "conf-java-client-stdio", + "description": "java client (--mode raw) -> raw agent over stdio: 6 steps (generated)", + "timeoutSec": 270, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && ${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio --mode raw", + "env": { + "STEPS": "init.initialize,raw.error-no-message,raw.null-id-response,raw.unknown-update,raw.null-result,conn.close", + "AGENT_CMD": "${ROOT}/programs/raw/launch/agent.sh --transport stdio" + }, + "timeoutSec": 150 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 6", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/conf-java-client-ws.json b/integration-testing/configs/conf-java-client-ws.json new file mode 100644 index 00000000..97d1fd16 --- /dev/null +++ b/integration-testing/configs/conf-java-client-ws.json @@ -0,0 +1,46 @@ +{ + "name": "conf-java-client-ws", + "description": "java client (--mode raw) -> raw agent over ws: 6 steps (generated)", + "timeoutSec": 270, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/raw", + "build": "${ROOT}/programs/raw/launch/build.sh", + "run": "${ROOT}/programs/raw/launch/agent.sh --transport ws --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 30 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp --mode raw", + "env": { + "STEPS": "init.initialize,raw.error-no-message,raw.null-id-response,raw.unknown-update,raw.null-result,conn.close" + }, + "timeoutSec": 150 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 6", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-micronaut-agent-http.json b/integration-testing/configs/fw-micronaut-agent-http.json new file mode 100644 index 00000000..a1826822 --- /dev/null +++ b/integration-testing/configs/fw-micronaut-agent-http.json @@ -0,0 +1,69 @@ +{ + "name": "fw-micronaut-agent-http", + "description": "typescript client -> micronaut-hosted java agent over http, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/micronaut", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/micronaut/launch/build.sh", + "run": "${ROOT}/programs/micronaut/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-micronaut-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-micronaut-agent-stdio.json b/integration-testing/configs/fw-micronaut-agent-stdio.json new file mode 100644 index 00000000..87cc24f1 --- /dev/null +++ b/integration-testing/configs/fw-micronaut-agent-stdio.json @@ -0,0 +1,55 @@ +{ + "name": "fw-micronaut-agent-stdio", + "description": "typescript client -> micronaut-hosted java agent over stdio, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/micronaut/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/micronaut/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "client": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-micronaut-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-micronaut-agent-ws.json b/integration-testing/configs/fw-micronaut-agent-ws.json new file mode 100644 index 00000000..0999e9dd --- /dev/null +++ b/integration-testing/configs/fw-micronaut-agent-ws.json @@ -0,0 +1,69 @@ +{ + "name": "fw-micronaut-agent-ws", + "description": "typescript client -> micronaut-hosted java agent over ws, 13 smoke steps (generated from smoke.json)", + "timeoutSec": 365, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/micronaut", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/micronaut/launch/build.sh", + "run": "${ROOT}/programs/micronaut/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 185 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-micronaut-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-micronaut-client-http.json b/integration-testing/configs/fw-micronaut-client-http.json new file mode 100644 index 00000000..06de606f --- /dev/null +++ b/integration-testing/configs/fw-micronaut-client-http.json @@ -0,0 +1,59 @@ +{ + "name": "fw-micronaut-client-http", + "description": "micronaut-built java client -> typescript agent over http, 10 smoke steps (generated from smoke.json)", + "timeoutSec": 350, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/micronaut", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/micronaut/launch/build.sh", + "run": "${ROOT}/programs/micronaut/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-info,session.new,update.agent_message_chunk,perm.selected,fs.read,elicit.form,cancel.prompt,ext.client-request,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 170 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.fs.read PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "checks": [ + "client RESULT pass == 10", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-quarkus-agent-http.json b/integration-testing/configs/fw-quarkus-agent-http.json new file mode 100644 index 00000000..5abeb8ac --- /dev/null +++ b/integration-testing/configs/fw-quarkus-agent-http.json @@ -0,0 +1,69 @@ +{ + "name": "fw-quarkus-agent-http", + "description": "typescript client -> quarkus-hosted java agent over http, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-quarkus-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-quarkus-agent-stdio.json b/integration-testing/configs/fw-quarkus-agent-stdio.json new file mode 100644 index 00000000..23bd4b1c --- /dev/null +++ b/integration-testing/configs/fw-quarkus-agent-stdio.json @@ -0,0 +1,55 @@ +{ + "name": "fw-quarkus-agent-stdio", + "description": "typescript client -> quarkus-hosted java agent over stdio, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/quarkus/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "client": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-quarkus-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-quarkus-agent-ws.json b/integration-testing/configs/fw-quarkus-agent-ws.json new file mode 100644 index 00000000..266fd968 --- /dev/null +++ b/integration-testing/configs/fw-quarkus-agent-ws.json @@ -0,0 +1,69 @@ +{ + "name": "fw-quarkus-agent-ws", + "description": "typescript client -> quarkus-hosted java agent over ws, 13 smoke steps (generated from smoke.json)", + "timeoutSec": 365, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 185 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-quarkus-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-quarkus-client-http.json b/integration-testing/configs/fw-quarkus-client-http.json new file mode 100644 index 00000000..ab7a09bf --- /dev/null +++ b/integration-testing/configs/fw-quarkus-client-http.json @@ -0,0 +1,59 @@ +{ + "name": "fw-quarkus-client-http", + "description": "quarkus-built java client -> typescript agent over http, 10 smoke steps (generated from smoke.json)", + "timeoutSec": 350, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-info,session.new,update.agent_message_chunk,perm.selected,fs.read,elicit.form,cancel.prompt,ext.client-request,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 170 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.fs.read PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "checks": [ + "client RESULT pass == 10", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-agent-http.json b/integration-testing/configs/fw-spring-agent-http.json new file mode 100644 index 00000000..2f0e8c1f --- /dev/null +++ b/integration-testing/configs/fw-spring-agent-http.json @@ -0,0 +1,69 @@ +{ + "name": "fw-spring-agent-http", + "description": "typescript client -> spring-hosted java agent over http, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-spring-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-agent-stdio.json b/integration-testing/configs/fw-spring-agent-stdio.json new file mode 100644 index 00000000..3f2fd245 --- /dev/null +++ b/integration-testing/configs/fw-spring-agent-stdio.json @@ -0,0 +1,55 @@ +{ + "name": "fw-spring-agent-stdio", + "description": "typescript client -> spring-hosted java agent over stdio, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/spring/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "client": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-spring-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-agent-ws.json b/integration-testing/configs/fw-spring-agent-ws.json new file mode 100644 index 00000000..f63f6e66 --- /dev/null +++ b/integration-testing/configs/fw-spring-agent-ws.json @@ -0,0 +1,69 @@ +{ + "name": "fw-spring-agent-ws", + "description": "typescript client -> spring-hosted java agent over ws, 13 smoke steps (generated from smoke.json)", + "timeoutSec": 365, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 185 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-spring-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-client-http.json b/integration-testing/configs/fw-spring-client-http.json new file mode 100644 index 00000000..8748a38b --- /dev/null +++ b/integration-testing/configs/fw-spring-client-http.json @@ -0,0 +1,59 @@ +{ + "name": "fw-spring-client-http", + "description": "spring-built java client -> typescript agent over http, 10 smoke steps (generated from smoke.json)", + "timeoutSec": 350, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-info,session.new,update.agent_message_chunk,perm.selected,fs.read,elicit.form,cancel.prompt,ext.client-request,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 170 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.fs.read PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "checks": [ + "client RESULT pass == 10", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-webflux-agent-http.json b/integration-testing/configs/fw-spring-webflux-agent-http.json new file mode 100644 index 00000000..662110b6 --- /dev/null +++ b/integration-testing/configs/fw-spring-webflux-agent-http.json @@ -0,0 +1,69 @@ +{ + "name": "fw-spring-webflux-agent-http", + "description": "typescript client -> spring-webflux-hosted java agent over http, 14 smoke steps (generated from smoke.json)", + "timeoutSec": 370, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --web reactive --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 190 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-spring-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 14", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/fw-spring-webflux-agent-ws.json b/integration-testing/configs/fw-spring-webflux-agent-ws.json new file mode 100644 index 00000000..61b4ff89 --- /dev/null +++ b/integration-testing/configs/fw-spring-webflux-agent-ws.json @@ -0,0 +1,69 @@ +{ + "name": "fw-spring-webflux-agent-ws", + "description": "typescript client -> spring-webflux-hosted java agent over ws, 13 smoke steps (generated from smoke.json)", + "timeoutSec": 365, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --web reactive --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.agent-info,session.new,session.load,session.multi,update.agent_message_chunk,perm.selected,elicit.form,cancel.prompt,ext.client-request,error.method-not-found,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 185 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.perm.selected PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "requiredPatterns": { + "client": [ + "STEP init\\.initialize PASS .*\"name\":\"interop-spring-agent\"" + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/interop-java-java.json b/integration-testing/configs/interop-java-java.json deleted file mode 100644 index 716b74c1..00000000 --- a/integration-testing/configs/interop-java-java.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "interop-java-java", - "description": "Java client -> Java server (both SDKs from this checkout)", - "timeoutSec": 240, - "peers": [], - "processes": [ - { - "name": "server", - "language": "java", - "role": "server", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaAgentMain ${PORT}", - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "java", - "role": "client", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaClientMain http://127.0.0.1:${PORT}/acp", - "timeoutSec": 120 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "negotiated HTTP_2", - "permission request:", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS", - "STEP close PASS", - "STEP reconnect-initialize PASS", - "STEP reconnect-session/load PASS", - "STEP reconnect-prompt PASS", - "STEP reconnect-close PASS" - ], - "server": [ - "[agent] permission granted=true", - "[agent] session/load java-sess-1" - ] - }, - "forbiddenOutput": { - "client": [ - "negotiated HTTP_1_1" - ], - "server": [ - " /acp HTTP/1.1 " - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/2\\.0 .*-> 200", - "\\[http\\] DELETE /acp HTTP/2\\.0 .*-> 202" - ] - }, - "checks": [ - "client RESULT pass == 12", - "client RESULT fail == 0", - "client RESULT prompt1_updates >= 2", - "client RESULT prompt2_updates >= 2", - "client RESULT prompt3_permission_updates >= 2", - "client RESULT prompt_after_load_updates >= 2", - "client RESULT reconnect_prompt_updates >= 2" - ] - } -} diff --git a/integration-testing/configs/interop-python-client.json b/integration-testing/configs/interop-python-client.json deleted file mode 100644 index 07585d86..00000000 --- a/integration-testing/configs/interop-python-client.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "interop-python-client", - "description": "Python SDK client -> Java server", - "timeoutSec": 240, - "peers": [ - "python-sdk" - ], - "processes": [ - { - "name": "server", - "language": "java", - "role": "server", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaAgentMain ${PORT}", - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "python", - "role": "client", - "dir": "${ROOT}/programs/python", - "run": "${peer.python-sdk}/.venv/bin/python py-client.py http://127.0.0.1:${PORT}/acp", - "env": { - "PYTHONUNBUFFERED": "1" - }, - "timeoutSec": 90 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "permission request", - "RESULT pass=", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS", - "STEP close PASS" - ], - "server": [ - "[agent] permission granted=true", - "[agent] session/load java-sess-1" - ] - }, - "forbiddenOutput": { - "server": [ - " /acp HTTP/2.0 " - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/1\\.1 -> 200", - "\\[http\\] DELETE /acp HTTP/1\\.1 -> 202" - ] - }, - "checks": [ - "client RESULT pass == 8", - "client RESULT fail == 0", - "client RESULT updates_total == 9" - ] - } -} diff --git a/integration-testing/configs/interop-python-server.json b/integration-testing/configs/interop-python-server.json deleted file mode 100644 index 2e575f84..00000000 --- a/integration-testing/configs/interop-python-server.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "name": "interop-python-server", - "description": "Java client -> Python SDK server (Hypercorn, h2c enabled)", - "timeoutSec": 240, - "peers": [ - "python-sdk" - ], - "processes": [ - { - "name": "server", - "language": "python", - "role": "server", - "dir": "${ROOT}/programs/python", - "run": "${peer.python-sdk}/.venv/bin/python py-server.py", - "env": { - "PORT": "${PORT}", - "PYTHONUNBUFFERED": "1" - }, - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "java", - "role": "client", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaClientMain http://127.0.0.1:${PORT}/acp", - "timeoutSec": 120 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "negotiated HTTP_2", - "permission request:", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS", - "STEP close PASS", - "STEP reconnect-initialize PASS", - "STEP reconnect-close PASS" - ], - "server": [ - "[agent] permission result", - "[agent] session/load" - ] - }, - "forbiddenOutput": { - "client": [ - "negotiated HTTP_1_1" - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/2 .*-> 200", - "\\[http\\] DELETE /acp HTTP/2 .*-> 202" - ] - }, - "checks": [ - "client RESULT pass == 10", - "client RESULT fail == 2", - "client RESULT prompt1_updates >= 2", - "client RESULT prompt2_updates >= 2", - "client RESULT prompt3_permission_updates >= 2", - "client RESULT prompt_after_load_updates >= 2" - ], - "expectedFailures": [ - { - "process": "client", - "step": "reconnect-session/load", - "contains": "404", - "reason": "RFD contradiction, not a Java bug: the Java client follows the reconnect diagram and pre-opens the session GET before session/load on the new connection; the Python server follows the rule that an Acp-Session-Id unknown to the connection is a 404", - "see": "RFD streamable-http-websocket-transport: the reconnect sequence opens the session GET before session/load, while the server rules say an unknown Acp-Session-Id returns 404; the Python server follows the latter" - }, - { - "process": "client", - "step": "reconnect-prompt", - "contains": "404", - "reason": "follows from reconnect-session/load: the session was never attached to the new connection", - "see": "RFD streamable-http-websocket-transport: the reconnect sequence opens the session GET before session/load, while the server rules say an unknown Acp-Session-Id returns 404; the Python server follows the latter" - } - ] - }, - "comment": "Reconnect-then-load is an EXPECTED failure (RFD contradiction, not a Java bug); if the Python server starts accepting it the scenario fails so the expectation gets updated." -} diff --git a/integration-testing/configs/interop-rust-client.json b/integration-testing/configs/interop-rust-client.json deleted file mode 100644 index 1ef26905..00000000 --- a/integration-testing/configs/interop-rust-client.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "name": "interop-rust-client", - "description": "Rust SDK client -> Java server", - "timeoutSec": 240, - "peers": [ - "rust-sdk" - ], - "processes": [ - { - "name": "server", - "language": "java", - "role": "server", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaAgentMain ${PORT}", - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "rust", - "role": "client", - "dir": "${ROOT}/programs/rust", - "build": "CARGO_TARGET_DIR=${CACHE}/build/${peerKey.rust-sdk} cargo build --release --quiet", - "run": "${CACHE}/build/${peerKey.rust-sdk}/release/rust-client http://127.0.0.1:${PORT}/acp", - "timeoutSec": 90 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "permission request", - "connect_with result: Ok(())", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS" - ], - "server": [ - "[agent] permission granted=true", - "[agent] session/load java-sess-1" - ] - }, - "forbiddenOutput": { - "server": [ - " /acp HTTP/2.0 " - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/1\\.1 -> 200", - "\\[http\\] DELETE /acp HTTP/1\\.1 -> 202" - ] - }, - "checks": [ - "client RESULT pass == 7", - "client RESULT fail == 0", - "client RESULT updates_total == 9" - ] - } -} diff --git a/integration-testing/configs/interop-rust-server.json b/integration-testing/configs/interop-rust-server.json deleted file mode 100644 index 410052aa..00000000 --- a/integration-testing/configs/interop-rust-server.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "interop-rust-server", - "description": "Java client -> Rust SDK server (axum)", - "timeoutSec": 240, - "peers": [ - "rust-sdk" - ], - "processes": [ - { - "name": "server", - "language": "rust", - "role": "server", - "dir": "${ROOT}/programs/rust", - "build": "CARGO_TARGET_DIR=${CACHE}/build/${peerKey.rust-sdk} cargo build --release --quiet", - "run": "${CACHE}/build/${peerKey.rust-sdk}/release/rust-agent", - "env": { - "PORT": "${PORT}" - }, - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "java", - "role": "client", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaClientMain http://127.0.0.1:${PORT}/acp", - "timeoutSec": 120 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "negotiated HTTP_1_1", - "permission request:", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS", - "STEP close PASS", - "STEP reconnect-initialize PASS", - "STEP reconnect-session/load PASS", - "STEP reconnect-prompt PASS", - "STEP reconnect-close PASS" - ], - "server": [ - "[agent] permission result", - "[agent] session/load" - ] - }, - "forbiddenOutput": { - "client": [ - "negotiated HTTP_2" - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", - "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" - ] - }, - "checks": [ - "client RESULT pass == 12", - "client RESULT fail == 0", - "client RESULT prompt1_updates >= 2", - "client RESULT prompt2_updates >= 2", - "client RESULT prompt3_permission_updates >= 2", - "client RESULT prompt_after_load_updates >= 2", - "client RESULT reconnect_prompt_updates >= 2" - ] - }, - "comment": "The Rust server answers session/load on the session stream rather than the connection stream; the Java client logs a WARN and accepts it." -} diff --git a/integration-testing/configs/interop-ts-client.json b/integration-testing/configs/interop-ts-client.json deleted file mode 100644 index 1b269e18..00000000 --- a/integration-testing/configs/interop-ts-client.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "interop-ts-client", - "description": "TypeScript SDK client -> Java server", - "timeoutSec": 240, - "peers": [ - "typescript-sdk" - ], - "processes": [ - { - "name": "server", - "language": "java", - "role": "server", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaAgentMain ${PORT}", - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "node", - "role": "client", - "dir": "${ROOT}/programs/node", - "run": "node ts-client.mjs http://127.0.0.1:${PORT}/acp", - "env": { - "TS_SDK": "${peer.typescript-sdk}" - }, - "timeoutSec": 90 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "permission request", - "connectWith returned", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS" - ], - "server": [ - "[agent] permission granted=true", - "[agent] session/load java-sess-1" - ] - }, - "forbiddenOutput": { - "server": [ - " /acp HTTP/2.0 " - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/1\\.1 -> 200", - "\\[http\\] DELETE /acp HTTP/1\\.1 -> 202" - ] - }, - "checks": [ - "client RESULT pass == 7", - "client RESULT fail == 0", - "client RESULT updates_total == 9" - ] - } -} diff --git a/integration-testing/configs/interop-ts-server.json b/integration-testing/configs/interop-ts-server.json deleted file mode 100644 index 6f2f957a..00000000 --- a/integration-testing/configs/interop-ts-server.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "interop-ts-server", - "description": "Java client -> TypeScript SDK server (example wiring as shipped: HTTP handler plus WebSocket upgrade listener)", - "timeoutSec": 240, - "peers": [ - "typescript-sdk" - ], - "processes": [ - { - "name": "server", - "language": "node", - "role": "server", - "dir": "${ROOT}/programs/node", - "run": "node ts-server.mjs", - "env": { - "TS_SDK": "${peer.typescript-sdk}", - "PORT": "${PORT}" - }, - "ready": { - "line": "READY", - "timeoutSec": 60 - } - }, - { - "name": "client", - "language": "java", - "role": "client", - "dir": "${ROOT}/programs/java", - "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", - "run": "java -cp target/classes:$(cat target/classpath.txt) interop.JavaClientMain http://127.0.0.1:${PORT}/acp", - "timeoutSec": 120 - } - ], - "assertions": { - "requiredOutput": { - "client": [ - "negotiated HTTP_1_1", - "permission request:", - "STEP initialize PASS", - "STEP session/new PASS", - "STEP prompt1 PASS", - "STEP prompt2 PASS", - "STEP prompt3-permission PASS", - "STEP session/load PASS", - "STEP prompt-after-load PASS", - "STEP close PASS", - "STEP reconnect-initialize PASS", - "STEP reconnect-session/load PASS", - "STEP reconnect-prompt PASS", - "STEP reconnect-close PASS" - ], - "server": [ - "[agent] permission result", - "[agent] session/load" - ] - }, - "forbiddenOutput": { - "client": [ - "negotiated HTTP_2" - ] - }, - "requiredPatterns": { - "server": [ - "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", - "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" - ] - }, - "checks": [ - "client RESULT pass == 12", - "client RESULT fail == 0", - "client RESULT prompt1_updates >= 2", - "client RESULT prompt2_updates >= 2", - "client RESULT prompt3_permission_updates >= 2", - "client RESULT prompt_after_load_updates >= 2", - "client RESULT reconnect_prompt_updates >= 2" - ] - }, - "comment": "Node has no h2c, and its upgrade listener would take the JDK's 'Upgrade: h2c' as a WebSocket request (405); the Java client pins HTTP/1.1 after its GET probe." -} diff --git a/integration-testing/configs/load-10-fw-micronaut.json b/integration-testing/configs/load-10-fw-micronaut.json new file mode 100644 index 00000000..0b39a6d7 --- /dev/null +++ b/integration-testing/configs/load-10-fw-micronaut.json @@ -0,0 +1,56 @@ +{ + "name": "load-10-fw-micronaut", + "description": "10 connections x 10 prompts against the micronaut-hosted agent over http, two streamed updates each (generated from smoke.json)", + "timeoutSec": 420, + "processes": [ + { + "name": "server", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/micronaut", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/micronaut/launch/build.sh", + "run": "${ROOT}/programs/micronaut/launch/agent.sh --transport http --port ${PORT}", + "env": { + "JAVA_OPTS": "-Xmx512m" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "clients", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "java -Xmx1g -Dacp.client.level=INFO -cp target/classes:$(cat target/classpath.txt) load.LoadClients http://127.0.0.1:${PORT}/acp 10 10 perclient", + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": { + "clients": [ + "PEAK_REACHED", + "CLOSED" + ] + }, + "forbiddenOutput": { + "clients": [ + "ERROR ", + "CLOSE_ERROR" + ], + "server": [ + " ERROR " + ] + }, + "checks": [ + "clients RESULT errors == 0", + "clients RESULT ok == expected", + "clients RESULT expected == 100", + "clients RESULT updates == 2*ok", + "clients RESULT close_errors == 0" + ] + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/load-10-fw-quarkus.json b/integration-testing/configs/load-10-fw-quarkus.json new file mode 100644 index 00000000..3081919c --- /dev/null +++ b/integration-testing/configs/load-10-fw-quarkus.json @@ -0,0 +1,56 @@ +{ + "name": "load-10-fw-quarkus", + "description": "10 connections x 10 prompts against the quarkus-hosted agent over http, two streamed updates each (generated from smoke.json)", + "timeoutSec": 420, + "processes": [ + { + "name": "server", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/agent.sh --transport http --port ${PORT}", + "env": { + "JAVA_OPTS": "-Xmx512m" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "clients", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "java -Xmx1g -Dacp.client.level=INFO -cp target/classes:$(cat target/classpath.txt) load.LoadClients http://127.0.0.1:${PORT}/acp 10 10 perclient", + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": { + "clients": [ + "PEAK_REACHED", + "CLOSED" + ] + }, + "forbiddenOutput": { + "clients": [ + "ERROR ", + "CLOSE_ERROR" + ], + "server": [ + " ERROR " + ] + }, + "checks": [ + "clients RESULT errors == 0", + "clients RESULT ok == expected", + "clients RESULT expected == 100", + "clients RESULT updates == 2*ok", + "clients RESULT close_errors == 0" + ] + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/load-10-fw-spring-webflux.json b/integration-testing/configs/load-10-fw-spring-webflux.json new file mode 100644 index 00000000..42374391 --- /dev/null +++ b/integration-testing/configs/load-10-fw-spring-webflux.json @@ -0,0 +1,56 @@ +{ + "name": "load-10-fw-spring-webflux", + "description": "10 connections x 10 prompts against the spring-webflux-hosted agent over http, two streamed updates each (generated from smoke.json)", + "timeoutSec": 420, + "processes": [ + { + "name": "server", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --web reactive --transport http --port ${PORT}", + "env": { + "JAVA_OPTS": "-Xmx512m" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "clients", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "java -Xmx1g -Dacp.client.level=INFO -cp target/classes:$(cat target/classpath.txt) load.LoadClients http://127.0.0.1:${PORT}/acp 10 10 perclient", + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": { + "clients": [ + "PEAK_REACHED", + "CLOSED" + ] + }, + "forbiddenOutput": { + "clients": [ + "ERROR ", + "CLOSE_ERROR" + ], + "server": [ + " ERROR " + ] + }, + "checks": [ + "clients RESULT errors == 0", + "clients RESULT ok == expected", + "clients RESULT expected == 100", + "clients RESULT updates == 2*ok", + "clients RESULT close_errors == 0" + ] + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/load-10-fw-spring.json b/integration-testing/configs/load-10-fw-spring.json new file mode 100644 index 00000000..27dbcc45 --- /dev/null +++ b/integration-testing/configs/load-10-fw-spring.json @@ -0,0 +1,56 @@ +{ + "name": "load-10-fw-spring", + "description": "10 connections x 10 prompts against the spring-hosted agent over http, two streamed updates each (generated from smoke.json)", + "timeoutSec": 420, + "processes": [ + { + "name": "server", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/spring", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/spring/launch/build.sh", + "run": "${ROOT}/programs/spring/launch/agent.sh --transport http --port ${PORT}", + "env": { + "JAVA_OPTS": "-Xmx512m" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "clients", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "java -Xmx1g -Dacp.client.level=INFO -cp target/classes:$(cat target/classpath.txt) load.LoadClients http://127.0.0.1:${PORT}/acp 10 10 perclient", + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": { + "clients": [ + "PEAK_REACHED", + "CLOSED" + ] + }, + "forbiddenOutput": { + "clients": [ + "ERROR ", + "CLOSE_ERROR" + ], + "server": [ + " ERROR " + ] + }, + "checks": [ + "clients RESULT errors == 0", + "clients RESULT ok == expected", + "clients RESULT expected == 100", + "clients RESULT updates == 2*ok", + "clients RESULT close_errors == 0" + ] + }, + "comment": "GENERATED by GenConfigs.java from smoke.json, steps.json and matrix.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/quarkus-typescript-http.json b/integration-testing/configs/quarkus-typescript-http.json new file mode 100644 index 00000000..39a50aae --- /dev/null +++ b/integration-testing/configs/quarkus-typescript-http.json @@ -0,0 +1,57 @@ +{ + "name": "quarkus-typescript-http", + "description": "typescript client -> Quarkus-hosted annotated Java agent (acp-quarkus, @AcpAgent bean on the Quarkus HTTP server) over http: the catalogue steps that agent implements (hand-written)", + "timeoutSec": 400, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.new,session.multi,update.agent_message_chunk,stop.max_tokens,stop.refusal,stop.max_turn_requests,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": {}, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + } +} diff --git a/integration-testing/configs/quarkus-typescript-ws.json b/integration-testing/configs/quarkus-typescript-ws.json new file mode 100644 index 00000000..3dc96b80 --- /dev/null +++ b/integration-testing/configs/quarkus-typescript-ws.json @@ -0,0 +1,57 @@ +{ + "name": "quarkus-typescript-ws", + "description": "typescript client -> Quarkus-hosted annotated Java agent (acp-quarkus, @AcpAgent bean on the Quarkus HTTP server) over ws: the catalogue steps that agent implements (hand-written)", + "timeoutSec": 400, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/quarkus", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/quarkus/launch/build.sh", + "run": "${ROOT}/programs/quarkus/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.new,session.multi,update.agent_message_chunk,stop.max_tokens,stop.refusal,stop.max_turn_requests,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 300 + } + ], + "assertions": { + "requiredOutput": {}, + "forbiddenOutput": { + "agent": [ + " ERROR ", + " WARN " + ] + }, + "checks": [ + "client RESULT pass == 13", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + } +} diff --git a/integration-testing/configs/smoke-claude.json b/integration-testing/configs/smoke-claude.json new file mode 100644 index 00000000..ebf8273a --- /dev/null +++ b/integration-testing/configs/smoke-claude.json @@ -0,0 +1,31 @@ +{ + "name": "smoke-claude", + "description": "Real-agent smoke: the Java stdio client from this checkout against the published claude ACP agent (programs/smoke). Not part of the default run (tag: smoke).", + "comment": "Tag smoke: run-all.sh must skip smoke-* unless asked. Without credentials the prompt step prints SKIP, which the runner ignores; set the agent's key (or log in to its CLI locally) to run it.", + "timeoutSec": 900, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/smoke", + "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", + "run": "./smoke.sh claude", + "timeoutSec": 720 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP initialize PASS", + "STEP session/new PASS", + "STEP close PASS" + ] + }, + "checks": [ + "client RESULT fail == 0", + "client RESULT protocol_version == 1" + ] + } +} diff --git a/integration-testing/configs/smoke-codex.json b/integration-testing/configs/smoke-codex.json new file mode 100644 index 00000000..f2a0fa4d --- /dev/null +++ b/integration-testing/configs/smoke-codex.json @@ -0,0 +1,31 @@ +{ + "name": "smoke-codex", + "description": "Real-agent smoke: the Java stdio client from this checkout against the published codex ACP agent (programs/smoke). Not part of the default run (tag: smoke).", + "comment": "Tag smoke: run-all.sh must skip smoke-* unless asked. Without credentials the prompt step prints SKIP, which the runner ignores; set the agent's key (or log in to its CLI locally) to run it.", + "timeoutSec": 900, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/smoke", + "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", + "run": "./smoke.sh codex", + "timeoutSec": 720 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP initialize PASS", + "STEP session/new PASS", + "STEP close PASS" + ] + }, + "checks": [ + "client RESULT fail == 0", + "client RESULT protocol_version == 1" + ] + } +} diff --git a/integration-testing/configs/smoke-gemini.json b/integration-testing/configs/smoke-gemini.json new file mode 100644 index 00000000..57cf5e05 --- /dev/null +++ b/integration-testing/configs/smoke-gemini.json @@ -0,0 +1,31 @@ +{ + "name": "smoke-gemini", + "description": "Real-agent smoke: the Java stdio client from this checkout against the published gemini ACP agent (programs/smoke). Not part of the default run (tag: smoke).", + "comment": "Tag smoke: run-all.sh must skip smoke-* unless asked. Without credentials the prompt step prints SKIP, which the runner ignores; set the agent's key (or log in to its CLI locally) to run it.", + "timeoutSec": 900, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/smoke", + "build": "${MVNW} -q -B -Dacp.version=${ACP_VERSION} compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", + "run": "./smoke.sh gemini", + "timeoutSec": 720 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP initialize PASS", + "STEP session/new PASS", + "STEP close PASS" + ] + }, + "checks": [ + "client RESULT fail == 0", + "client RESULT protocol_version == 1" + ] + } +} diff --git a/integration-testing/configs/x-java-java-http-unstable.json b/integration-testing/configs/x-java-java-http-unstable.json new file mode 100644 index 00000000..ed40ec76 --- /dev/null +++ b/integration-testing/configs/x-java-java-http-unstable.json @@ -0,0 +1,66 @@ +{ + "name": "x-java-java-http-unstable", + "description": "java client -> java agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_1_1" + ], + "agent": [ + " /acp HTTP/1.1 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/2\\.0 .*-> 200", + "\\[http\\] DELETE /acp HTTP/2\\.0 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-java-http.json b/integration-testing/configs/x-java-java-http.json new file mode 100644 index 00000000..d0d8ccf0 --- /dev/null +++ b/integration-testing/configs/x-java-java-http.json @@ -0,0 +1,88 @@ +{ + "name": "x-java-java-http", + "description": "java client -> java agent over http, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ], + "client": [ + "negotiated HTTP_2" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_1_1" + ], + "agent": [ + " /acp HTTP/1.1 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/2\\.0 .*-> 200", + "\\[http\\] DELETE /acp HTTP/2\\.0 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 73", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-java-stdio-unstable.json b/integration-testing/configs/x-java-java-stdio-unstable.json new file mode 100644 index 00000000..b4860afa --- /dev/null +++ b/integration-testing/configs/x-java-java-stdio-unstable.json @@ -0,0 +1,34 @@ +{ + "name": "x-java-java-stdio-unstable", + "description": "java client -> java agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-java-stdio.json b/integration-testing/configs/x-java-java-stdio.json new file mode 100644 index 00000000..50e3b7b9 --- /dev/null +++ b/integration-testing/configs/x-java-java-stdio.json @@ -0,0 +1,58 @@ +{ + "name": "x-java-java-stdio", + "description": "java client -> java agent over stdio, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 73", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-java-ws-unstable.json b/integration-testing/configs/x-java-java-ws-unstable.json new file mode 100644 index 00000000..82621a83 --- /dev/null +++ b/integration-testing/configs/x-java-java-ws-unstable.json @@ -0,0 +1,51 @@ +{ + "name": "x-java-java-ws-unstable", + "description": "java client -> java agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-java-ws.json b/integration-testing/configs/x-java-java-ws.json new file mode 100644 index 00000000..75927181 --- /dev/null +++ b/integration-testing/configs/x-java-java-ws.json @@ -0,0 +1,75 @@ +{ + "name": "x-java-java-ws", + "description": "java client -> java agent over ws, stable profile, 72 steps (generated)", + "timeoutSec": 600, + "peers": [], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": {}, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close" + }, + "timeoutSec": 480 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 72", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-kotlin-stdio-unstable.json b/integration-testing/configs/x-java-kotlin-stdio-unstable.json new file mode 100644 index 00000000..b5aeae1b --- /dev/null +++ b/integration-testing/configs/x-java-kotlin-stdio-unstable.json @@ -0,0 +1,37 @@ +{ + "name": "x-java-kotlin-stdio-unstable", + "description": "java client -> kotlin agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/kotlin/launch/agent.sh --transport stdio", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-kotlin-stdio.json b/integration-testing/configs/x-java-kotlin-stdio.json new file mode 100644 index 00000000..d714e05d --- /dev/null +++ b/integration-testing/configs/x-java-kotlin-stdio.json @@ -0,0 +1,75 @@ +{ + "name": "x-java-kotlin-stdio", + "description": "java client -> kotlin agent over stdio, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/kotlin/launch/agent.sh --transport stdio", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 68", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.stop", + "contains": "cannot send stopReason interop_future_stop", + "reason": "A Kotlin agent answers a prompt only with a PromptResponse, whose StopReason is a closed enum, so it cannot send an unknown stop reason; the program answers -32603 instead", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "Role does not contain element with name 'interop_future_role'", + "reason": "Role is a closed Kotlin enum, so the SDK fails to decode a prompt whose annotations.audience holds an unknown Role and answers -32700 before the handler runs", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Types.kt:157 [expectations/kotlin.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-kotlin-ws-unstable.json b/integration-testing/configs/x-java-kotlin-ws-unstable.json new file mode 100644 index 00000000..21ceafe3 --- /dev/null +++ b/integration-testing/configs/x-java-kotlin-ws-unstable.json @@ -0,0 +1,51 @@ +{ + "name": "x-java-kotlin-ws-unstable", + "description": "java client -> kotlin agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "kotlin", + "role": "server", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-kotlin-ws.json b/integration-testing/configs/x-java-kotlin-ws.json new file mode 100644 index 00000000..943c5c66 --- /dev/null +++ b/integration-testing/configs/x-java-kotlin-ws.json @@ -0,0 +1,89 @@ +{ + "name": "x-java-kotlin-ws", + "description": "java client -> kotlin agent over ws, stable profile, 69 steps (generated)", + "timeoutSec": 585, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "kotlin", + "role": "server", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 90 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 465 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 67", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.stop", + "contains": "cannot send stopReason interop_future_stop", + "reason": "A Kotlin agent answers a prompt only with a PromptResponse, whose StopReason is a closed enum, so it cannot send an unknown stop reason; the program answers -32603 instead", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "Role does not contain element with name 'interop_future_role'", + "reason": "Role is a closed Kotlin enum, so the SDK fails to decode a prompt whose annotations.audience holds an unknown Role and answers -32700 before the handler runs", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Types.kt:157 [expectations/kotlin.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-http-unstable.json b/integration-testing/configs/x-java-python-http-unstable.json new file mode 100644 index 00000000..c1a85ce3 --- /dev/null +++ b/integration-testing/configs/x-java-python-http-unstable.json @@ -0,0 +1,77 @@ +{ + "name": "x-java-python-http-unstable", + "description": "java client -> python agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/agent.sh --transport http --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_1_1" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/2(\\.0)? .*-> 200", + "\\[http\\] DELETE /acp HTTP/2(\\.0)? .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 2", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "session.fork", + "contains": "-32601", + "reason": "The SDK's HTTP server never enables the unstable router: session/fork is answered method-not-found", + "see": "peer:python-sdk http/server.py:270 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-http.json b/integration-testing/configs/x-java-python-http.json new file mode 100644 index 00000000..df75b1e6 --- /dev/null +++ b/integration-testing/configs/x-java-python-http.json @@ -0,0 +1,137 @@ +{ + "name": "x-java-python-http", + "description": "java client -> python agent over http, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/agent.sh --transport http --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ], + "client": [ + "negotiated HTTP_2" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_1_1" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/2(\\.0)? .*-> 200", + "\\[http\\] DELETE /acp HTTP/2(\\.0)? .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 65", + "client RESULT fail == 5" + ], + "expectedFailures": [ + { + "process": "client", + "step": "session.resume", + "contains": "-32601", + "reason": "The SDK's HTTP server builds each AgentSideConnection without use_unstable_protocol, so session/resume (unstable in the Python SDK) is answered method-not-found although the agent implements it", + "see": "peer:python-sdk http/server.py:270 [expectations/python.json]" + }, + { + "process": "client", + "step": "session.close", + "contains": "-32601", + "reason": "The SDK's HTTP server never enables the unstable router: session/close is answered method-not-found", + "see": "peer:python-sdk http/server.py:270 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user\"", + "reason": "Annotations.audience is List[Literal[\"assistant\", \"user\"]] with skip_invalid_items: the agent receives the prompt but the unknown Role interop_future_role is silently dropped, so it reads [user] and chunks \"audience: user\"", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "['user']", + "reason": "Annotations.audience skips invalid items: the Python agent's handler sees audience ['user'], interop_future_role dropped before it", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "", + "reason": "The Python SDK routes no $/cancel_request (only the constant exists), so the #slow handler never sees it and the prompt runs its full 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "cancel-request", + "reason": "The Python agent cannot send $/cancel_request (no API), so it answers the chunk \"cancel-request unsupported\" instead of \"cancel-request sent\"", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.cancel-request.agent", + "contains": "$/cancel_request", + "reason": "The Python agent cannot send $/cancel_request for its pending fs/read_text_file (no API)", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-stdio-unstable.json b/integration-testing/configs/x-java-python-stdio-unstable.json new file mode 100644 index 00000000..db420df7 --- /dev/null +++ b/integration-testing/configs/x-java-python-stdio-unstable.json @@ -0,0 +1,38 @@ +{ + "name": "x-java-python-stdio-unstable", + "description": "java client -> python agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/python/launch/agent.sh --transport stdio", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-stdio.json b/integration-testing/configs/x-java-python-stdio.json new file mode 100644 index 00000000..498a149f --- /dev/null +++ b/integration-testing/configs/x-java-python-stdio.json @@ -0,0 +1,95 @@ +{ + "name": "x-java-python-stdio", + "description": "java client -> python agent over stdio, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/python/launch/agent.sh --transport stdio", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 67", + "client RESULT fail == 3" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user\"", + "reason": "Annotations.audience is List[Literal[\"assistant\", \"user\"]] with skip_invalid_items: the agent receives the prompt but the unknown Role interop_future_role is silently dropped, so it reads [user] and chunks \"audience: user\"", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "client", + "step": "agent.enum.audience", + "contains": "['user']", + "reason": "Annotations.audience skips invalid items: the Python agent's handler sees audience ['user'], interop_future_role dropped before it", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "", + "reason": "The Python SDK routes no $/cancel_request (only the constant exists), so the #slow handler never sees it and the prompt runs its full 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "cancel-request", + "reason": "The Python agent cannot send $/cancel_request (no API), so it answers the chunk \"cancel-request unsupported\" instead of \"cancel-request sent\"", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "agent.cancel-request.agent", + "contains": "$/cancel_request", + "reason": "The Python agent cannot send $/cancel_request for its pending fs/read_text_file (no API)", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-ws-unstable.json b/integration-testing/configs/x-java-python-ws-unstable.json new file mode 100644 index 00000000..cfa0457d --- /dev/null +++ b/integration-testing/configs/x-java-python-ws-unstable.json @@ -0,0 +1,61 @@ +{ + "name": "x-java-python-ws-unstable", + "description": "java client -> python agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 2", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "session.fork", + "contains": "-32601", + "reason": "The SDK's WebSocket server never enables the unstable router: session/fork is answered method-not-found", + "see": "peer:python-sdk ws/server.py:55 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-python-ws.json b/integration-testing/configs/x-java-python-ws.json new file mode 100644 index 00000000..64f28bf9 --- /dev/null +++ b/integration-testing/configs/x-java-python-ws.json @@ -0,0 +1,123 @@ +{ + "name": "x-java-python-ws", + "description": "java client -> python agent over ws, stable profile, 69 steps (generated)", + "timeoutSec": 585, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "python", + "role": "server", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 465 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 64", + "client RESULT fail == 5" + ], + "expectedFailures": [ + { + "process": "client", + "step": "session.resume", + "contains": "-32601", + "reason": "handle_websocket builds the AgentSideConnection without use_unstable_protocol, so session/resume is answered method-not-found although the agent implements it", + "see": "peer:python-sdk ws/server.py:55 [expectations/python.json]" + }, + { + "process": "client", + "step": "session.close", + "contains": "-32601", + "reason": "The SDK's WebSocket server never enables the unstable router: session/close is answered method-not-found", + "see": "peer:python-sdk ws/server.py:55 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user\"", + "reason": "Annotations.audience is List[Literal[\"assistant\", \"user\"]] with skip_invalid_items: the agent receives the prompt but the unknown Role interop_future_role is silently dropped, so it reads [user] and chunks \"audience: user\"", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "['user']", + "reason": "Annotations.audience skips invalid items: the Python agent's handler sees audience ['user'], interop_future_role dropped before it", + "see": "peer:python-sdk schema.py:2648 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "", + "reason": "The Python SDK routes no $/cancel_request (only the constant exists), so the #slow handler never sees it and the prompt runs its full 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "cancel-request", + "reason": "The Python agent cannot send $/cancel_request (no API), so it answers the chunk \"cancel-request unsupported\" instead of \"cancel-request sent\"", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.cancel-request.agent", + "contains": "$/cancel_request", + "reason": "The Python agent cannot send $/cancel_request for its pending fs/read_text_file (no API)", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-http-unstable.json b/integration-testing/configs/x-java-rust-http-unstable.json new file mode 100644 index 00000000..40ee1e6e --- /dev/null +++ b/integration-testing/configs/x-java-rust-http-unstable.json @@ -0,0 +1,69 @@ +{ + "name": "x-java-rust-http-unstable", + "description": "java client -> rust agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "rust", + "role": "server", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/agent.sh --transport http --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "negotiated HTTP_1_1" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-http.json b/integration-testing/configs/x-java-rust-http.json new file mode 100644 index 00000000..60e4e245 --- /dev/null +++ b/integration-testing/configs/x-java-rust-http.json @@ -0,0 +1,112 @@ +{ + "name": "x-java-rust-http", + "description": "java client -> rust agent over http, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "rust", + "role": "server", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/agent.sh --transport http --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ], + "client": [ + "negotiated HTTP_1_1" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 68", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "The Rust agent cannot answer an unknown stopReason: a typed prompt Responder re-parses even an erase_to_json() answer into PromptResponse (closed StopReason, schema src/v1/agent.rs:3182), so the SDK answers -32700 instead", + "see": "peer:rust-sdk src/agent-client-protocol/src/jsonrpc.rs:4777 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum, so the agent silently drops interop_future_role and echoes \"audience: user\"", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "[\"user\"]", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum: the unknown Role is silently dropped, so the agent reads [\"user\"]", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-stdio-unstable.json b/integration-testing/configs/x-java-rust-stdio-unstable.json new file mode 100644 index 00000000..f8a16d94 --- /dev/null +++ b/integration-testing/configs/x-java-rust-stdio-unstable.json @@ -0,0 +1,38 @@ +{ + "name": "x-java-rust-stdio-unstable", + "description": "java client -> rust agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/rust/launch/agent.sh --transport stdio", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-stdio.json b/integration-testing/configs/x-java-rust-stdio.json new file mode 100644 index 00000000..51f0c01e --- /dev/null +++ b/integration-testing/configs/x-java-rust-stdio.json @@ -0,0 +1,83 @@ +{ + "name": "x-java-rust-stdio", + "description": "java client -> rust agent over stdio, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/rust/launch/agent.sh --transport stdio", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 68", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "The Rust agent cannot answer an unknown stopReason: a typed prompt Responder re-parses even an erase_to_json() answer into PromptResponse (closed StopReason, schema src/v1/agent.rs:3182), so the SDK answers -32700 instead", + "see": "peer:rust-sdk src/agent-client-protocol/src/jsonrpc.rs:4777 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum, so the agent silently drops interop_future_role and echoes \"audience: user\"", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + }, + { + "process": "client", + "step": "agent.enum.audience", + "contains": "[\"user\"]", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum: the unknown Role is silently dropped, so the agent reads [\"user\"]", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-ws-unstable.json b/integration-testing/configs/x-java-rust-ws-unstable.json new file mode 100644 index 00000000..c1b29add --- /dev/null +++ b/integration-testing/configs/x-java-rust-ws-unstable.json @@ -0,0 +1,53 @@ +{ + "name": "x-java-rust-ws-unstable", + "description": "java client -> rust agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "rust", + "role": "server", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-rust-ws.json b/integration-testing/configs/x-java-rust-ws.json new file mode 100644 index 00000000..b4117092 --- /dev/null +++ b/integration-testing/configs/x-java-rust-ws.json @@ -0,0 +1,98 @@ +{ + "name": "x-java-rust-ws", + "description": "java client -> rust agent over ws, stable profile, 69 steps (generated)", + "timeoutSec": 585, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "rust", + "role": "server", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 465 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 67", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "The Rust agent cannot answer an unknown stopReason: a typed prompt Responder re-parses even an erase_to_json() answer into PromptResponse (closed StopReason, schema src/v1/agent.rs:3182), so the SDK answers -32700 instead", + "see": "peer:rust-sdk src/agent-client-protocol/src/jsonrpc.rs:4777 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.audience", + "contains": "audience: user", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum, so the agent silently drops interop_future_role and echoes \"audience: user\"", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "[\"user\"]", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum: the unknown Role is silently dropped, so the agent reads [\"user\"]", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-http-unstable.json b/integration-testing/configs/x-java-typescript-http-unstable.json new file mode 100644 index 00000000..fb70dedf --- /dev/null +++ b/integration-testing/configs/x-java-typescript-http-unstable.json @@ -0,0 +1,67 @@ +{ + "name": "x-java-typescript-http-unstable", + "description": "java client -> typescript agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "negotiated HTTP_1_1" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-http.json b/integration-testing/configs/x-java-typescript-http.json new file mode 100644 index 00000000..36c2222e --- /dev/null +++ b/integration-testing/configs/x-java-typescript-http.json @@ -0,0 +1,103 @@ +{ + "name": "x-java-typescript-http", + "description": "java client -> typescript agent over http, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ], + "client": [ + "negotiated HTTP_1_1" + ] + }, + "forbiddenOutput": { + "client": [ + "negotiated HTTP_2" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 69", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.audience", + "contains": "chunks [\"audience: user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127), so the TypeScript agent's handler sees [\"user\"]: the unknown Role interop_future_role is dropped, not kept", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "audience [\"user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127): the unknown Role is dropped before the handler sees the prompt", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-stdio-unstable.json b/integration-testing/configs/x-java-typescript-stdio-unstable.json new file mode 100644 index 00000000..ebfd5843 --- /dev/null +++ b/integration-testing/configs/x-java-typescript-stdio-unstable.json @@ -0,0 +1,37 @@ +{ + "name": "x-java-typescript-stdio-unstable", + "description": "java client -> typescript agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/node/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-stdio.json b/integration-testing/configs/x-java-typescript-stdio.json new file mode 100644 index 00000000..69ff855d --- /dev/null +++ b/integration-testing/configs/x-java-typescript-stdio.json @@ -0,0 +1,75 @@ +{ + "name": "x-java-typescript-stdio", + "description": "java client -> typescript agent over stdio, stable profile, 70 steps (generated)", + "timeoutSec": 590, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/node/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 470 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 69", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.audience", + "contains": "chunks [\"audience: user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127), so the TypeScript agent's handler sees [\"user\"]: the unknown Role interop_future_role is dropped, not kept", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + }, + { + "process": "client", + "step": "agent.enum.audience", + "contains": "audience [\"user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127): the unknown Role is dropped before the handler sees the prompt", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-ws-unstable.json b/integration-testing/configs/x-java-typescript-ws-unstable.json new file mode 100644 index 00000000..3303038e --- /dev/null +++ b/integration-testing/configs/x-java-typescript-ws-unstable.json @@ -0,0 +1,51 @@ +{ + "name": "x-java-typescript-ws-unstable", + "description": "java client -> typescript agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-java-typescript-ws.json b/integration-testing/configs/x-java-typescript-ws.json new file mode 100644 index 00000000..22117c78 --- /dev/null +++ b/integration-testing/configs/x-java-typescript-ws.json @@ -0,0 +1,89 @@ +{ + "name": "x-java-typescript-ws", + "description": "java client -> typescript agent over ws, stable profile, 69 steps (generated)", + "timeoutSec": 585, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "typescript", + "role": "server", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "java", + "role": "client", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 465 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 68", + "client RESULT fail == 1" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.audience", + "contains": "chunks [\"audience: user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127), so the TypeScript agent's handler sees [\"user\"]: the unknown Role interop_future_role is dropped, not kept", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + }, + { + "process": "agent", + "step": "agent.enum.audience", + "contains": "audience [\"user\"]", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127): the unknown Role is dropped before the handler sees the prompt", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-kotlin-java-stdio-unstable.json b/integration-testing/configs/x-kotlin-java-stdio-unstable.json new file mode 100644 index 00000000..82fd4508 --- /dev/null +++ b/integration-testing/configs/x-kotlin-java-stdio-unstable.json @@ -0,0 +1,37 @@ +{ + "name": "x-kotlin-java-stdio-unstable", + "description": "kotlin client -> java agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "client", + "language": "kotlin", + "role": "client", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-kotlin-java-stdio.json b/integration-testing/configs/x-kotlin-java-stdio.json new file mode 100644 index 00000000..9ee2f32c --- /dev/null +++ b/integration-testing/configs/x-kotlin-java-stdio.json @@ -0,0 +1,111 @@ +{ + "name": "x-kotlin-java-stdio", + "description": "kotlin client -> java agent over stdio, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "client", + "language": "kotlin", + "role": "client", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 66", + "client RESULT fail == 7" + ], + "expectedFailures": [ + { + "process": "client", + "step": "init.config-boolean", + "contains": "cannot advertise it", + "reason": "Kotlin ClientCapabilities has no session field, so the Kotlin client cannot advertise session.configOptions.boolean", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "auth.terminal", + "contains": "cannot advertise auth.terminal", + "reason": "Kotlin AuthCapabilities has only _meta, so the Kotlin client cannot advertise auth.terminal and no agent lists its terminal auth method", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:53 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "update.tool_call-name", + "contains": "no name field", + "reason": "Kotlin v1 SessionUpdate.ToolCall has no name field, so the client cannot read the tool call name", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionUpdate.kt:131 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.tool_call", + "contains": "the update was dropped", + "reason": "ToolCallStatus (and ToolKind, ToolCall.kt:18) is a closed Kotlin enum and ACPJson has no coerceInputValues, so a tool_call with an unknown status fails to decode and the SDK drops the whole update (the turn goes on)", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/ToolCall.kt:39 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "the update was dropped", + "reason": "PlanEntryPriority and PlanEntryStatus (Plan.kt:36) are closed Kotlin enums, so a plan with unknown values fails to decode and the SDK drops the update", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Plan.kt:22 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "StopReason does not contain element with name 'interop_future_stop'", + "reason": "StopReason is a closed Kotlin enum: the PromptResponse with stopReason interop_future_stop fails to decode and the prompt call fails (-32700); the session still answers the next prompt", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "config.boolean", + "contains": "", + "reason": "the Kotlin client cannot advertise session.configOptions.boolean, so no agent offers the boolean option verbose to it", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84 [expectations/kotlin.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-kotlin-java-ws-unstable.json b/integration-testing/configs/x-kotlin-java-ws-unstable.json new file mode 100644 index 00000000..47660abb --- /dev/null +++ b/integration-testing/configs/x-kotlin-java-ws-unstable.json @@ -0,0 +1,56 @@ +{ + "name": "x-kotlin-java-ws-unstable", + "description": "kotlin client -> java agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "kotlin", + "role": "client", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-kotlin-java-ws.json b/integration-testing/configs/x-kotlin-java-ws.json new file mode 100644 index 00000000..ac36bc81 --- /dev/null +++ b/integration-testing/configs/x-kotlin-java-ws.json @@ -0,0 +1,130 @@ +{ + "name": "x-kotlin-java-ws", + "description": "kotlin client -> java agent over ws, stable profile, 72 steps (generated)", + "timeoutSec": 600, + "peers": [ + "kotlin-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "kotlin", + "role": "client", + "dir": "${ROOT}/programs/kotlin", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" KOTLIN_SDK=\"${peer.kotlin-sdk}\" ${ROOT}/programs/kotlin/launch/build.sh", + "run": "${ROOT}/programs/kotlin/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "KOTLIN_SDK": "${peer.kotlin-sdk}" + }, + "timeoutSec": 480 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 65", + "client RESULT fail == 7" + ], + "expectedFailures": [ + { + "process": "client", + "step": "init.config-boolean", + "contains": "cannot advertise it", + "reason": "Kotlin ClientCapabilities has no session field, so the Kotlin client cannot advertise session.configOptions.boolean", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "auth.terminal", + "contains": "cannot advertise auth.terminal", + "reason": "Kotlin AuthCapabilities has only _meta, so the Kotlin client cannot advertise auth.terminal and no agent lists its terminal auth method", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:53 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "update.tool_call-name", + "contains": "no name field", + "reason": "Kotlin v1 SessionUpdate.ToolCall has no name field, so the client cannot read the tool call name", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionUpdate.kt:131 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.tool_call", + "contains": "the update was dropped", + "reason": "ToolCallStatus (and ToolKind, ToolCall.kt:18) is a closed Kotlin enum and ACPJson has no coerceInputValues, so a tool_call with an unknown status fails to decode and the SDK drops the whole update (the turn goes on)", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/ToolCall.kt:39 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "the update was dropped", + "reason": "PlanEntryPriority and PlanEntryStatus (Plan.kt:36) are closed Kotlin enums, so a plan with unknown values fails to decode and the SDK drops the update", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Plan.kt:22 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "StopReason does not contain element with name 'interop_future_stop'", + "reason": "StopReason is a closed Kotlin enum: the PromptResponse with stopReason interop_future_stop fails to decode and the prompt call fails (-32700); the session still answers the next prompt", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301 [expectations/kotlin.json]" + }, + { + "process": "client", + "step": "config.boolean", + "contains": "", + "reason": "the Kotlin client cannot advertise session.configOptions.boolean, so no agent offers the boolean option verbose to it", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84 [expectations/kotlin.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-http-unstable.json b/integration-testing/configs/x-python-java-http-unstable.json new file mode 100644 index 00000000..758d1682 --- /dev/null +++ b/integration-testing/configs/x-python-java-http-unstable.json @@ -0,0 +1,65 @@ +{ + "name": "x-python-java-http-unstable", + "description": "python client -> java agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-http.json b/integration-testing/configs/x-python-java-http.json new file mode 100644 index 00000000..5a284118 --- /dev/null +++ b/integration-testing/configs/x-python-java-http.json @@ -0,0 +1,137 @@ +{ + "name": "x-python-java-http", + "description": "python client -> java agent over http, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 67", + "client RESULT fail == 6" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status None", + "reason": "ToolCall's kind and status are closed Literals whose wrap validator (use_default_on_error) replaces an unknown value with None: the tool call surfaces with the raw status interop_future_status (and kind) dropped, so the value is lost rather than kept open", + "see": "peer:python-sdk schema.py:4983 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "PlanEntry.priority and status are closed Literals (schema.py:3542, :3547) and Plan.entries skips invalid items: the entry with interop_future_priority/interop_future_status is silently dropped and the plan surfaces empty", + "see": "peer:python-sdk schema.py:3578 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "ValidationError", + "reason": "PromptResponse.stopReason is a closed Literal with no lenient validator: a response with stopReason interop_future_stop fails the prompt call with a pydantic ValidationError (the connection survives)", + "see": "peer:python-sdk schema.py:3414 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so its fs read of slow.txt runs its full 10 s instead of ending -32800: the prompt ends late", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so the agent's cancelled fs/read_text_file is never answered -32800: the read handler answers the content after 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.unknown", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-stdio-unstable.json b/integration-testing/configs/x-python-java-stdio-unstable.json new file mode 100644 index 00000000..f9cb09a0 --- /dev/null +++ b/integration-testing/configs/x-python-java-stdio-unstable.json @@ -0,0 +1,38 @@ +{ + "name": "x-python-java-stdio-unstable", + "description": "python client -> java agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-stdio.json b/integration-testing/configs/x-python-java-stdio.json new file mode 100644 index 00000000..1801795e --- /dev/null +++ b/integration-testing/configs/x-python-java-stdio.json @@ -0,0 +1,110 @@ +{ + "name": "x-python-java-stdio", + "description": "python client -> java agent over stdio, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 67", + "client RESULT fail == 6" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status None", + "reason": "ToolCall's kind and status are closed Literals whose wrap validator (use_default_on_error) replaces an unknown value with None: the tool call surfaces with the raw status interop_future_status (and kind) dropped, so the value is lost rather than kept open", + "see": "peer:python-sdk schema.py:4983 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "PlanEntry.priority and status are closed Literals (schema.py:3542, :3547) and Plan.entries skips invalid items: the entry with interop_future_priority/interop_future_status is silently dropped and the plan surfaces empty", + "see": "peer:python-sdk schema.py:3578 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "ValidationError", + "reason": "PromptResponse.stopReason is a closed Literal with no lenient validator: a response with stopReason interop_future_stop fails the prompt call with a pydantic ValidationError (the connection survives)", + "see": "peer:python-sdk schema.py:3414 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so its fs read of slow.txt runs its full 10 s instead of ending -32800: the prompt ends late", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "agent.cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so the agent's cancelled fs/read_text_file is never answered -32800: the read handler answers the content after 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.unknown", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-ws-unstable.json b/integration-testing/configs/x-python-java-ws-unstable.json new file mode 100644 index 00000000..401f9712 --- /dev/null +++ b/integration-testing/configs/x-python-java-ws-unstable.json @@ -0,0 +1,58 @@ +{ + "name": "x-python-java-ws-unstable", + "description": "python client -> java agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-python-java-ws.json b/integration-testing/configs/x-python-java-ws.json new file mode 100644 index 00000000..1f5fbe94 --- /dev/null +++ b/integration-testing/configs/x-python-java-ws.json @@ -0,0 +1,144 @@ +{ + "name": "x-python-java-ws", + "description": "python client -> java agent over ws, stable profile, 72 steps (generated)", + "timeoutSec": 600, + "peers": [ + "python-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "python", + "role": "client", + "dir": "${ROOT}/programs/python", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" PY_SDK=\"${peer.python-sdk}\" PYTHONUNBUFFERED=\"1\" ${ROOT}/programs/python/launch/build.sh", + "run": "${ROOT}/programs/python/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "PY_SDK": "${peer.python-sdk}", + "PYTHONUNBUFFERED": "1" + }, + "timeoutSec": 480 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 64", + "client RESULT fail == 8" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status None", + "reason": "ToolCall's kind and status are closed Literals whose wrap validator (use_default_on_error) replaces an unknown value with None: the tool call surfaces with the raw status interop_future_status (and kind) dropped, so the value is lost rather than kept open", + "see": "peer:python-sdk schema.py:4983 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "PlanEntry.priority and status are closed Literals (schema.py:3542, :3547) and Plan.entries skips invalid items: the entry with interop_future_priority/interop_future_status is silently dropped and the plan surfaces empty", + "see": "peer:python-sdk schema.py:3578 [expectations/python.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "ValidationError", + "reason": "PromptResponse.stopReason is a closed Literal with no lenient validator: a response with stopReason interop_future_stop fails the prompt call with a pydantic ValidationError (the connection survives)", + "see": "peer:python-sdk schema.py:3414 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.client", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so its fs read of slow.txt runs its full 10 s instead of ending -32800: the prompt ends late", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "agent", + "step": "agent.cancel-request.agent", + "contains": "", + "reason": "The Python client routes no $/cancel_request, so the agent's cancelled fs/read_text_file is never answered -32800: the read handler answers the content after 10 s", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "cancel-request.unknown", + "contains": "no $/cancel_request API", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it", + "see": "peer:python-sdk meta.py:49 [expectations/python.json]" + }, + { + "process": "client", + "step": "big.update-1m", + "contains": "1009", + "reason": "create_websocket_stream connects with websockets' default max_size of 1 MiB and offers no way to raise it; a 1 MiB chunk plus its envelope exceeds it and the client closes the socket with 1009", + "see": "peer:python-sdk ws/client.py:98 [expectations/python.json]" + }, + { + "process": "client", + "step": "big.update-8m", + "contains": "1009", + "reason": "create_websocket_stream connects with websockets' default max_size of 1 MiB and offers no way to raise it; the client closes the socket with 1009", + "see": "peer:python-sdk ws/client.py:98 [expectations/python.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-http-unstable.json b/integration-testing/configs/x-rust-java-http-unstable.json new file mode 100644 index 00000000..13644481 --- /dev/null +++ b/integration-testing/configs/x-rust-java-http-unstable.json @@ -0,0 +1,65 @@ +{ + "name": "x-rust-java-http-unstable", + "description": "rust client -> java agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-http.json b/integration-testing/configs/x-rust-java-http.json new file mode 100644 index 00000000..060e6603 --- /dev/null +++ b/integration-testing/configs/x-rust-java-http.json @@ -0,0 +1,111 @@ +{ + "name": "x-rust-java-http", + "description": "rust client -> java agent over http, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 70", + "client RESULT fail == 3" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "surfaced without the raw values", + "reason": "ToolCall.status is a closed enum read with DefaultOnError, so the unknown status interop_future_status silently becomes the default (pending) instead of being kept; the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/tool_call.rs:49 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "surfaced without the raw values", + "reason": "Plan.entries is read with VecSkipError and PlanEntryPriority/PlanEntryStatus are closed enums, so the entry with unknown values is silently dropped (entries []); the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/plan.rs:38 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "StopReason is a closed enum: the PromptResponse with stopReason interop_future_stop fails to parse and the prompt fails with -32700 (the session stays usable)", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/agent.rs:3182 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-stdio-unstable.json b/integration-testing/configs/x-rust-java-stdio-unstable.json new file mode 100644 index 00000000..4353f8cb --- /dev/null +++ b/integration-testing/configs/x-rust-java-stdio-unstable.json @@ -0,0 +1,38 @@ +{ + "name": "x-rust-java-stdio-unstable", + "description": "rust client -> java agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-stdio.json b/integration-testing/configs/x-rust-java-stdio.json new file mode 100644 index 00000000..a880dc84 --- /dev/null +++ b/integration-testing/configs/x-rust-java-stdio.json @@ -0,0 +1,84 @@ +{ + "name": "x-rust-java-stdio", + "description": "rust client -> java agent over stdio, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 70", + "client RESULT fail == 3" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "surfaced without the raw values", + "reason": "ToolCall.status is a closed enum read with DefaultOnError, so the unknown status interop_future_status silently becomes the default (pending) instead of being kept; the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/tool_call.rs:49 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "surfaced without the raw values", + "reason": "Plan.entries is read with VecSkipError and PlanEntryPriority/PlanEntryStatus are closed enums, so the entry with unknown values is silently dropped (entries []); the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/plan.rs:38 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "StopReason is a closed enum: the PromptResponse with stopReason interop_future_stop fails to parse and the prompt fails with -32700 (the session stays usable)", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/agent.rs:3182 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-ws-unstable.json b/integration-testing/configs/x-rust-java-ws-unstable.json new file mode 100644 index 00000000..15ce5cc2 --- /dev/null +++ b/integration-testing/configs/x-rust-java-ws-unstable.json @@ -0,0 +1,58 @@ +{ + "name": "x-rust-java-ws-unstable", + "description": "rust client -> java agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-rust-java-ws.json b/integration-testing/configs/x-rust-java-ws.json new file mode 100644 index 00000000..0ddd6d3b --- /dev/null +++ b/integration-testing/configs/x-rust-java-ws.json @@ -0,0 +1,104 @@ +{ + "name": "x-rust-java-ws", + "description": "rust client -> java agent over ws, stable profile, 72 steps (generated)", + "timeoutSec": 600, + "peers": [ + "rust-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "rust", + "role": "client", + "dir": "${ROOT}/programs/rust", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" RUST_SDK=\"${peer.rust-sdk}\" CARGO_TARGET_DIR=\"${CACHE}/build/${peerKey.rust-sdk}\" ${ROOT}/programs/rust/launch/build.sh", + "run": "${ROOT}/programs/rust/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "RUST_SDK": "${peer.rust-sdk}", + "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" + }, + "timeoutSec": 480 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 69", + "client RESULT fail == 3" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "surfaced without the raw values", + "reason": "ToolCall.status is a closed enum read with DefaultOnError, so the unknown status interop_future_status silently becomes the default (pending) instead of being kept; the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/tool_call.rs:49 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "surfaced without the raw values", + "reason": "Plan.entries is read with VecSkipError and PlanEntryPriority/PlanEntryStatus are closed enums, so the entry with unknown values is silently dropped (entries []); the turn continues", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/plan.rs:38 [expectations/rust.json]" + }, + { + "process": "client", + "step": "enum.stop", + "contains": "-32700", + "reason": "StopReason is a closed enum: the PromptResponse with stopReason interop_future_stop fails to parse and the prompt fails with -32700 (the session stays usable)", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/agent.rs:3182 [expectations/rust.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-http-unstable.json b/integration-testing/configs/x-typescript-java-http-unstable.json new file mode 100644 index 00000000..152db67d --- /dev/null +++ b/integration-testing/configs/x-typescript-java-http-unstable.json @@ -0,0 +1,63 @@ +{ + "name": "x-typescript-java-http-unstable", + "description": "typescript client -> java agent over http, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-http.json b/integration-testing/configs/x-typescript-java-http.json new file mode 100644 index 00000000..640a8554 --- /dev/null +++ b/integration-testing/configs/x-typescript-java-http.json @@ -0,0 +1,102 @@ +{ + "name": "x-typescript-java-http", + "description": "typescript client -> java agent over http, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport http --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport http --url http://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,http.reconnect,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "forbiddenOutput": { + "agent": [ + " /acp HTTP/2.0 " + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", + "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202", + "\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202" + ] + }, + "checks": [ + "client RESULT pass == 71", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status undefined", + "reason": "zToolCall reads status (and kind, :2359) with defaultOnError over the closed zToolCallStatus union (:117), so an unknown status becomes undefined: the turn goes on but the raw value interop_future_status is lost", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2360 [expectations/typescript.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "zPlan reads entries with vecSkipError, and zPlanEntry's priority and status are closed unions (:2408-2409, :2381, :2393), so the entry with unknown values is dropped: the plan arrives empty", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2426 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-stdio-unstable.json b/integration-testing/configs/x-typescript-java-stdio-unstable.json new file mode 100644 index 00000000..0f81c70a --- /dev/null +++ b/integration-testing/configs/x-typescript-java-stdio-unstable.json @@ -0,0 +1,37 @@ +{ + "name": "x-typescript-java-stdio-unstable", + "description": "typescript client -> java agent over stdio, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-stdio.json b/integration-testing/configs/x-typescript-java-stdio.json new file mode 100644 index 00000000..6056cb75 --- /dev/null +++ b/integration-testing/configs/x-typescript-java-stdio.json @@ -0,0 +1,76 @@ +{ + "name": "x-typescript-java-stdio", + "description": "typescript client -> java agent over stdio, stable profile, 73 steps (generated)", + "timeoutSec": 605, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh && IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport stdio", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,stdio.eof-exit,conn.close", + "AGENT_CMD": "${ROOT}/programs/java/launch/agent.sh --transport stdio", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 485 + } + ], + "assertions": { + "requiredOutput": { + "client": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "checks": [ + "client RESULT pass == 71", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status undefined", + "reason": "zToolCall reads status (and kind, :2359) with defaultOnError over the closed zToolCallStatus union (:117), so an unknown status becomes undefined: the turn goes on but the raw value interop_future_status is lost", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2360 [expectations/typescript.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "zPlan reads entries with vecSkipError, and zPlanEntry's priority and status are closed unions (:2408-2409, :2381, :2393), so the entry with unknown values is dropped: the plan arrives empty", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2426 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-ws-unstable.json b/integration-testing/configs/x-typescript-java-ws-unstable.json new file mode 100644 index 00000000..8bda5333 --- /dev/null +++ b/integration-testing/configs/x-typescript-java-ws-unstable.json @@ -0,0 +1,56 @@ +{ + "name": "x-typescript-java-ws-unstable", + "description": "typescript client -> java agent over ws, unstable profile, 3 steps (generated)", + "timeoutSec": 255, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,session.fork,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 135 + } + ], + "assertions": { + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 3", + "client RESULT fail == 0" + ], + "expectedFailures": [], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/configs/x-typescript-java-ws.json b/integration-testing/configs/x-typescript-java-ws.json new file mode 100644 index 00000000..3395810e --- /dev/null +++ b/integration-testing/configs/x-typescript-java-ws.json @@ -0,0 +1,95 @@ +{ + "name": "x-typescript-java-ws", + "description": "typescript client -> java agent over ws, stable profile, 72 steps (generated)", + "timeoutSec": 600, + "peers": [ + "typescript-sdk" + ], + "processes": [ + { + "name": "agent", + "language": "java", + "role": "server", + "dir": "${ROOT}/programs/java", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" ${ROOT}/programs/java/launch/build.sh", + "run": "${ROOT}/programs/java/launch/agent.sh --transport ws --port ${PORT}", + "env": { + "TS_SDK": "${peer.typescript-sdk}" + }, + "ready": { + "line": "READY", + "timeoutSec": 60 + } + }, + { + "name": "client", + "language": "typescript", + "role": "client", + "dir": "${ROOT}/programs/node", + "build": "IT_ROOT=\"${ROOT}\" MVNW=\"${MVNW}\" ACP_VERSION=\"${ACP_VERSION}\" CACHE=\"${CACHE}\" TS_SDK=\"${peer.typescript-sdk}\" ${ROOT}/programs/node/launch/build.sh", + "run": "${ROOT}/programs/node/launch/client.sh --transport ws --url ws://127.0.0.1:${PORT}/acp", + "env": { + "STEPS": "init.initialize,init.agent-capabilities,init.client-capabilities,init.auth-methods,init.agent-info,init.config-boolean,auth.authenticate,auth.logout,auth.logout-capability,auth.terminal,session.new,session.load,session.load-replay,session.resume,session.list,session.close,session.delete,session.multi,update.agent_message_chunk,update.user_message_chunk,update.agent_thought_chunk,update.tool_call,update.tool_call_update,update.tool_call-name,update.plan,update.available_commands_update,update.current_mode_update,update.config_option_update,update.session_info_update,update.usage_update,update.unknown,stop.max_tokens,stop.refusal,stop.max_turn_requests,enum.tool_call,enum.plan,enum.stop,enum.audience,mode.set,config.on-new,config.select,config.boolean,config.grouped,perm.selected,perm.cancelled,fs.write,fs.read,fs.read-range,fs.read-missing,term.run,term.kill,elicit.form,elicit.complete,cancel.prompt,cancel.prompt-while-cancelling,cancel.grace,cancel.max-duration,cancel-request.client,cancel-request.agent,cancel-request.unknown,ext.agent-request,ext.agent-notification,ext.client-request,ext.client-notification,meta.prompt,meta.permission,error.method-not-found,big.prompt-1m,big.update-1m,big.prompt-8m,big.update-8m,conn.close", + "TS_SDK": "${peer.typescript-sdk}" + }, + "timeoutSec": 480 + } + ], + "assertions": { + "requiredOutput": { + "agent": [ + "STEP agent.init.client-capabilities PASS", + "STEP agent.auth.authenticate PASS", + "STEP agent.session.close PASS", + "STEP agent.enum.audience PASS", + "STEP agent.mode.set PASS", + "STEP agent.config.grouped PASS", + "STEP agent.perm.selected PASS", + "STEP agent.perm.cancelled PASS", + "STEP agent.fs.write PASS", + "STEP agent.fs.read PASS", + "STEP agent.fs.read-range PASS", + "STEP agent.fs.read-missing PASS", + "STEP agent.term.run PASS", + "STEP agent.term.kill PASS", + "STEP agent.elicit.form PASS", + "STEP agent.cancel.prompt PASS", + "STEP agent.cancel-request.client PASS", + "STEP agent.cancel-request.agent PASS", + "STEP agent.ext.agent-request PASS", + "STEP agent.meta.permission PASS" + ] + }, + "requiredPatterns": { + "agent": [ + "\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\"" + ] + }, + "checks": [ + "client RESULT pass == 70", + "client RESULT fail == 2" + ], + "expectedFailures": [ + { + "process": "client", + "step": "enum.tool_call", + "contains": "status undefined", + "reason": "zToolCall reads status (and kind, :2359) with defaultOnError over the closed zToolCallStatus union (:117), so an unknown status becomes undefined: the turn goes on but the raw value interop_future_status is lost", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2360 [expectations/typescript.json]" + }, + { + "process": "client", + "step": "enum.plan", + "contains": "entries []", + "reason": "zPlan reads entries with vecSkipError, and zPlanEntry's priority and status are closed unions (:2408-2409, :2381, :2393), so the entry with unknown values is dropped: the plan arrives empty", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2426 [expectations/typescript.json]" + } + ], + "report": { + "client": [ + "TIMEOUT" + ] + } + }, + "comment": "GENERATED by GenConfigs.java from steps.json, matrix.json and expectations/*.json; do not edit, regenerate." +} diff --git a/integration-testing/expectations/README.md b/integration-testing/expectations/README.md new file mode 100644 index 00000000..ea2a4faf --- /dev/null +++ b/integration-testing/expectations/README.md @@ -0,0 +1,58 @@ +# Expected failures + +Known failures of generated cells (`configs/x-*.json`, `configs/self-*.json`) are declared here, +never in the configs. `GenConfigs.java` copies every matching entry into the cell's +`assertions.expectedFailures`, so the runner's existing rules apply unchanged: + +- a `STEP FAIL` that is not declared fails the scenario; +- a declared failure must print `STEP FAIL` and its line must contain `contains`; +- **a declared failure that passes fails the scenario.** When the gap closes (a Phase B item lands, + a peer fixes a bug), the same change must delete the entry. + +## Files + +One file per language package, owned by that package: `java.json`, `typescript.json`, `rust.json`, +`python.json`, `kotlin.json`, `raw.json`. A file declares the failures caused by its own SDK or +program. `java.json` holds the Java gaps (`see: "P"`); a peer file holds that peer's bugs +(`see: "peer:..."`) and the spec disagreements its pairs show (`see: "spec:..."`). `raw.json`'s +`expectations` array stays empty (the raw driver is no matrix language); its `conformance` array +is read only by `programs/raw/gen_conf.py`, for the `conf-java-*` scenarios. + +```json +{ + "comment": "optional", + "expectations": [ + { "step": "update.tool_call-name", "when": { "client": "java" }, "contains": "", + "see": "P3", "reason": "Java ToolCall has no name (AcpSchema:1555)" }, + { "step": "agent.perm.cancelled", "when": { "agent": "python", "transport": ["http", "ws"] }, + "contains": "outcome", "see": "peer:python-sdk connection.py:239", "reason": "..." } + ] +} +``` + +| Field | Required | Meaning | +|---|---|---| +| `step` | yes | A step id from `steps.json`, or `agent.` for the agent-side assertion of a step that has one (`pass.agent`). | +| `when` | no | The cells it applies to. Keys `client`, `agent` (language ids from `matrix.json`), `transport` (`stdio`, `http`, `ws`), `profile` (`stable`, `unstable`). Each value is a string or an array; omitted keys match anything. | +| `contains` | yes | A substring the `STEP ... FAIL` line must contain; `""` accepts any failure. Prefer something specific (`-32601`, `404`, `TIMEOUT`, `unknown-step`). | +| `see` | yes | Where the fix lives: a Phase B item (`P1` ... `P9`, `P1b`), `peer: ` (e.g. `peer:python-sdk http/server.py:260`), or `spec:` for a spec contradiction. | +| `reason` | yes | One line: what fails and why. | + +The generator rejects unknown fields, unknown step ids, unknown `when` values, a malformed `see`, +and an entry that matches no cell of the full matrix. + +**When several entries match the same step in a cell**, the most specific wins: more `when` keys +first, then the entry from the client's language file, then the agent's, then the earlier entry. +An agent-side expectation (`agent.`) also removes that step's required +`STEP agent. PASS` line; a client-side expectation removes it too, since a failed client step +usually means the agent never got that far. + +In a cell, a client-side entry is checked on the `client` process; an `agent.` entry on the +`agent` process (HTTP and WebSocket) or on `client` (stdio, where the client relays the agent's +stderr). + +## Phase B + +Each Phase B commit deletes its `see: "Pn"` entries, regenerates, and shows `run-all.sh` green: the +gate for P3 is "`run-all.sh` green with zero `P3` entries left". The steps each item flips are listed +in `steps.json` (`phaseB`). diff --git a/integration-testing/expectations/java.json b/integration-testing/expectations/java.json new file mode 100644 index 00000000..8f3df8c5 --- /dev/null +++ b/integration-testing/expectations/java.json @@ -0,0 +1,4 @@ +{ + "comment": "Expected failures caused by the Java SDK (programs/java): the Java client (when.client java) and the Java agent (when.agent java). Each entry names the Phase B item that closes it; that item's commit deletes its entries. Client entries come first, so in a Java<->Java cell the client's (more specific) contains wins.", + "expectations": [] +} diff --git a/integration-testing/expectations/kotlin.json b/integration-testing/expectations/kotlin.json new file mode 100644 index 00000000..d77f8105 --- /dev/null +++ b/integration-testing/expectations/kotlin.json @@ -0,0 +1,86 @@ +{ + "comment": "Expected failures caused by the Kotlin SDK (kotlin-sdk master, 3af219a) or the Kotlin programs (programs/kotlin). Citations are relative to the kotlin-sdk root; M = acp-model/src/commonMain/kotlin/com/agentclientprotocol/model.", + "expectations": [ + { + "step": "init.config-boolean", + "when": { + "client": "kotlin" + }, + "contains": "cannot advertise it", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84", + "reason": "Kotlin ClientCapabilities has no session field, so the Kotlin client cannot advertise session.configOptions.boolean" + }, + { + "step": "config.boolean", + "when": { + "client": "kotlin" + }, + "contains": "", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:84", + "reason": "the Kotlin client cannot advertise session.configOptions.boolean, so no agent offers the boolean option verbose to it" + }, + { + "step": "auth.terminal", + "when": { + "client": "kotlin" + }, + "contains": "cannot advertise auth.terminal", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Capabilities.kt:53", + "reason": "Kotlin AuthCapabilities has only _meta, so the Kotlin client cannot advertise auth.terminal and no agent lists its terminal auth method" + }, + { + "step": "update.tool_call-name", + "when": { + "client": "kotlin" + }, + "contains": "no name field", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/SessionUpdate.kt:131", + "reason": "Kotlin v1 SessionUpdate.ToolCall has no name field, so the client cannot read the tool call name" + }, + { + "step": "enum.tool_call", + "when": { + "client": "kotlin" + }, + "contains": "the update was dropped", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/ToolCall.kt:39", + "reason": "ToolCallStatus (and ToolKind, ToolCall.kt:18) is a closed Kotlin enum and ACPJson has no coerceInputValues, so a tool_call with an unknown status fails to decode and the SDK drops the whole update (the turn goes on)" + }, + { + "step": "enum.plan", + "when": { + "client": "kotlin" + }, + "contains": "the update was dropped", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Plan.kt:22", + "reason": "PlanEntryPriority and PlanEntryStatus (Plan.kt:36) are closed Kotlin enums, so a plan with unknown values fails to decode and the SDK drops the update" + }, + { + "step": "enum.stop", + "when": { + "client": "kotlin" + }, + "contains": "StopReason does not contain element with name 'interop_future_stop'", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301", + "reason": "StopReason is a closed Kotlin enum: the PromptResponse with stopReason interop_future_stop fails to decode and the prompt call fails (-32700); the session still answers the next prompt" + }, + { + "step": "enum.stop", + "when": { + "agent": "kotlin" + }, + "contains": "cannot send stopReason interop_future_stop", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Requests.kt:301", + "reason": "A Kotlin agent answers a prompt only with a PromptResponse, whose StopReason is a closed enum, so it cannot send an unknown stop reason; the program answers -32603 instead" + }, + { + "step": "enum.audience", + "when": { + "agent": "kotlin" + }, + "contains": "Role does not contain element with name 'interop_future_role'", + "see": "peer:kotlin-sdk acp-model/src/commonMain/kotlin/com/agentclientprotocol/model/Types.kt:157", + "reason": "Role is a closed Kotlin enum, so the SDK fails to decode a prompt whose annotations.audience holds an unknown Role and answers -32700 before the handler runs" + } + ] +} diff --git a/integration-testing/expectations/python.json b/integration-testing/expectations/python.json new file mode 100644 index 00000000..7d1e6b43 --- /dev/null +++ b/integration-testing/expectations/python.json @@ -0,0 +1,193 @@ +{ + "comment": "Expected failures caused by the Python SDK (python-sdk, paths relative to src/acp/) or the Python interop programs. Owned by programs/python. See README.md in this directory.", + "expectations": [ + { + "step": "session.resume", + "when": { + "agent": "python", + "transport": "http" + }, + "contains": "-32601", + "see": "peer:python-sdk http/server.py:270", + "reason": "The SDK's HTTP server builds each AgentSideConnection without use_unstable_protocol, so session/resume (unstable in the Python SDK) is answered method-not-found although the agent implements it" + }, + { + "step": "session.resume", + "when": { + "agent": "python", + "transport": "ws" + }, + "contains": "-32601", + "see": "peer:python-sdk ws/server.py:55", + "reason": "handle_websocket builds the AgentSideConnection without use_unstable_protocol, so session/resume is answered method-not-found although the agent implements it" + }, + { + "step": "session.close", + "when": { + "agent": "python", + "transport": "http" + }, + "contains": "-32601", + "see": "peer:python-sdk http/server.py:270", + "reason": "The SDK's HTTP server never enables the unstable router: session/close is answered method-not-found" + }, + { + "step": "session.close", + "when": { + "agent": "python", + "transport": "ws" + }, + "contains": "-32601", + "see": "peer:python-sdk ws/server.py:55", + "reason": "The SDK's WebSocket server never enables the unstable router: session/close is answered method-not-found" + }, + { + "step": "session.fork", + "when": { + "agent": "python", + "transport": "http" + }, + "contains": "-32601", + "see": "peer:python-sdk http/server.py:270", + "reason": "The SDK's HTTP server never enables the unstable router: session/fork is answered method-not-found" + }, + { + "step": "session.fork", + "when": { + "agent": "python", + "transport": "ws" + }, + "contains": "-32601", + "see": "peer:python-sdk ws/server.py:55", + "reason": "The SDK's WebSocket server never enables the unstable router: session/fork is answered method-not-found" + }, + { + "step": "cancel-request.client", + "when": { + "client": "python" + }, + "contains": "no $/cancel_request API", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it" + }, + { + "step": "cancel-request.unknown", + "when": { + "client": "python" + }, + "contains": "no $/cancel_request API", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python SDK has only the $/cancel_request method-name constant: no API to send it" + }, + { + "step": "cancel-request.client", + "when": { + "agent": "python" + }, + "contains": "", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python SDK routes no $/cancel_request (only the constant exists), so the #slow handler never sees it and the prompt runs its full 10 s" + }, + { + "step": "cancel-request.agent", + "when": { + "agent": "python" + }, + "contains": "cancel-request", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python agent cannot send $/cancel_request (no API), so it answers the chunk \"cancel-request unsupported\" instead of \"cancel-request sent\"" + }, + { + "step": "agent.cancel-request.agent", + "when": { + "agent": "python" + }, + "contains": "$/cancel_request", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python agent cannot send $/cancel_request for its pending fs/read_text_file (no API)" + }, + { + "step": "agent.cancel-request.agent", + "when": { + "client": "python" + }, + "contains": "", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python client routes no $/cancel_request, so the agent's cancelled fs/read_text_file is never answered -32800: the read handler answers the content after 10 s" + }, + { + "step": "cancel-request.agent", + "when": { + "client": "python" + }, + "contains": "", + "see": "peer:python-sdk meta.py:49", + "reason": "The Python client routes no $/cancel_request, so its fs read of slow.txt runs its full 10 s instead of ending -32800: the prompt ends late" + }, + { + "step": "big.update-1m", + "when": { + "client": "python", + "transport": "ws" + }, + "contains": "1009", + "see": "peer:python-sdk ws/client.py:98", + "reason": "create_websocket_stream connects with websockets' default max_size of 1 MiB and offers no way to raise it; a 1 MiB chunk plus its envelope exceeds it and the client closes the socket with 1009" + }, + { + "step": "big.update-8m", + "when": { + "client": "python", + "transport": "ws" + }, + "contains": "1009", + "see": "peer:python-sdk ws/client.py:98", + "reason": "create_websocket_stream connects with websockets' default max_size of 1 MiB and offers no way to raise it; the client closes the socket with 1009" + }, + { + "step": "enum.tool_call", + "when": { + "client": "python" + }, + "contains": "status None", + "see": "peer:python-sdk schema.py:4983", + "reason": "ToolCall's kind and status are closed Literals whose wrap validator (use_default_on_error) replaces an unknown value with None: the tool call surfaces with the raw status interop_future_status (and kind) dropped, so the value is lost rather than kept open" + }, + { + "step": "enum.plan", + "when": { + "client": "python" + }, + "contains": "entries []", + "see": "peer:python-sdk schema.py:3578", + "reason": "PlanEntry.priority and status are closed Literals (schema.py:3542, :3547) and Plan.entries skips invalid items: the entry with interop_future_priority/interop_future_status is silently dropped and the plan surfaces empty" + }, + { + "step": "enum.stop", + "when": { + "client": "python" + }, + "contains": "ValidationError", + "see": "peer:python-sdk schema.py:3414", + "reason": "PromptResponse.stopReason is a closed Literal with no lenient validator: a response with stopReason interop_future_stop fails the prompt call with a pydantic ValidationError (the connection survives)" + }, + { + "step": "enum.audience", + "when": { + "agent": "python" + }, + "contains": "audience: user\"", + "see": "peer:python-sdk schema.py:2648", + "reason": "Annotations.audience is List[Literal[\"assistant\", \"user\"]] with skip_invalid_items: the agent receives the prompt but the unknown Role interop_future_role is silently dropped, so it reads [user] and chunks \"audience: user\"" + }, + { + "step": "agent.enum.audience", + "when": { + "agent": "python" + }, + "contains": "['user']", + "see": "peer:python-sdk schema.py:2648", + "reason": "Annotations.audience skips invalid items: the Python agent's handler sees audience ['user'], interop_future_role dropped before it" + } + ] +} diff --git a/integration-testing/expectations/raw.json b/integration-testing/expectations/raw.json new file mode 100644 index 00000000..793dde81 --- /dev/null +++ b/integration-testing/expectations/raw.json @@ -0,0 +1,16 @@ +{ + "comment": "The raw conformance driver (programs/raw). \"expectations\" is read by GenConfigs.java like every expectations file; the raw driver is no matrix language, so it holds nothing. \"conformance\" holds the expected failures of the conf-java-* scenarios, which programs/raw/gen_conf.py generates (see its header for the fields).", + "expectations": [], + "conformance": [ + { + "case": "raw.http.unknown-session-stream", + "when": { + "side": "agent", + "transport": "http" + }, + "contains": "held open", + "see": "spec:docs/rfds/streamable-http-websocket-transport.mdx:268", + "reason": "RFD gap B: the RFD answers an unknown Acp-Session-Id with 404 (:268, :301) but its reconnect flow opens the session stream before session/load (:237-244); the Java server keeps it open as a provisional stream." + } + ] +} diff --git a/integration-testing/expectations/rust.json b/integration-testing/expectations/rust.json new file mode 100644 index 00000000..d8a48f6d --- /dev/null +++ b/integration-testing/expectations/rust.json @@ -0,0 +1,59 @@ +{ + "comment": "Expected failures caused by the Rust SDK or the Rust interop programs (programs/rust). See README.md in this directory.", + "expectations": [ + { + "step": "enum.tool_call", + "when": { + "client": "rust" + }, + "contains": "surfaced without the raw values", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/tool_call.rs:49", + "reason": "ToolCall.status is a closed enum read with DefaultOnError, so the unknown status interop_future_status silently becomes the default (pending) instead of being kept; the turn continues" + }, + { + "step": "enum.plan", + "when": { + "client": "rust" + }, + "contains": "surfaced without the raw values", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/plan.rs:38", + "reason": "Plan.entries is read with VecSkipError and PlanEntryPriority/PlanEntryStatus are closed enums, so the entry with unknown values is silently dropped (entries []); the turn continues" + }, + { + "step": "enum.stop", + "when": { + "client": "rust" + }, + "contains": "-32700", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/agent.rs:3182", + "reason": "StopReason is a closed enum: the PromptResponse with stopReason interop_future_stop fails to parse and the prompt fails with -32700 (the session stays usable)" + }, + { + "step": "enum.stop", + "when": { + "agent": "rust" + }, + "contains": "-32700", + "see": "peer:rust-sdk src/agent-client-protocol/src/jsonrpc.rs:4777", + "reason": "The Rust agent cannot answer an unknown stopReason: a typed prompt Responder re-parses even an erase_to_json() answer into PromptResponse (closed StopReason, schema src/v1/agent.rs:3182), so the SDK answers -32700 instead" + }, + { + "step": "enum.audience", + "when": { + "agent": "rust" + }, + "contains": "audience: user", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum, so the agent silently drops interop_future_role and echoes \"audience: user\"" + }, + { + "step": "agent.enum.audience", + "when": { + "agent": "rust" + }, + "contains": "[\"user\"]", + "see": "peer:rust-sdk agent-client-protocol-schema 1.9.1 (pinned =1.9.1 in rust-sdk Cargo.toml:38) src/v1/content.rs:568", + "reason": "Annotations.audience is read with VecSkipError over the closed Role enum: the unknown Role is silently dropped, so the agent reads [\"user\"]" + } + ] +} diff --git a/integration-testing/expectations/typescript.json b/integration-testing/expectations/typescript.json new file mode 100644 index 00000000..15169b53 --- /dev/null +++ b/integration-testing/expectations/typescript.json @@ -0,0 +1,41 @@ +{ + "comment": "Expected failures of cells with a TypeScript client or agent (programs/node). See README.md in this directory.", + "expectations": [ + { + "step": "enum.tool_call", + "when": { + "client": "typescript" + }, + "contains": "status undefined", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2360", + "reason": "zToolCall reads status (and kind, :2359) with defaultOnError over the closed zToolCallStatus union (:117), so an unknown status becomes undefined: the turn goes on but the raw value interop_future_status is lost" + }, + { + "step": "enum.plan", + "when": { + "client": "typescript" + }, + "contains": "entries []", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:2426", + "reason": "zPlan reads entries with vecSkipError, and zPlanEntry's priority and status are closed unions (:2408-2409, :2381, :2393), so the entry with unknown values is dropped: the plan arrives empty" + }, + { + "step": "enum.audience", + "when": { + "agent": "typescript" + }, + "contains": "chunks [\"audience: user\"]", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127), so the TypeScript agent's handler sees [\"user\"]: the unknown Role interop_future_role is dropped, not kept" + }, + { + "step": "agent.enum.audience", + "when": { + "agent": "typescript" + }, + "contains": "audience [\"user\"]", + "see": "peer:typescript-sdk src/schema/zod.gen.ts:133", + "reason": "zAnnotations reads audience with vecSkipError over the closed zRole union (:127): the unknown Role is dropped before the handler sees the prompt" + } + ] +} diff --git a/integration-testing/jbang-lib/Peers.java b/integration-testing/jbang-lib/Peers.java index d72fa082..302721b2 100644 --- a/integration-testing/jbang-lib/Peers.java +++ b/integration-testing/jbang-lib/Peers.java @@ -32,10 +32,29 @@ public static Map load(Path file) throws Exception { }); } - public static Checkout prepare(String name, Peer peer, String ref, Path cacheDir, Path logDir) throws Exception { + /** + * Clone or fetch, check out and build the peer at {@code ref}, then point + * {@code .cache/peers/} at the checkout. With {@code prepared}, a run that must not touch + * shared state (parallel runs): resolve the ref locally and require the checkout to be at it + * and built already; no git fetch, checkout, build or symlink change. + */ + public static Checkout prepare(String name, Peer peer, String ref, Path cacheDir, Path logDir, boolean prepared) + throws Exception { Path peersDir = cacheDir.resolve("peers"); Files.createDirectories(peersDir); Path dir = peersDir.resolve(name + "@" + ref.replaceAll("[^A-Za-z0-9._-]", "_")); + if (prepared) { + if (!Files.exists(dir.resolve(".git"))) { + throw new IllegalStateException(name + " @ " + ref + " was not prepared (no checkout at " + dir + ")"); + } + String sha = resolve(dir, ref); + Path stamp = dir.resolve(".harness-built"); + if (!Files.exists(stamp) || !Files.readString(stamp).trim().equals(sha)) { + throw new IllegalStateException(name + " @ " + ref + " was not prepared (not built at " + sha + ")"); + } + System.out.println(" " + name + " @ " + ref + " (" + sha.substring(0, 12) + ") prepared"); + return new Checkout(name, ref, sha, dir.toAbsolutePath()); + } Path log = logDir.resolve("peer-" + name + ".log"); String sha; try (OutputStream out = Files.newOutputStream(log)) { diff --git a/integration-testing/matrix.json b/integration-testing/matrix.json new file mode 100644 index 00000000..44085da6 --- /dev/null +++ b/integration-testing/matrix.json @@ -0,0 +1,99 @@ +{ + "comment": "Input of GenConfigs.java: languages, pairs, transports, profiles and transport facts. Generated cells are configs/x---[-unstable].json (and self--[-unstable].json with --self). A language's cells are generated only while it is enabled; a language package enables its row when its programs land. See README.md, Contracts.", + "version": 1, + "stepSelection": "all", + "transports": ["stdio", "http", "ws"], + "profiles": { + "stable": { "suffix": "" }, + "unstable": { "suffix": "-unstable" } + }, + "languages": { + "java": { + "enabled": true, + "dir": "programs/java", + "peers": [], + "env": {}, + "transports": ["stdio", "http", "ws"], + "readyTimeoutSec": 60 + }, + "typescript": { + "enabled": true, + "dir": "programs/node", + "peers": ["typescript-sdk"], + "env": { "TS_SDK": "${peer.typescript-sdk}" }, + "transports": ["stdio", "http", "ws"], + "readyTimeoutSec": 60 + }, + "rust": { + "enabled": true, + "dir": "programs/rust", + "peers": ["rust-sdk"], + "env": { "RUST_SDK": "${peer.rust-sdk}", "CARGO_TARGET_DIR": "${CACHE}/build/${peerKey.rust-sdk}" }, + "transports": ["stdio", "http", "ws"], + "readyTimeoutSec": 60 + }, + "python": { + "enabled": true, + "dir": "programs/python", + "peers": ["python-sdk"], + "env": { "PY_SDK": "${peer.python-sdk}", "PYTHONUNBUFFERED": "1" }, + "transports": ["stdio", "http", "ws"], + "readyTimeoutSec": 60 + }, + "kotlin": { + "enabled": true, + "dir": "programs/kotlin", + "peers": ["kotlin-sdk"], + "env": { "KOTLIN_SDK": "${peer.kotlin-sdk}" }, + "transports": ["stdio", "ws"], + "readyTimeoutSec": 90 + } + }, + "pairs": [ + ["java", "java"], + ["java", "typescript"], ["typescript", "java"], + ["java", "rust"], ["rust", "java"], + ["java", "python"], ["python", "java"], + ["java", "kotlin"], ["kotlin", "java"] + ], + "self": ["typescript", "rust", "python", "kotlin"], + "facts": [ + { + "comment": "Java client -> Java agent on HTTP negotiates h2c on every request (carried over from the retired interop-java-java).", + "when": { "client": "java", "agent": "java", "transport": "http" }, + "requiredOutput": { "client": ["negotiated HTTP_2"] }, + "forbiddenOutput": { "client": ["negotiated HTTP_1_1"], "agent": [" /acp HTTP/1.1 "] }, + "requiredPatterns": { "agent": ["\\[http\\] POST /acp HTTP/2\\.0 .*-> 200", "\\[http\\] DELETE /acp HTTP/2\\.0 .*-> 202"] } + }, + { + "comment": "The Java client -> TypeScript or Rust agent on HTTP: neither server speaks h2c, so the Java client's probe falls back to HTTP/1.1 and every request is HTTP/1.1; DELETE is answered 202 (carried over from interop-ts-server and interop-rust-server).", + "when": { "client": "java", "agent": ["typescript", "rust"], "transport": "http" }, + "requiredOutput": { "client": ["negotiated HTTP_1_1"] }, + "forbiddenOutput": { "client": ["negotiated HTTP_2"] }, + "requiredPatterns": { "agent": ["\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202"] } + }, + { + "comment": "The Java client -> Python agent on HTTP negotiates h2c; DELETE is answered 202 (carried over from interop-python-server).", + "when": { "client": "java", "agent": "python", "transport": "http" }, + "requiredOutput": { "client": ["negotiated HTTP_2"] }, + "forbiddenOutput": { "client": ["negotiated HTTP_1_1"] }, + "requiredPatterns": { "agent": ["\\[http\\] POST /acp HTTP/2(\\.0)? .*-> 200", "\\[http\\] DELETE /acp HTTP/2(\\.0)? .*-> 202"] } + }, + { + "comment": "TypeScript, Rust and Python clients -> Java agent on HTTP stay on HTTP/1.1 (no h2c upgrade) (carried over from interop-{ts,rust,python}-client).", + "when": { "client": ["typescript", "rust", "python"], "agent": "java", "transport": "http" }, + "forbiddenOutput": { "agent": [" /acp HTTP/2.0 "] }, + "requiredPatterns": { "agent": ["\\[http\\] POST /acp HTTP/1\\.1 .*-> 200", "\\[http\\] DELETE /acp HTTP/1\\.1 .*-> 202"] } + }, + { + "comment": "Any client closing an HTTP connection to the Java agent sends DELETE, answered 202.", + "when": { "agent": "java", "transport": "http" }, + "requiredPatterns": { "agent": ["\\[http\\] DELETE /acp HTTP/\\S+ .*-> 202"] } + }, + { + "comment": "A WebSocket connection to the Java agent is a GET upgrade answered 101.", + "when": { "agent": "java", "transport": "ws" }, + "requiredPatterns": { "agent": ["\\[http\\] GET /acp HTTP/1\\.1 -> 101 .*upgrade=\"websocket\""] } + } + ] +} diff --git a/integration-testing/peers.json b/integration-testing/peers.json index 9e3ab76e..ad262d86 100644 --- a/integration-testing/peers.json +++ b/integration-testing/peers.json @@ -13,5 +13,10 @@ "url": "https://github.com/agentclientprotocol/python-sdk.git", "ref": "main", "build": "rm -rf .venv && python3 -m venv .venv && .venv/bin/pip install --quiet --disable-pip-version-check -e '.[http]' 'hypercorn==0.18.0'" + }, + "kotlin-sdk": { + "url": "https://github.com/agentclientprotocol/kotlin-sdk.git", + "ref": "master", + "build": "./gradlew --quiet help" } } diff --git a/integration-testing/programs/framework/src/main/java/interop/framework/SmokeAgent.java b/integration-testing/programs/framework/src/main/java/interop/framework/SmokeAgent.java new file mode 100644 index 00000000..b794d58f --- /dev/null +++ b/integration-testing/programs/framework/src/main/java/interop/framework/SmokeAgent.java @@ -0,0 +1,234 @@ +package interop.framework; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import com.agentclientprotocol.sdk.agent.SyncPromptContext; +import com.agentclientprotocol.sdk.annotation.CloseSession; +import com.agentclientprotocol.sdk.annotation.DeleteSession; +import com.agentclientprotocol.sdk.annotation.ExtRequest; +import com.agentclientprotocol.sdk.annotation.ListSessions; +import com.agentclientprotocol.sdk.annotation.LoadSession; +import com.agentclientprotocol.sdk.annotation.NewSession; +import com.agentclientprotocol.sdk.annotation.Prompt; +import com.agentclientprotocol.sdk.annotation.ResumeSession; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.spec.AcpSchema; + +/** + * The handler methods of the framework-hosted smoke agent (integration-testing/smoke.json), shared + * by every framework program: each framework's program declares a subclass that carries + * {@code @AcpAgent(name = "interop--agent")} and that framework's bean annotation, so the + * handlers are written once and every framework discovers, derives and serves them its own way. + * + *

    There is deliberately no {@code @Initialize} method: the initialize response, capabilities + * included, is derived from the annotations. {@code @LoadSession}, {@code @ListSessions}, + * {@code @ResumeSession}, {@code @CloseSession} and {@code @DeleteSession} make it advertise + * {@code loadSession} and {@code sessionCapabilities.list/resume/close/delete}, which + * {@code init.agent-capabilities} checks. + * + *

    Prompt directives (steps.json "directives"): plain text is echoed as the chunks + * {@code "echo: "} and the text; {@code #permission allow}, {@code #elicit form}, {@code #slow}, + * {@code #stop }, {@code #len } and {@code #big } as the catalogue says; anything + * else that starts with {@code #} is answered -32602. The extension request {@code _interop/ping} + * answers {@code {"pong": 1}}. Agent-side assertions go to stderr as {@code STEP agent.} lines. + * + *

    One instance serves every connection, so sessions live in a concurrent map: a session created + * on one HTTP connection can be loaded on another ({@code http.reconnect}). + */ +public abstract class SmokeAgent { + + private final Map sessions = new ConcurrentHashMap<>(); + + @NewSession + public AcpSchema.NewSessionResponse newSession(AcpSchema.NewSessionRequest request) { + String id = "smoke-" + UUID.randomUUID(); + sessions.put(id, Objects.requireNonNullElse(request.cwd(), "")); + return new AcpSchema.NewSessionResponse(id, null, null, null); + } + + @LoadSession + public AcpSchema.LoadSessionResponse loadSession(AcpSchema.LoadSessionRequest request) { + known(request.sessionId()); + return new AcpSchema.LoadSessionResponse(null, null, null); + } + + @ResumeSession + public AcpSchema.ResumeSessionResponse resumeSession(AcpSchema.ResumeSessionRequest request) { + known(request.sessionId()); + return new AcpSchema.ResumeSessionResponse(null, null, null); + } + + @ListSessions + public AcpSchema.ListSessionsResponse listSessions(AcpSchema.ListSessionsRequest request) { + List out = new ArrayList<>(); + sessions.forEach((id, cwd) -> { + if (request.cwd() == null || request.cwd().equals(cwd)) { + out.add(new AcpSchema.SessionInfo(id, cwd, null, null, null, null)); + } + }); + return new AcpSchema.ListSessionsResponse(out, null, null); + } + + @CloseSession + public AcpSchema.CloseSessionResponse closeSession(AcpSchema.CloseSessionRequest request) { + known(request.sessionId()); + return new AcpSchema.CloseSessionResponse(null); + } + + @DeleteSession + public AcpSchema.DeleteSessionResponse deleteSession(AcpSchema.DeleteSessionRequest request) { + sessions.remove(request.sessionId()); + return new AcpSchema.DeleteSessionResponse(null); + } + + @ExtRequest("_interop/ping") + public Map ping(Map params) { + return Map.of("pong", 1); + } + + @Prompt + public AcpSchema.PromptResponse prompt(AcpSchema.PromptRequest request, SyncPromptContext context, + NegotiatedCapabilities client) { + String text = text(request); + if (!text.startsWith("#")) { + context.sendMessage("echo: "); + context.sendMessage(text); + return AcpSchema.PromptResponse.endTurn(); + } + int space = text.indexOf(' '); + String name = space < 0 ? text : text.substring(0, space); + String args = space < 0 ? "" : text.substring(space + 1); + return switch (name) { + case "#permission" -> "allow".equals(args) ? permission(context) : unknown(text); + case "#elicit" -> "form".equals(args) ? elicitForm(context, client) : unknown(text); + case "#slow" -> args.isEmpty() ? slow(context) : unknown(text); + case "#stop" -> { + context.sendMessage("stop"); + yield new AcpSchema.PromptResponse(AcpSchema.StopReason.of(args)); + } + case "#len" -> { + context.sendMessage("len=" + args.length()); + yield AcpSchema.PromptResponse.endTurn(); + } + case "#big" -> { + context.sendMessage("x".repeat(Integer.parseInt(args.strip()))); + yield AcpSchema.PromptResponse.endTurn(); + } + default -> unknown(text); + }; + } + + /** {@code #permission allow}: perm.selected. */ + private static AcpSchema.PromptResponse permission(SyncPromptContext context) { + long t0 = System.nanoTime(); + AcpSchema.RequestPermissionResponse response = context.client().requestPermission(new AcpSchema.RequestPermissionRequest( + context.getSessionId(), + new AcpSchema.ToolCallUpdate("perm-1", "interop permission", AcpSchema.ToolKind.EDIT, + AcpSchema.ToolCallStatus.PENDING), + List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE), + new AcpSchema.PermissionOption("reject", "Reject", AcpSchema.PermissionOptionKind.REJECT_ONCE)), + null)); + String said = response.outcome() instanceof AcpSchema.PermissionSelected selected + ? "selected " + selected.optionId() : "cancelled"; + step("perm.selected", said.equals("selected allow"), t0, "outcome " + said); + context.sendMessage("permission: " + said); + return said.equals("cancelled") ? AcpSchema.PromptResponse.cancelled() : AcpSchema.PromptResponse.endTurn(); + } + + /** {@code #elicit form}: elicit.form, only when the client advertised form elicitation. */ + private static AcpSchema.PromptResponse elicitForm(SyncPromptContext context, NegotiatedCapabilities client) { + long t0 = System.nanoTime(); + if (!client.supportsElicitationForm()) { + step("elicit.form", false, t0, "the client did not advertise elicitation.form"); + context.sendMessage("elicit error capability"); + return AcpSchema.PromptResponse.endTurn(); + } + Map properties = Map.of("name", + new AcpSchema.StringPropertySchema("string", null, null, null, null, null, null, null, null, null, + null)); + AcpSchema.CreateElicitationResponse response = context.client().createElicitation(AcpSchema.CreateElicitationRequest + .form(context.getSessionId(), "interop form", new AcpSchema.ElicitationSchema(properties, List.of("name")))); + String action = String.valueOf(response.action()); + Object name = response.content() == null ? null : response.content().get("name"); + step("elicit.form", "accept".equals(action) && "interop".equals(name), t0, + "action " + action + " content " + response.content()); + context.sendMessage("elicit: " + action + " " + json(response.content())); + return AcpSchema.PromptResponse.endTurn(); + } + + /** + * {@code #slow}: the chunk "tick" every 100 ms; on session/cancel it answers cancelled at once, + * uncancelled it answers end_turn after 10 s. + */ + private static AcpSchema.PromptResponse slow(SyncPromptContext context) { + long t0 = System.nanoTime(); + while (System.nanoTime() - t0 < 10_000_000_000L) { + if (context.isCancelled()) { + step("cancel.prompt", true, t0, "session/cancel arrived for the running session"); + return AcpSchema.PromptResponse.cancelled(); + } + context.sendMessage("tick"); + try { + Thread.sleep(100); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + step("cancel.prompt", context.isCancelled(), t0, "interrupted; cancelled=" + context.isCancelled()); + return AcpSchema.PromptResponse.cancelled(); + } + } + return AcpSchema.PromptResponse.endTurn(); + } + + private static AcpSchema.PromptResponse unknown(String text) { + int space = text.indexOf(' '); + throw new AcpProtocolException(AcpErrorCodes.INVALID_PARAMS, + "unknown directive: " + (space < 0 ? text : text.substring(0, space))); + } + + private void known(String sessionId) { + if (!sessions.containsKey(sessionId)) { + throw new AcpProtocolException(AcpErrorCodes.RESOURCE_NOT_FOUND, "unknown session " + sessionId); + } + } + + private static String text(AcpSchema.PromptRequest request) { + for (AcpSchema.ContentBlock block : request.prompt()) { + if (block instanceof AcpSchema.TextContent content) { + return content.text(); + } + } + return ""; + } + + /** The JSON of a flat map of strings, numbers and booleans (the elicitation answer). */ + static String json(Map map) { + if (map == null) { + return "null"; + } + StringBuilder out = new StringBuilder("{"); + map.forEach((k, v) -> { + if (out.length() > 1) { + out.append(','); + } + out.append('"').append(k).append("\":"); + out.append(v instanceof String s ? "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" : v); + }); + return out.append('}').toString(); + } + + /** An agent-side assertion, {@code STEP agent. PASS|FAIL ( ms) -> }, on stderr. */ + static void step(String id, boolean pass, long t0, String detail) { + System.err.println("STEP agent." + id + (pass ? " PASS" : " FAIL") + " (" + (System.nanoTime() - t0) / 1_000_000 + + " ms) -> " + detail.replace('\n', ' ').replace('\r', ' ')); + System.err.flush(); + } + +} diff --git a/integration-testing/programs/framework/src/main/java/interop/framework/SmokeClient.java b/integration-testing/programs/framework/src/main/java/interop/framework/SmokeClient.java new file mode 100644 index 00000000..390b0da2 --- /dev/null +++ b/integration-testing/programs/framework/src/main/java/interop/framework/SmokeClient.java @@ -0,0 +1,309 @@ +package interop.framework; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * The client side of the framework smoke cells, shared by every framework program. The framework + * builds the ACP client bean from its configuration (transport URI and advertised capabilities); + * this class supplies the handlers through {@link #customize(AcpClient.AsyncSpec)}, which each + * framework program calls from an {@code AcpClientCustomizer} bean, and then drives the client + * bean through the catalogue steps in {@code STEPS} with {@link #run(AcpAsyncClient, List)}. + * + *

    Output follows the client contract of integration-testing/README.md: one + * {@code STEP PASS|FAIL ( ms) -> } line per step, then + * {@code RESULT pass=.. fail=.. updates_total=.. upd_=..}. + */ +public final class SmokeClient { + + static final Duration T = Duration.ofMillis(Long.parseLong(env("STEP_TIMEOUT_MS", "15000"))); + + static final Duration UPDATE_GRACE = Duration.ofMillis(1000); + + static final String FS_READ_CONTENT = "line1\nline2\nline3\n"; + + private final Map> chunks = new ConcurrentHashMap<>(); + + private final Map permissionRequests = new ConcurrentHashMap<>(); + + private final AtomicInteger updatesTotal = new AtomicInteger(); + + private final Map updatesByKind = new ConcurrentHashMap<>(); + + private AcpAsyncClient client; + + private AcpSchema.InitializeResponse initResponse; + + private Path dir; + + private int pass; + + private int fail; + + /** Registers the handlers the steps need; called from the framework's customizer bean. */ + public void customize(AcpClient.AsyncSpec spec) { + spec.sessionUpdateHandler(n -> { + updatesTotal.incrementAndGet(); + String kind = n.update() == null ? "other" + : n.update().getClass().getSimpleName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase(); + updatesByKind.computeIfAbsent(kind, k -> new AtomicInteger()).incrementAndGet(); + if (n.update() instanceof AcpSchema.AgentMessageChunk chunk + && chunk.content() instanceof AcpSchema.TextContent text) { + chunks(n.sessionId()).add(text.text()); + } + return Mono.empty(); + }); + spec.requestPermissionHandler(request -> { + permissionRequests.computeIfAbsent(request.sessionId(), k -> new AtomicInteger()).incrementAndGet(); + String option = request.options().stream() + .filter(o -> o.kind() == AcpSchema.PermissionOptionKind.ALLOW_ONCE) + .findFirst() + .orElse(request.options().get(0)) + .optionId(); + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected(option), null)); + }); + spec.readTextFileHandler(request -> Mono.fromCallable(() -> { + List lines = Files.readAllLines(Path.of(request.path()), StandardCharsets.UTF_8); + int from = request.line() == null ? 0 : Math.max(0, request.line() - 1); + int to = request.limit() == null ? lines.size() : Math.min(lines.size(), from + request.limit()); + StringBuilder out = new StringBuilder(); + for (String line : lines.subList(Math.min(from, lines.size()), to)) { + out.append(line).append('\n'); + } + return new AcpSchema.ReadTextFileResponse(out.toString()); + })); + spec.createElicitationHandler(request -> Mono.just(new AcpSchema.CreateElicitationResponse( + AcpSchema.ElicitationAction.ACCEPT, Map.of("name", "interop"), null))); + } + + /** Runs the steps on the framework's client bean and prints the RESULT line. */ + public void run(AcpAsyncClient client, List steps) throws Exception { + this.client = client; + this.dir = Files.createTempDirectory("smoke-client-"); + for (String id : steps) { + step(id); + } + StringBuilder result = new StringBuilder("RESULT pass=" + pass + " fail=" + fail + " updates_total=" + + updatesTotal.get()); + new TreeMap<>(updatesByKind).forEach((k, v) -> result.append(" upd_").append(k).append('=').append(v.get())); + System.out.println(result); + System.out.flush(); + } + + private void step(String id) { + long t0 = System.nanoTime(); + String outcome; + String detail; + try { + detail = switch (id) { + case "init.initialize" -> initInitialize(); + case "init.agent-info" -> initAgentInfo(); + case "session.new" -> "sessionId=" + newSession(); + case "update.agent_message_chunk" -> agentMessageChunk(); + case "perm.selected" -> permSelected(); + case "fs.read" -> fsRead(); + case "elicit.form" -> elicitForm(); + case "cancel.prompt" -> cancelPrompt(); + case "ext.client-request" -> extClientRequest(); + case "conn.close" -> connClose(); + default -> throw new StepFailure("unknown-step"); + }; + outcome = "PASS"; + pass++; + } + catch (Throwable e) { + detail = e instanceof StepFailure ? e.getMessage() : timeout(e) ? "TIMEOUT " + e : e.toString(); + outcome = "FAIL"; + fail++; + } + long ms = "unknown-step".equals(detail) ? 0 : (System.nanoTime() - t0) / 1_000_000; + System.out.println("STEP " + id + " " + outcome + " (" + ms + " ms) -> " + abbreviate(detail.replace('\n', ' '))); + System.out.flush(); + } + + private String initInitialize() { + initResponse = block(client.initialize()); + check(initResponse.protocolVersion() != null && initResponse.protocolVersion() == 1, + "protocolVersion " + initResponse.protocolVersion()); + return "protocolVersion=1 agentInfo=" + initResponse.agentInfo(); + } + + private String initAgentInfo() { + check(initResponse != null, "no initialize response: init.initialize did not pass"); + AcpSchema.Implementation info = initResponse.agentInfo(); + check(info != null && info.name() != null && info.name().startsWith("interop-"), "agentInfo " + info); + return "agentInfo " + info.name(); + } + + private String newSession() { + String sid = block(client.newSession(new AcpSchema.NewSessionRequest(dir.toString(), List.of()))).sessionId(); + check(sid != null && !sid.isEmpty(), "empty sessionId"); + return sid; + } + + private AcpSchema.PromptResponse prompt(String sid, String text) { + return block(client.prompt(AcpSchema.PromptRequest.text(sid, text))); + } + + private String agentMessageChunk() throws InterruptedException { + String sid = newSession(); + AcpSchema.PromptResponse r = prompt(sid, "hello"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> String.join("", chunks(sid)).equals("echo: hello"), UPDATE_GRACE, + "chunks " + chunks(sid) + " do not spell \"echo: hello\""); + return "end_turn; chunks spell \"echo: hello\""; + } + + private String permSelected() throws InterruptedException { + String sid = newSession(); + AcpSchema.PromptResponse r = prompt(sid, "#permission allow"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> chunks(sid).contains("permission: selected allow"), UPDATE_GRACE, + "no chunk \"permission: selected allow\" in " + chunks(sid)); + int asked = permissionRequests.getOrDefault(sid, new AtomicInteger()).get(); + check(asked == 1, asked + " permission requests, expected 1"); + return "one permission request; selected allow; end_turn"; + } + + private String fsRead() throws Exception { + Path file = dir.resolve("fs-read.txt"); + Files.writeString(file, FS_READ_CONTENT, StandardCharsets.UTF_8); + String sid = newSession(); + AcpSchema.PromptResponse r = prompt(sid, "#fs read " + file); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> chunks(sid).contains(FS_READ_CONTENT), UPDATE_GRACE, "chunks " + chunks(sid)); + return "the agent read the file through the client's fs/read_text_file handler"; + } + + private String elicitForm() throws InterruptedException { + String sid = newSession(); + AcpSchema.PromptResponse r = prompt(sid, "#elicit form"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> chunks(sid).stream().anyMatch(s -> s.startsWith("elicit: ")), UPDATE_GRACE, + "chunks " + chunks(sid)); + String chunk = chunks(sid).stream().filter(s -> s.startsWith("elicit: ")).findFirst().orElseThrow(); + check(chunk.replace(" ", "").equals("elicit:accept{\"name\":\"interop\"}"), "chunk " + chunk); + return "chunk " + chunk; + } + + private String cancelPrompt() throws Exception { + String sid = newSession(); + CompletableFuture slow = client.prompt(AcpSchema.PromptRequest.text(sid, "#slow")) + .toFuture(); + await(() -> chunks(sid).contains("tick"), T, "no tick"); + client.cancel(new AcpSchema.CancelNotification(sid)).block(T); + long t0 = System.nanoTime(); + AcpSchema.PromptResponse r = slow.get(5, TimeUnit.SECONDS); + check(AcpSchema.StopReason.CANCELLED.equals(r.stopReason()), "stopReason " + r.stopReason()); + return "stopReason cancelled " + (System.nanoTime() - t0) / 1_000_000 + " ms after the cancel"; + } + + private String extClientRequest() { + Object result = block(client.sendExtRequest("_interop/ping", Map.of("n", 1))); + check(result instanceof Map m && m.size() == 1 && m.get("pong") instanceof Number n + && n.longValue() == 1, "result " + result); + return "result " + result; + } + + private String connClose() { + client.closeGracefully().block(T); + return "closed"; + } + + private List chunks(String sid) { + return chunks.computeIfAbsent(sid, k -> new CopyOnWriteArrayList<>()); + } + + private static V block(Mono mono) { + V value = mono.block(T); + check(value != null, "empty response"); + return value; + } + + private static void await(BooleanSupplier condition, Duration within, String failure) throws InterruptedException { + long deadline = System.nanoTime() + within.toNanos(); + while (!condition.getAsBoolean()) { + if (System.nanoTime() > deadline) { + throw new StepFailure(failure); + } + Thread.sleep(20); + } + } + + private static void check(boolean condition, String failure) { + if (!condition) { + throw new StepFailure(failure); + } + } + + private static boolean timeout(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof java.util.concurrent.TimeoutException || String.valueOf(t.getMessage()).contains("Timeout")) { + return true; + } + } + return false; + } + + private static String abbreviate(String s) { + return s.length() > 300 ? s.substring(0, 300) + "... (" + s.length() + " chars)" : s; + } + + static String env(String name, String fallback) { + String v = System.getenv(name); + return v == null || v.isBlank() ? fallback : v; + } + + /** Parses {@code --transport http|ws --url }; exits 2 on anything else. */ + public static String[] args(String[] args) { + String transport = null; + String url = null; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--transport" -> transport = i + 1 < args.length ? args[++i] : null; + case "--url" -> url = i + 1 < args.length ? args[++i] : null; + default -> transport = null; + } + } + if (url == null || !("http".equals(transport) || "ws".equals(transport))) { + System.err.println("usage: client.sh --transport http|ws --url (env STEPS)"); + System.exit(2); + } + return new String[] { transport, url }; + } + + /** The STEPS environment variable, split. */ + public static List steps() { + String steps = System.getenv("STEPS"); + if (steps == null || steps.isBlank()) { + System.err.println("STEPS is not set"); + System.exit(2); + } + return List.of(steps.split(",")); + } + + static final class StepFailure extends RuntimeException { + + StepFailure(String message) { + super(message, null, false, false); + } + + } + +} diff --git a/integration-testing/programs/java/launch/agent.sh b/integration-testing/programs/java/launch/agent.sh new file mode 100755 index 00000000..d5db3888 --- /dev/null +++ b/integration-testing/programs/java/launch/agent.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Java interop agent: --transport stdio | --transport http|ws --port . +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself: on stdio, +# stdout is the protocol stream. JAVA_OPTS, if set, is passed to the JVM. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.Agent "$@" diff --git a/integration-testing/programs/java/launch/build.sh b/integration-testing/programs/java/launch/build.sh new file mode 100755 index 00000000..d424b2b5 --- /dev/null +++ b/integration-testing/programs/java/launch/build.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Builds the Java interop programs against the SDK installed from this checkout. +# Launcher contract: integration-testing/README.md, "Contracts". Env: MVNW, ACP_VERSION. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +exec "${MVNW:?MVNW is not set}" -q -B -Dacp.version="${ACP_VERSION:?ACP_VERSION is not set}" \ + compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt diff --git a/integration-testing/programs/java/launch/client.sh b/integration-testing/programs/java/launch/client.sh new file mode 100755 index 00000000..a454b984 --- /dev/null +++ b/integration-testing/programs/java/launch/client.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Java interop client: --transport stdio (env AGENT_CMD) | --transport http|ws --url . +# Env STEPS (required), STEP_TIMEOUT_MS (default 15000). Launcher contract: integration-testing/README.md, +# "Contracts". JAVA_OPTS, if set, is passed to the JVM. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.Client "$@" diff --git a/integration-testing/programs/java/src/main/java/interop/Agent.java b/integration-testing/programs/java/src/main/java/interop/Agent.java new file mode 100644 index 00000000..97e525ea --- /dev/null +++ b/integration-testing/programs/java/src/main/java/interop/Agent.java @@ -0,0 +1,1019 @@ +package interop; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.agentclientprotocol.sdk.agent.AcpAgent; +import com.agentclientprotocol.sdk.agent.AcpAgentFactory; +import com.agentclientprotocol.sdk.agent.AcpAsyncAgent; +import com.agentclientprotocol.sdk.agent.PromptContext; +import com.agentclientprotocol.sdk.agent.transport.StdioAcpAgentTransport; +import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpAgentTransport; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.error.AcpErrorCodes; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.RequestCancellation; +import com.agentclientprotocol.sdk.spec.PromptTimeouts; +import org.eclipse.jetty.server.CustomRequestLog; +import org.eclipse.jetty.server.RequestLog; +import org.eclipse.jetty.server.Server; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The Java interop agent of the step catalogue (integration-testing/steps.json), driven by + * directives in the prompt text. One binary serves every scenario. + * + *

    + *   launch/agent.sh --transport stdio                 ACP on stdin/stdout; exits when stdin ends
    + *   launch/agent.sh --transport http|ws --port <p>    listens on /acp (HTTP and WebSocket upgrade on
    + *                                                     the same endpoint); prints READY <port> on stdout
    + * 
    + * + * Every diagnostic, and every {@code STEP agent.} line, goes to stderr: on stdio, stdout is the + * protocol stream. + * + *

    + * Sessions live in one table for the whole process, so a session created on one HTTP connection + * can be loaded on another ({@code http.reconnect}). The prompt timeouts are the SDK's + * {@link PromptTimeouts}: the cancel grace period is 1 s ({@code cancel.grace}; env + * {@code INTEROP_CANCEL_GRACE_MS} overrides it, the SDK default is 60 s) and a prompt may run at + * most 8 s ({@code cancel.max-duration}; env {@code INTEROP_MAX_PROMPT_MS}), so a {@code #hang} + * nobody cancels is answered -32800 by the SDK. No other directive runs that long against a Java + * agent: an uncancelled {@code #slow} (10 s) is only sent by a client that also cancels it. + *

    + * + *

    + * The unknown update is an {@code AcpSchema.UnknownSessionUpdate}, which writes the type and + * fields it is given. + *

    + */ +public class Agent { + + static final AcpJsonMapper JSON = AcpJsonMapper.createDefault(); + + static final AtomicInteger sessionCounter = new AtomicInteger(); + + /** Every session this process created, across connections. */ + static final Map SESSIONS = new ConcurrentHashMap<>(); + + static final Duration CANCEL_GRACE = Duration.ofMillis(Long.parseLong(env("INTEROP_CANCEL_GRACE_MS", "1000"))); + + static final Duration MAX_PROMPT = Duration.ofMillis(Long.parseLong(env("INTEROP_MAX_PROMPT_MS", "8000"))); + + /** The values the v1 schema does not define (fixtures.unknownEnums). */ + static final String FUTURE_STATUS = "interop_future_status"; + + static final List UNKNOWN_AUDIENCE = List.of("user", "interop_future_role"); + + static final String FS_READ_CONTENT = "line1\nline2\nline3\n"; + + public static void main(String[] args) throws Exception { + String transport = null; + int port = 0; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--transport" -> transport = value(args, ++i); + case "--port" -> port = Integer.parseInt(value(args, ++i)); + default -> usage("unknown argument " + args[i]); + } + } + if (transport == null) { + usage("--transport is required"); + } + log("prompt timeouts: " + new PromptTimeouts(CANCEL_GRACE, MAX_PROMPT) + " (SDK defaults " + + PromptTimeouts.DEFAULTS + ")"); + switch (transport) { + case "stdio" -> runStdio(); + case "http", "ws" -> runHttp(port); + default -> usage("unknown transport " + transport); + } + } + + static String value(String[] args, int i) { + if (i >= args.length) { + usage("missing value for " + args[i - 1]); + } + return args[i]; + } + + static void usage(String problem) { + System.err.println("agent: " + problem); + System.err.println("usage: agent --transport stdio | --transport http|ws --port "); + System.exit(2); + } + + static void runStdio() { + AcpAsyncAgent agent = build(new StdioAcpAgentTransport(JSON)); + agent.start().block(); + log("stdio agent started"); + agent.awaitTermination().block(); + log("stdin closed; exiting"); + System.exit(0); + } + + static void runHttp(int port) throws Exception { + // One agent per connection; the WebSocket upgrade is served on the same endpoint. + AcpAgentFactory factory = AcpAgentFactory.async(Agent::build); + StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(port, + StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH, JSON, factory); + server.start().block(); + attachRequestLog(server); + log("listening http://127.0.0.1:" + server.getPort() + "/acp (HTTP and WebSocket)"); + System.out.println("READY " + server.getPort()); + System.out.flush(); + server.awaitTermination().block(); + } + + // ---------------------------------------------------------------- fixtures + + static AcpSchema.SessionModeState modes(String current) { + return new AcpSchema.SessionModeState(current, List.of(new AcpSchema.SessionMode("interop-mode-a", "Mode A", null), + new AcpSchema.SessionMode("interop-mode-b", "Mode B", null))); + } + + static List configOptions(String model) { + return configOptions(model, null); + } + + /** The model option, and the boolean verbose option when {@code verbose} is not null. */ + static List configOptions(String model, Boolean verbose) { + List options = new ArrayList<>(); + options.add(new AcpSchema.SessionConfigSelect("model", "Model", model, + List.of(new AcpSchema.SessionConfigSelectOption("model-a", "Model A"), + new AcpSchema.SessionConfigSelectOption("model-b", "Model B")))); + if (verbose != null) { + options.add(new AcpSchema.SessionConfigBoolean("verbose", "Verbose", verbose)); + } + return options; + } + + /** Whether the client advertised session.configOptions.boolean: only then is verbose offered. */ + static boolean booleanOptions(AcpSchema.InitializeRequest init) { + AcpSchema.ClientCapabilities caps = init == null ? null : init.clientCapabilities(); + return caps != null && caps.session() != null && caps.session().configOptions() != null + && caps.session().configOptions().booleanOptions() != null; + } + + static List configOptions(SessionState s, AcpSchema.InitializeRequest init) { + List options = configOptions(s.model, booleanOptions(init) ? s.verbose : null); + if (s.grouped) { + options.add(groupedOption(s.effort)); + } + return options; + } + + /** fixtures.groupedConfigOption: the select effort, its options in the groups fast and deep. */ + static AcpSchema.SessionConfigSelect groupedOption(String effort) { + return new AcpSchema.SessionConfigSelect("effort", "Effort", effort, AcpSchema.SessionConfigSelectOptions.grouped( + List.of(new AcpSchema.SessionConfigSelectGroup("fast", "Fast", + List.of(new AcpSchema.SessionConfigSelectOption("effort-low", "Low"))), + new AcpSchema.SessionConfigSelectGroup("deep", "Deep", + List.of(new AcpSchema.SessionConfigSelectOption("effort-medium", "Medium"), + new AcpSchema.SessionConfigSelectOption("effort-high", "High")))))); + } + + /** The state of one session, shared by every connection of this process. */ + static final class SessionState { + + final String id; + + final String cwd; + + /** Replayed on session/load: per plain-text turn, the user chunk and the agent chunks. */ + final List history = new CopyOnWriteArrayList<>(); + + volatile boolean closed; + + volatile String mode = "interop-mode-a"; + + volatile String model = "model-a"; + + volatile boolean verbose; + + /** Whether #config grouped added fixtures.groupedConfigOption, and its value. */ + volatile boolean grouped; + + volatile String effort = "effort-low"; + + /** The running turn, or null. */ + final AtomicReference turn = new AtomicReference<>(); + + SessionState(String id, String cwd) { + this.id = id; + this.cwd = cwd; + } + + } + + /** One running prompt, as the handler sees it: when (and why) it was asked to stop. */ + static final class Turn { + + volatile long cancelledAtNanos; + + volatile boolean closed; + + boolean cancelled() { + return this.cancelledAtNanos != 0; + } + + void cancel(boolean byClose) { + if (byClose) { + this.closed = true; + } + if (this.cancelledAtNanos == 0) { + this.cancelledAtNanos = System.nanoTime(); + } + } + + } + + // ---------------------------------------------------------------- the agent + + /** Builds one agent for one connection. */ + static AcpAsyncAgent build(AcpAgentTransport transport) { + AtomicReference init = new AtomicReference<>(); + AtomicReference self = new AtomicReference<>(); + AtomicReference lastExtNotification = new AtomicReference<>("none"); + Connection connection = new Connection(self, lastExtNotification); + AcpAsyncAgent agent = AcpAgent.async(transport) + .cancelGracePeriod(CANCEL_GRACE) + .maxPromptDuration(MAX_PROMPT) + .initializeHandler(r -> { + init.set(r); + log("[agent] initialize " + r); + return Mono.just(new AcpSchema.InitializeResponse(1, + new AcpSchema.AgentCapabilities(true, + new AcpSchema.SessionCapabilities(Map.of(), Map.of(), Map.of(), Map.of(), null, null), + new AcpSchema.McpCapabilities(false, false), new AcpSchema.PromptCapabilities(false, false, false), + AcpSchema.AgentAuthCapabilities.withLogout(), null, null), + authMethods(r.clientCapabilities()), + new AcpSchema.Implementation("interop-java-agent", "1"), null)); + }) + .authenticateHandler(r -> { + long t0 = System.nanoTime(); + boolean ok = "interop-auth".equals(r.methodId()); + step("auth.authenticate", ok, t0, "methodId " + r.methodId()); + return ok ? Mono.just(new AcpSchema.AuthenticateResponse()) + : Mono.error(new AcpProtocolException(-32602, "unknown auth method " + r.methodId())); + }) + .logoutHandler(r -> Mono.just(new AcpSchema.LogoutResponse())) + .newSessionHandler(r -> { + String id = "java-sess-" + sessionCounter.incrementAndGet(); + SessionState created = new SessionState(id, r.cwd()); + SESSIONS.put(id, created); + log("[agent] session/new " + id + " cwd " + r.cwd()); + return Mono.just( + new AcpSchema.NewSessionResponse(id, modes("interop-mode-a"), configOptions(created, init.get()))); + }) + .loadSessionHandler(r -> known(r.sessionId()).flatMap(s -> { + log("[agent] session/load " + s.id + ": replaying " + s.history.size() + " updates"); + return Flux.fromIterable(s.history) + .concatMap(u -> self.get().sendSessionUpdate(s.id, u)) + .then(Mono.fromSupplier( + () -> new AcpSchema.LoadSessionResponse(modes(s.mode), configOptions(s, init.get())))); + })) + .resumeSessionHandler(r -> known(r.sessionId()).map(s -> { + log("[agent] session/resume " + s.id); + return new AcpSchema.ResumeSessionResponse(modes(s.mode), configOptions(s, init.get())); + })) + .listSessionsHandler(r -> { + List out = new ArrayList<>(); + for (SessionState s : SESSIONS.values()) { + if (!s.closed && (r.cwd() == null || r.cwd().equals(s.cwd))) { + out.add(new AcpSchema.SessionInfo(s.id, s.cwd)); + } + } + return Mono.just(new AcpSchema.ListSessionsResponse(out)); + }) + .closeSessionHandler(r -> { + long t0 = System.nanoTime(); + SessionState s = SESSIONS.get(r.sessionId()); + if (s == null) { + step("session.close", false, t0, "close named an unknown session " + r.sessionId()); + return Mono.error(new AcpProtocolException(-32602, "unknown session " + r.sessionId())); + } + s.closed = true; + Turn turn = s.turn.get(); + if (turn != null) { + turn.cancel(true); + } + step("session.close", turn != null, t0, + turn != null ? "closed " + s.id + "; its running turn was cancelled" : "closed " + s.id + " with no running turn"); + return Mono.just(new AcpSchema.CloseSessionResponse()); + }) + .deleteSessionHandler(r -> { + SESSIONS.remove(r.sessionId()); + return Mono.just(new AcpSchema.DeleteSessionResponse()); + }) + .forkSessionHandler(r -> known(r.sessionId()).map(s -> { + String id = "java-sess-" + sessionCounter.incrementAndGet(); + SessionState fork = new SessionState(id, r.cwd()); + fork.history.addAll(s.history); + SESSIONS.put(id, fork); + return new AcpSchema.ForkSessionResponse(id, modes(s.mode)); + })) + .setSessionModeHandler(r -> known(r.sessionId()).flatMap(s -> { + long t0 = System.nanoTime(); + boolean knownMode = r.modeId().equals("interop-mode-a") || r.modeId().equals("interop-mode-b"); + step("mode.set", knownMode && r.modeId().equals("interop-mode-b"), t0, "set_mode " + r.modeId()); + if (!knownMode) { + return Mono.error(new AcpProtocolException(-32602, "unknown mode " + r.modeId())); + } + s.mode = r.modeId(); + return Mono.just(new AcpSchema.SetSessionModeResponse()); + })) + .setSessionConfigOptionHandler(r -> known(r.sessionId()).flatMap(s -> { + if ("model".equals(r.configId()) && ("model-a".equals(r.value()) || "model-b".equals(r.value()))) { + s.model = (String) r.value(); + return Mono.just(new AcpSchema.SetSessionConfigOptionResponse(configOptions(s, init.get()))); + } + if ("effort".equals(r.configId()) && s.grouped && r.value() instanceof String value && groupedOption(s.effort) + .options() + .allOptions() + .stream() + .anyMatch(o -> o.value().equals(value))) { + long t0 = System.nanoTime(); + s.effort = value; + step("config.grouped", "effort-high".equals(value), t0, "effort set to " + value); + return Mono.just(new AcpSchema.SetSessionConfigOptionResponse(configOptions(s, init.get()))); + } + if ("verbose".equals(r.configId()) && "boolean".equals(r.type()) && r.value() instanceof Boolean on + && booleanOptions(init.get())) { + s.verbose = on; + return Mono.just(new AcpSchema.SetSessionConfigOptionResponse(configOptions(s, init.get()))); + } + return Mono.error(new AcpProtocolException(-32602, + "unknown config option " + r.configId() + "=" + r.value() + + " (verbose needs a boolean value and session.configOptions.boolean)")); + })) + .cancelHandler(n -> { + SessionState s = SESSIONS.get(n.sessionId()); + Turn turn = s == null ? null : s.turn.get(); + log("[agent] session/cancel " + n.sessionId() + (turn == null ? " (no running turn)" : "")); + if (turn != null) { + turn.cancel(false); + } + return Mono.empty(); + }) + .extRequestHandler(EXT_METHOD, params -> { + log("[agent] extension request " + EXT_METHOD + " " + params); + return Mono.just(EXT_RESULT); + }) + .extNotificationHandler(EXT_NOTIFICATION, params -> { + log("[agent] extension notification " + EXT_NOTIFICATION + " " + params); + lastExtNotification.set(EXT_NOTIFICATION); + return Mono.empty(); + }) + .promptHandler((request, context) -> prompt(request, context, init.get(), connection)) + .build(); + self.set(agent); + return agent; + } + + /** The interop auth method, plus the terminal one iff the client advertised auth.terminal. */ + static List authMethods(AcpSchema.ClientCapabilities caps) { + List methods = new ArrayList<>(); + methods.add(new AcpSchema.AuthMethodAgent("interop-auth", "Interop auth", "Accepts any authenticate call")); + if (caps != null && caps.auth() != null && Boolean.TRUE.equals(caps.auth().terminal())) { + methods.add(new AcpSchema.AuthMethodTerminal("interop-terminal-auth", "Interop terminal auth", + List.of("--login"), null)); + } + return methods; + } + + static Mono known(String sessionId) { + SessionState s = sessionId == null ? null : SESSIONS.get(sessionId); + if (s == null) { + return Mono.error(new AcpProtocolException(-32602, "unknown session " + sessionId)); + } + if (s.closed) { + return Mono.error(new AcpProtocolException(-32602, "session " + sessionId + " is closed")); + } + return Mono.just(s); + } + + /** What a prompt can reach of its connection: the agent, and the last extension notification it received. */ + record Connection(AtomicReference agent, AtomicReference lastExtNotification) { + } + + static Mono prompt(AcpSchema.PromptRequest request, PromptContext context, + AcpSchema.InitializeRequest init, Connection connection) { + String text = request.text(); + log("[agent] session/prompt " + request.sessionId() + ": " + abbreviate(text)); + return known(request.sessionId()).flatMap(s -> { + Turn turn = new Turn(); + s.turn.set(turn); + return directive(s, turn, request, context, init, connection).doFinally(sig -> s.turn.compareAndSet(turn, null)); + }); + } + + static Mono directive(SessionState s, Turn turn, AcpSchema.PromptRequest request, + PromptContext context, AcpSchema.InitializeRequest init, Connection connection) { + String text = request.text(); + if (!text.startsWith("#")) { + s.history.add(new AcpSchema.UserMessageChunk(new AcpSchema.TextContent(text))); + s.history.add(chunk("echo: ")); + s.history.add(chunk(text)); + return context.sendMessage("echo: ").then(context.sendMessage(text)).thenReturn(endTurn()); + } + String[] words = text.split(" "); + String name = words[0]; + String sub = words.length > 1 ? words[1] : ""; + AcpSchema.ClientCapabilities caps = init == null ? null : init.clientCapabilities(); + return switch (name) { + case "#permission" -> switch (sub) { + case "allow" -> words.length == 2 ? permission(s.id, context, false, false) + : words.length == 3 && words[2].equals("meta") ? permission(s.id, context, false, true) + : unknown(text); + case "hold" -> permission(s.id, context, true, false); + default -> unknown(text); + }; + case "#fs" -> switch (sub) { + case "write" -> fsWrite(s.id, context, caps, rest(text, 2)); + case "read" -> fsRead(s.id, context, caps, words, "fs.read"); + case "read-range" -> fsRead(s.id, context, caps, words, "fs.read-range"); + case "read-missing" -> fsRead(s.id, context, caps, words, "fs.read-missing"); + case "read-slow" -> readSlow(s.id, context, words); + default -> unknown(text); + }; + case "#emit" -> emit(context, words); + case "#stop" -> stop(context, sub); + case "#slow" -> slow(context, turn, words); + case "#hang" -> Mono.never(); + case "#terminal" -> switch (sub) { + case "run" -> terminalRun(s.id, context, caps, words); + case "kill" -> terminalKill(s.id, context, caps, words); + default -> unknown(text); + }; + case "#elicit" -> switch (sub) { + case "form" -> elicitForm(s.id, context, caps); + case "url" -> elicitUrl(s.id, context, caps); + default -> unknown(text); + }; + case "#ext" -> switch (sub) { + case "request" -> extRequest(context, connection.agent().get(), words.length > 2 ? words[2] : EXT_METHOD); + case "notify" -> connection.agent() + .get() + .sendExtNotification(words.length > 2 ? words[2] : EXT_NOTIFICATION, EXT_PARAMS) + .then(context.sendMessage("ext notified")) + .thenReturn(endTurn()); + case "last-notification" -> context.sendMessage("ext last: " + connection.lastExtNotification().get()) + .thenReturn(endTurn()); + default -> unknown(text); + }; + case "#meta" -> meta(context, request); + case "#echo-caps" -> echoCaps(context, init); + case "#enum" -> switch (sub) { + case "tool_call", "plan", "stop" -> unknownEnum(context, sub); + case "audience" -> audience(context, request); + default -> unknown(text); + }; + case "#config" -> "grouped".equals(sub) ? configGrouped(s, context, init) : unknown(text); + case "#len" -> context.sendMessage("len=" + rest(text, 1).length()).thenReturn(endTurn()); + case "#big" -> big(context, sub); + default -> unknown(text); + }; + } + + static Mono unknown(String text) { + return Mono.error(new AcpProtocolException(-32602, "unknown directive: " + text.split(" ")[0])); + } + + static AcpSchema.PromptResponse endTurn() { + return AcpSchema.PromptResponse.endTurn(); + } + + static AcpSchema.PromptResponse cancelled() { + return new AcpSchema.PromptResponse(AcpSchema.StopReason.CANCELLED); + } + + static AcpSchema.AgentMessageChunk chunk(String text) { + return new AcpSchema.AgentMessageChunk(new AcpSchema.TextContent(text)); + } + + /** Everything after the first {@code n} space-separated words. */ + static String rest(String text, int n) { + int at = 0; + for (int i = 0; i < n; i++) { + at = text.indexOf(' ', at); + if (at < 0) { + return ""; + } + at++; + } + return text.substring(at); + } + + // ---------------------------------------------------------------- directives + + /** + * {@code #permission allow}: perm.selected; {@code #permission allow meta}: meta.permission, with + * {@code _meta} on the request; {@code #permission hold}: perm.cancelled. + */ + static Mono permission(String sessionId, PromptContext context, boolean hold, + boolean meta) { + long t0 = System.nanoTime(); + String id = hold ? "perm.cancelled" : "perm.selected"; + AcpSchema.RequestPermissionRequest req = new AcpSchema.RequestPermissionRequest(sessionId, + new AcpSchema.ToolCallUpdate("perm-1", "interop permission", AcpSchema.ToolKind.EDIT, AcpSchema.ToolCallStatus.PENDING), + List.of(new AcpSchema.PermissionOption("allow", "Allow", AcpSchema.PermissionOptionKind.ALLOW_ONCE), + new AcpSchema.PermissionOption("reject", "Reject", AcpSchema.PermissionOptionKind.REJECT_ONCE)), + meta ? Map.of("interop", "m1") : null); + return context.client().requestPermission(req).flatMap(r -> { + String said = r.outcome() instanceof AcpSchema.PermissionSelected sel ? "selected " + sel.optionId() + : "cancelled"; + if (meta) { + step("meta.permission", r.meta() != null && "m1".equals(r.meta().get("interop")), t0, + "response _meta " + r.meta()); + } + else { + step(id, said.equals(hold ? "cancelled" : "selected allow"), t0, "outcome " + said); + } + return context.sendMessage("permission: " + said) + .thenReturn(said.equals("cancelled") ? cancelled() : endTurn()); + }).onErrorResume(e -> { + step(meta ? "meta.permission" : id, false, t0, "session/request_permission failed: " + e); + return Mono.error(e); + }); + } + + static boolean fsAllowed(AcpSchema.ClientCapabilities caps, boolean write) { + if (caps == null || caps.fs() == null) { + return false; + } + return Boolean.TRUE.equals(write ? caps.fs().writeTextFile() : caps.fs().readTextFile()); + } + + static Mono fsWrite(String sessionId, PromptContext context, + AcpSchema.ClientCapabilities caps, String args) { + long t0 = System.nanoTime(); + int space = args.indexOf(' '); + String path = space < 0 ? args : args.substring(0, space); + String content = space < 0 ? "" : args.substring(space + 1); + if (!fsAllowed(caps, true)) { + step("fs.write", false, t0, "the client did not advertise fs.writeTextFile"); + return context.sendMessage("fs write error capability").thenReturn(endTurn()); + } + return context.client().writeTextFile(new AcpSchema.WriteTextFileRequest(sessionId, path, content)) + .then(Mono.fromCallable(() -> { + step("fs.write", true, t0, "fs/write_text_file answered without error"); + return "fs write ok"; + })) + .onErrorResume(e -> { + step("fs.write", false, t0, "fs/write_text_file failed: " + e); + return Mono.just("fs write error " + code(e)); + }) + .flatMap(context::sendMessage) + .thenReturn(endTurn()); + } + + /** + * {@code #fs read }, {@code #fs read-range [line=] [limit=]} and + * {@code #fs read-missing }: the steps fs.read, fs.read-range and fs.read-missing. + */ + static Mono fsRead(String sessionId, PromptContext context, + AcpSchema.ClientCapabilities caps, String[] words, String id) { + long t0 = System.nanoTime(); + String path = words.length > 2 ? words[2] : ""; + Integer line = null; + Integer limit = null; + for (int i = 3; i < words.length; i++) { + if (words[i].startsWith("line=")) { + line = Integer.valueOf(words[i].substring(5)); + } + else if (words[i].startsWith("limit=")) { + limit = Integer.valueOf(words[i].substring(6)); + } + } + if (!fsAllowed(caps, false)) { + step(id, false, t0, "the client did not advertise fs.readTextFile"); + return context.sendMessage("fs read error capability").thenReturn(endTurn()); + } + return context.client().readTextFile(new AcpSchema.ReadTextFileRequest(sessionId, path, line, limit)).map(r -> { + String content = r.content(); + switch (id) { + case "fs.read" -> step(id, FS_READ_CONTENT.equals(content), t0, "content " + quote(content)); + case "fs.read-range" -> step(id, content != null && content.trim().equals("line2"), t0, + "content " + quote(content)); + default -> step(id, false, t0, "reading a missing file succeeded: " + quote(content)); + } + return content == null ? "" : content; + }).onErrorResume(e -> { + int code = codeOf(e); + step(id, id.equals("fs.read-missing") && code != 0, t0, "fs/read_text_file failed: " + e); + return Mono.just("fs read error " + (code != 0 ? code : e.getClass().getSimpleName())); + }).flatMap(context::sendMessage).thenReturn(endTurn()); + } + + /** + * {@code #fs read-slow } (cancel-request.agent): reads a file the client holds for up to + * 10 s, cancels the read with $/cancel_request after 300 ms, gracefully (it keeps waiting), and + * expects the client's answer: -32800 within 5 s of the cancel. + */ + static Mono readSlow(String sessionId, PromptContext context, String[] words) { + long t0 = System.nanoTime(); + String path = words.length > 2 ? words[2] : ""; + return context.client().readTextFile(new AcpSchema.ReadTextFileRequest(sessionId, path, null, null)) + .contextWrite(RequestCancellation.cancelWhen(Mono.delay(Duration.ofMillis(300)))) + .timeout(Duration.ofMillis(5_300)) + .doOnNext(r -> step("cancel-request.agent", false, t0, "the read answered content, not -32800")) + .then() + .onErrorResume(e -> { + int code = codeOf(e); + long ms = (System.nanoTime() - t0) / 1_000_000; + String outcome = e instanceof java.util.concurrent.TimeoutException + ? "TIMEOUT: no answer within 5 s of $/cancel_request" : "ended with " + (code != 0 ? code : e.toString()); + step("cancel-request.agent", code == AcpErrorCodes.REQUEST_CANCELLED, t0, + "fs/read_text_file " + outcome + " after " + ms + " ms"); + return Mono.empty(); + }) + .then(context.sendMessage("cancel-request sent")) + .thenReturn(endTurn()); + } + + static Mono emit(PromptContext context, String[] words) { + String kind = words.length > 1 ? words[1] : ""; + String sid = context.getSessionId(); + String name = null; + for (String word : words) { + if (word.startsWith("name=")) { + name = word.substring("name=".length()); + } + } + AcpSchema.SessionUpdate toolCall = new AcpSchema.ToolCall("tool_call", "call-1", "interop tool", name, + AcpSchema.ToolKind.READ, AcpSchema.ToolCallStatus.PENDING, null, null, null, null, null); + List updates = switch (kind) { + case "user_message_chunk" -> List.of(new AcpSchema.UserMessageChunk(new AcpSchema.TextContent("user-chunk"))); + case "agent_thought_chunk" -> List.of(new AcpSchema.AgentThoughtChunk(new AcpSchema.TextContent("thinking"))); + case "tool_call" -> List.of(toolCall); + case "tool_call_update" -> List.of(toolCall, + new AcpSchema.ToolCallUpdateNotification("tool_call_update", "call-1", null, null, null, + AcpSchema.ToolCallStatus.COMPLETED, + List.of(new AcpSchema.ToolCallContentBlock("content", new AcpSchema.TextContent("tool output"))), + null, null, null, null)); + case "plan" -> List.of(new AcpSchema.Plan(List.of(new AcpSchema.PlanEntry("step one", AcpSchema.PlanEntryPriority.HIGH, AcpSchema.PlanEntryStatus.PENDING), + new AcpSchema.PlanEntry("step two", AcpSchema.PlanEntryPriority.LOW, + AcpSchema.PlanEntryStatus.COMPLETED)))); + case "available_commands_update" -> List.of(new AcpSchema.AvailableCommandsUpdate(List.of(new AcpSchema.AvailableCommand("interop", "interop command", + new AcpSchema.AvailableCommandInput("args"))))); + case "current_mode_update" -> List.of(new AcpSchema.CurrentModeUpdate("interop-mode-b")); + case "config_option_update" -> List.of(new AcpSchema.ConfigOptionUpdate(configOptions("model-b"))); + case "usage_update" -> List.of(new AcpSchema.UsageUpdate("usage_update", 100L, 1000L, + new AcpSchema.Cost(0.01, "USD"), null)); + case "session_info_update" -> List.of(new AcpSchema.SessionInfoUpdate("interop title", null)); + case "unknown" -> List.of(new AcpSchema.UnknownSessionUpdate("interop_future_update", Map.of("x", 1)), + chunk("after-unknown")); + default -> null; + }; + if (updates == null) { + return Mono.error(new AcpProtocolException(-32602, "unknown directive: #emit " + kind)); + } + return Flux.fromIterable(updates).concatMap(u -> context.sendSessionUpdate(u)).then(Mono.just(endTurn())); + } + + /** + * {@code #enum tool_call|plan|stop}: values the v1 schema does not define. The tool call's + * kind is a closed Java enum ({@code ToolKind}), so the tool call goes out as an + * {@code UnknownSessionUpdate}, which writes the fields it is given; the plan's and the stop + * reason's are open value types. + */ + static Mono unknownEnum(PromptContext context, String what) { + String sid = context.getSessionId(); + if (what.equals("stop")) { + return context.sendMessage("stop").thenReturn(new AcpSchema.PromptResponse(new AcpSchema.StopReason("interop_future_stop"))); + } + AcpSchema.SessionUpdate update = what.equals("tool_call") + ? new AcpSchema.UnknownSessionUpdate("tool_call", Map.of("toolCallId", "call-enum", "title", "interop enum tool", + "kind", "interop_future_kind", "status", FUTURE_STATUS)) + : new AcpSchema.Plan(List.of(new AcpSchema.PlanEntry("future entry", + new AcpSchema.PlanEntryPriority("interop_future_priority"), new AcpSchema.PlanEntryStatus(FUTURE_STATUS)))); + return context.sendSessionUpdate(update).then(context.sendMessage("after-enum")).thenReturn(endTurn()); + } + + /** {@code #enum audience}: the first block's annotations.audience, one Role unknown to v1. */ + static Mono audience(PromptContext context, AcpSchema.PromptRequest request) { + long t0 = System.nanoTime(); + AcpSchema.ContentBlock first = request.prompt().isEmpty() ? null : request.prompt().get(0); + List audience = first instanceof AcpSchema.TextContent t && t.annotations() != null + && t.annotations().audience() != null + ? t.annotations().audience().stream().map(AcpSchema.Role::value).toList() : null; + boolean unknownKept = first instanceof AcpSchema.TextContent t && t.annotations() != null + && t.annotations().audience() != null && t.annotations().audience().size() == 2 + && !t.annotations().audience().get(1).isKnown(); + step("enum.audience", UNKNOWN_AUDIENCE.equals(audience) && unknownKept, t0, "audience " + audience + + (unknownKept ? " (interop_future_role kept, isKnown() false)" : "")); + return context.sendMessage("audience: " + (audience == null ? "none" : String.join(",", audience))) + .thenReturn(endTurn()); + } + + /** {@code #config grouped}: adds the grouped select effort and sends the session's complete list. */ + static Mono configGrouped(SessionState s, PromptContext context, + AcpSchema.InitializeRequest init) { + s.grouped = true; + return context.sendSessionUpdate(new AcpSchema.ConfigOptionUpdate(configOptions(s, init))) + .thenReturn(endTurn()); + } + + static Mono stop(PromptContext context, String reason) { + AcpSchema.StopReason r = switch (reason) { + case "end_turn" -> AcpSchema.StopReason.END_TURN; + case "max_tokens" -> AcpSchema.StopReason.MAX_TOKENS; + case "max_turn_requests" -> AcpSchema.StopReason.MAX_TURN_REQUESTS; + case "refusal" -> AcpSchema.StopReason.REFUSAL; + case "cancelled" -> AcpSchema.StopReason.CANCELLED; + default -> null; + }; + if (r == null) { + return Mono.error(new AcpProtocolException(-32602, "unknown directive: #stop " + reason)); + } + return context.sendMessage("stop").thenReturn(new AcpSchema.PromptResponse(r)); + } + + /** + * {@code #slow [grace=]}: a tick every 100 ms. After session/cancel (or session/close) it + * keeps ticking for grace ms, then answers cancelled; uncancelled, it answers end_turn after + * 10 s. The SDK holds the session's turn until this answers, so a prompt sent meanwhile is + * rejected (cancel.prompt-while-cancelling). + */ + static Mono slow(PromptContext context, Turn turn, String[] words) { + long graceMs = 0; + for (int i = 1; i < words.length; i++) { + if (words[i].startsWith("grace=")) { + graceMs = Long.parseLong(words[i].substring(6)); + } + } + long t0 = System.nanoTime(); + long grace = graceMs * 1_000_000; + return Flux.interval(Duration.ZERO, Duration.ofMillis(100)) + .takeUntil(i -> turn.cancelled() && System.nanoTime() - turn.cancelledAtNanos >= grace + || System.nanoTime() - t0 >= 10_000_000_000L) + .concatMap(i -> turn.cancelled() && System.nanoTime() - turn.cancelledAtNanos >= grace ? Mono.just(i) + : context.sendMessage("tick").thenReturn(i)) + // cancel-request.client: a $/cancel_request cancels this handler's subscription; a + // session/cancel or close does not (the turn answers cancelled itself). + .doOnCancel(() -> { + if (!turn.cancelled() && !turn.closed) { + step("cancel-request.client", true, t0, "the handler was cancelled by $/cancel_request"); + } + }) + .then(Mono.fromCallable(() -> { + if (!turn.cancelled()) { + return endTurn(); + } + if (!turn.closed && grace == 0) { + step("cancel.prompt", true, t0, "session/cancel arrived for the running session"); + } + return cancelled(); + })); + } + + static boolean terminalAllowed(AcpSchema.ClientCapabilities caps) { + return caps != null && Boolean.TRUE.equals(caps.terminal()); + } + + static Mono terminalRun(String sid, PromptContext context, AcpSchema.ClientCapabilities caps, + String[] words) { + long t0 = System.nanoTime(); + if (!terminalAllowed(caps) || words.length < 3) { + step("term.run", false, t0, "the client did not advertise terminal"); + return context.sendMessage("terminal error capability").thenReturn(endTurn()); + } + List args = List.of(words).subList(3, words.length); + return context.client().createTerminal(new AcpSchema.CreateTerminalRequest(sid, words[2], args, null, null, null)) + .flatMap(created -> { + String tid = created.terminalId(); + return context.client().waitForTerminalExit(new AcpSchema.WaitForTerminalExitRequest(sid, tid)) + .flatMap(exit -> context.client().getTerminalOutput(new AcpSchema.TerminalOutputRequest(sid, tid)) + .flatMap(out -> context.client().releaseTerminal(new AcpSchema.ReleaseTerminalRequest(sid, tid)) + .thenReturn(new Object[] { out.output(), exit.exitCode() }))); + }) + .map(r -> { + String output = r[0] == null ? "" : ((String) r[0]).trim(); + Integer exit = (Integer) r[1]; + step("term.run", output.contains("hi") && Integer.valueOf(0).equals(exit), t0, + "output " + quote(output) + " exitCode " + exit); + return "terminal: " + output + " exit=" + exit; + }) + .onErrorResume(e -> { + step("term.run", false, t0, "terminal failed: " + e); + return Mono.just("terminal error " + code(e)); + }) + .flatMap(context::sendMessage) + .thenReturn(endTurn()); + } + + static Mono terminalKill(String sid, PromptContext context, + AcpSchema.ClientCapabilities caps, String[] words) { + long t0 = System.nanoTime(); + if (!terminalAllowed(caps) || words.length < 3) { + step("term.kill", false, t0, "the client did not advertise terminal"); + return context.sendMessage("terminal error capability").thenReturn(endTurn()); + } + List args = List.of(words).subList(3, words.length); + return context.client().createTerminal(new AcpSchema.CreateTerminalRequest(sid, words[2], args, null, null, null)) + .flatMap(created -> { + String tid = created.terminalId(); + long[] killedAt = new long[1]; + return Mono.delay(Duration.ofMillis(200)) + .then(context.client().killTerminal(new AcpSchema.KillTerminalCommandRequest(sid, tid))) + .then(Mono.fromRunnable(() -> killedAt[0] = System.nanoTime())) + .then(context.client().waitForTerminalExit(new AcpSchema.WaitForTerminalExitRequest(sid, tid))) + .flatMap(exit -> { + long ms = (System.nanoTime() - killedAt[0]) / 1_000_000; + step("term.kill", ms <= 5000, t0, "wait_for_exit returned " + ms + " ms after the kill: exitCode " + + exit.exitCode() + " signal " + exit.signal()); + return context.client().releaseTerminal(new AcpSchema.ReleaseTerminalRequest(sid, tid)); + }) + .thenReturn("terminal killed"); + }) + .onErrorResume(e -> { + step("term.kill", false, t0, "terminal failed: " + e); + return Mono.just("terminal error " + code(e)); + }) + .flatMap(context::sendMessage) + .thenReturn(endTurn()); + } + + static Mono elicitForm(String sid, PromptContext context, AcpSchema.ClientCapabilities caps) { + long t0 = System.nanoTime(); + if (caps == null || caps.elicitation() == null || caps.elicitation().form() == null) { + step("elicit.form", false, t0, "the client did not advertise elicitation.form"); + return context.sendMessage("elicit error capability").thenReturn(endTurn()); + } + Map props = Map.of("name", + new AcpSchema.StringPropertySchema("string", null, null, null, null, null, null, null, null, null, + null)); + return context.client() + .createElicitation(AcpSchema.CreateElicitationRequest.form(sid, "interop form", + new AcpSchema.ElicitationSchema(props, List.of("name")))) + .map(r -> { + String action = String.valueOf(r.action()); + Object name = r.content() == null ? null : r.content().get("name"); + step("elicit.form", "accept".equals(action) && "interop".equals(name), t0, + "action " + action + " content " + r.content()); + return "elicit: " + action + " " + toJson(r.content()); + }) + .onErrorResume(e -> { + step("elicit.form", false, t0, "elicitation/create failed: " + e); + return Mono.just("elicit error " + code(e)); + }) + .flatMap(context::sendMessage) + .thenReturn(endTurn()); + } + + static Mono elicitUrl(String sid, PromptContext context, AcpSchema.ClientCapabilities caps) { + if (caps == null || caps.elicitation() == null || caps.elicitation().url() == null) { + return context.sendMessage("elicit error capability").thenReturn(endTurn()); + } + return context.client() + .createElicitation(AcpSchema.CreateElicitationRequest.url(sid, "interop url", "elic-1", + "https://example.invalid/interop")) + .then(context.client().completeElicitation(new AcpSchema.CompleteElicitationNotification("elic-1"))) + .thenReturn("elicit url done") + .onErrorResume(e -> Mono.just("elicit error " + code(e))) + .flatMap(context::sendMessage) + .thenReturn(endTurn()); + } + + static Mono meta(PromptContext context, AcpSchema.PromptRequest request) { + Map meta = request.meta() == null ? null : new HashMap<>(request.meta()); + return context + .sendSessionUpdate(new AcpSchema.AgentMessageChunk("agent_message_chunk", new AcpSchema.TextContent("meta"), null, meta)) + .thenReturn(new AcpSchema.PromptResponse(AcpSchema.StopReason.END_TURN, meta)); + } + + static Mono echoCaps(PromptContext context, AcpSchema.InitializeRequest init) { + long t0 = System.nanoTime(); + AcpSchema.ClientCapabilities caps = init == null ? null : init.clientCapabilities(); + String json; + try { + json = caps == null ? "null" : JSON.writeValueAsString(caps); + } + catch (Exception e) { + step("init.client-capabilities", false, t0, "could not serialize the client capabilities: " + e); + return Mono.error(new AcpProtocolException(-32603, "could not serialize the client capabilities")); + } + step("init.client-capabilities", caps != null, t0, "clientCapabilities " + json); + return context.sendMessage(json).thenReturn(endTurn()); + } + + static Mono big(PromptContext context, String bytes) { + int n; + try { + n = Integer.parseInt(bytes); + } + catch (NumberFormatException e) { + return Mono.error(new AcpProtocolException(-32602, "unknown directive: #big " + bytes)); + } + return context.sendMessage("x".repeat(n)).thenReturn(endTurn()); + } + + // ---------------------------------------------------------------- extensions + + static final String EXT_METHOD = "_interop/ping"; + + static final String EXT_NOTIFICATION = "_interop/note"; + + static final Map EXT_PARAMS = Map.of("n", 1); + + static final Map EXT_RESULT = Map.of("pong", 1); + + /** #ext request: sends the extension request to the client and reports its raw result. */ + static Mono extRequest(PromptContext context, AcpAsyncAgent agent, String method) { + long t0 = System.nanoTime(); + return agent.sendExtRequest(method, EXT_PARAMS) + .map(java.util.Optional::of) + .defaultIfEmpty(java.util.Optional.empty()) + .flatMap(result -> { + Object value = result.orElse(null); + boolean pass = value instanceof Map m && m.size() == 1 && m.get("pong") instanceof Number n + && n.longValue() == 1; + step("ext.agent-request", pass, t0, "result " + toJson(value)); + return context.sendMessage("ext: " + toJson(value)); + }) + .onErrorResume(e -> { + step("ext.agent-request", false, t0, method + " failed: " + e); + return context.sendMessage("ext error " + (e instanceof AcpError a ? a.getCode() : e.toString())); + }) + .thenReturn(endTurn()); + } + + // ---------------------------------------------------------------- helpers + + static String toJson(Object value) { + try { + return JSON.writeValueAsString(value); + } + catch (Exception e) { + return String.valueOf(value); + } + } + + static int codeOf(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof AcpProtocolException p) { + return p.getCode(); + } + if (t instanceof AcpError a) { + return a.getCode(); + } + } + return 0; + } + + static String code(Throwable e) { + int c = codeOf(e); + return c != 0 ? Integer.toString(c) : e.getClass().getSimpleName(); + } + + static String quote(String s) { + return s == null ? "null" : "\"" + abbreviate(s).replace("\n", "\\n") + "\""; + } + + /** An agent-side assertion, {@code STEP agent. PASS|FAIL ( ms) -> }, on stderr. */ + static void step(String id, boolean pass, long t0, String detail) { + System.err.println("STEP agent." + id + (pass ? " PASS" : " FAIL") + " (" + (System.nanoTime() - t0) / 1_000_000 + + " ms) -> " + detail.replace('\n', ' ').replace('\r', ' ')); + System.err.flush(); + } + + static void log(String line) { + System.err.println(line); + } + + static String abbreviate(String s) { + return s.length() > 200 ? s.substring(0, 200) + "... (" + s.length() + " chars)" : s; + } + + static String env(String name, String fallback) { + String v = System.getenv(name); + return v == null || v.isBlank() ? fallback : v; + } + + /** + * Harness-only instrumentation: the transport has no public request-log hook, so reach its + * Jetty server reflectively. If the field is renamed the scenario fails on its missing + * {@code [http]} lines rather than silently passing. + */ + static void attachRequestLog(StreamableHttpAcpAgentTransport transport) throws Exception { + Field f = StreamableHttpAcpAgentTransport.class.getDeclaredField("server"); + f.setAccessible(true); + Server jetty = (Server) f.get(transport); + jetty.setRequestLog(new CustomRequestLog((RequestLog.Writer) line -> System.err.println(line), + "[http] %m %U %H -> %s ct=\"%{Content-Type}i\" accept=\"%{Accept}i\" conn=\"%{Acp-Connection-Id}i\"" + + " sess=\"%{Acp-Session-Id}i\" upgrade=\"%{Upgrade}i\" respct=\"%{Content-Type}o\"")); + } + +} diff --git a/integration-testing/programs/java/src/main/java/interop/Client.java b/integration-testing/programs/java/src/main/java/interop/Client.java new file mode 100644 index 00000000..430a409f --- /dev/null +++ b/integration-testing/programs/java/src/main/java/interop/Client.java @@ -0,0 +1,1834 @@ +package interop; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.AgentParameters; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransportOptions; +import com.agentclientprotocol.sdk.client.transport.WebSocketAcpClientTransport; +import com.agentclientprotocol.sdk.capabilities.NegotiatedCapabilities; +import com.agentclientprotocol.sdk.error.AcpCapabilityException; +import com.agentclientprotocol.sdk.error.AcpProtocolException; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.json.TypeRef; +import com.agentclientprotocol.sdk.spec.AcpClientTransport; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import com.agentclientprotocol.sdk.spec.RequestCancellation; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * The Java interop client of the step catalogue (integration-testing/steps.json). Runs the step + * ids in {@code STEPS} (comma separated) in order and prints one + * {@code STEP PASS|FAIL ( ms) -> } line per step, then + * {@code RESULT pass=.. fail=.. updates_total=.. upd_=..}. + * + *
    + *   STEPS=... launch/client.sh --transport stdio            spawns bash -c "exec $AGENT_CMD"
    + *   STEPS=... launch/client.sh --transport http --url http://127.0.0.1:<p>/acp
    + *   STEPS=... launch/client.sh --transport ws   --url ws://127.0.0.1:<p>/acp
    + * 
    + * + * On stdio the agent's stderr is relayed to stdout: lines starting with {@code STEP } verbatim, + * every other line prefixed {@code agent| }. Exits 0 once RESULT is printed, whatever the step + * outcomes. + * + *

    + * The client advertises what it implements: {@code fs} read and write, {@code terminal} (real + * processes), {@code elicitation} form and url, {@code auth.terminal} and + * {@code session.configOptions.boolean}. Steps that need what the Java SDK lacks still run as far as they can and then fail with the + * Phase B item in their detail (e.g. {@code P9: ...}). + *

    + * + *

    + * {@code --mode raw} (with {@code --transport stdio}) runs the conformance steps {@code raw.*} + * against a raw driver agent; see {@link Raw}. + *

    + */ +public class Client { + + static final Duration T = Duration.ofMillis(Long.parseLong(env("STEP_TIMEOUT_MS", "15000"))); + + static final Duration UPDATE_GRACE = Duration.ofMillis(1000); + + static final Duration REPLAY_GRACE = Duration.ofMillis(2000); + + static final String FS_READ_CONTENT = "line1\nline2\nline3\n"; + + static final AcpJsonMapper JSON = AcpJsonMapper.createDefault(); + + static String transport; + + static String url; + + static String mode = "catalogue"; + + static Path dir; + + static int pass; + + static int fail; + + static final AtomicInteger updatesTotal = new AtomicInteger(); + + static final Map updatesByKind = new ConcurrentHashMap<>(); + + /** The main connection, opened by init.initialize. */ + static Conn main; + + static AcpSchema.InitializeResponse initResponse; + + public static void main(String[] args) throws Exception { + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--transport" -> transport = value(args, ++i); + case "--url" -> url = value(args, ++i); + case "--mode" -> mode = value(args, ++i); + default -> usage("unknown argument " + args[i]); + } + } + if (transport == null || !List.of("stdio", "http", "ws").contains(transport)) { + usage("--transport stdio|http|ws is required"); + } + if (!List.of("catalogue", "raw").contains(mode)) { + usage("--mode catalogue|raw"); + } + if (!transport.equals("stdio") && url == null) { + usage("--url is required for " + transport); + } + if (transport.equals("stdio") && System.getenv("AGENT_CMD") == null) { + usage("AGENT_CMD is required for stdio"); + } + String steps = System.getenv("STEPS"); + if (steps == null || steps.isBlank()) { + usage("STEPS is required"); + } + dir = Files.createTempDirectory("acp-interop-"); + for (String id : steps.split(",")) { + if (!id.isBlank()) { + run(id.trim()); + } + } + if (main != null) { + main.client.close(); + } + printResult(); + System.exit(0); + } + + static String value(String[] args, int i) { + if (i >= args.length) { + usage("missing value for " + args[i - 1]); + } + return args[i]; + } + + static void usage(String problem) { + System.err.println("client: " + problem); + System.err.println("usage: STEPS= client --transport stdio (with AGENT_CMD) | --transport http|ws --url " + + " [--mode catalogue|raw]"); + System.exit(2); + } + + interface Body { + + String run() throws Exception; + + } + + static final Map STEPS = new java.util.LinkedHashMap<>(); + + static { + STEPS.put("init.initialize", Client::initInitialize); + STEPS.put("init.agent-capabilities", Client::initAgentCapabilities); + STEPS.put("init.client-capabilities", Client::initClientCapabilities); + STEPS.put("init.auth-methods", Client::initAuthMethods); + STEPS.put("init.agent-info", Client::initAgentInfo); + STEPS.put("init.config-boolean", Client::initConfigBoolean); + STEPS.put("auth.authenticate", Client::authAuthenticate); + STEPS.put("auth.logout", Client::authLogout); + STEPS.put("auth.logout-capability", Client::authLogoutCapability); + STEPS.put("auth.terminal", Client::authTerminal); + STEPS.put("session.new", Client::sessionNew); + STEPS.put("session.load", Client::sessionLoad); + STEPS.put("session.load-replay", Client::sessionLoadReplay); + STEPS.put("session.resume", Client::sessionResume); + STEPS.put("session.list", Client::sessionList); + STEPS.put("session.close", Client::sessionClose); + STEPS.put("session.delete", Client::sessionDelete); + STEPS.put("session.multi", Client::sessionMulti); + STEPS.put("session.fork", Client::sessionFork); + STEPS.put("update.agent_message_chunk", Client::agentMessageChunk); + STEPS.put("update.user_message_chunk", () -> emit("user_message_chunk", + u -> u instanceof AcpSchema.UserMessageChunk c && text(c.content()).equals("user-chunk"))); + STEPS.put("update.agent_thought_chunk", () -> emit("agent_thought_chunk", + u -> u instanceof AcpSchema.AgentThoughtChunk c && text(c.content()).equals("thinking"))); + STEPS.put("update.tool_call", () -> emit("tool_call", + u -> u instanceof AcpSchema.ToolCall c && "call-1".equals(c.toolCallId()) + && "interop tool".equals(c.title()) && c.kind() == AcpSchema.ToolKind.READ + // status is optional in the v1 schema (ToolCall requires only toolCallId and + // title); an absent status is the default, pending (the Rust SDK omits it). + && (c.status() == null || AcpSchema.ToolCallStatus.PENDING.equals(c.status())))); + STEPS.put("update.tool_call_update", Client::toolCallUpdate); + STEPS.put("update.tool_call-name", Client::toolCallName); + STEPS.put("update.plan", () -> emit("plan", u -> u instanceof AcpSchema.Plan p && p.entries() != null + && p.entries().size() == 2 && planEntry(p.entries().get(0), "step one", "HIGH", "PENDING") + && planEntry(p.entries().get(1), "step two", "LOW", "COMPLETED"))); + STEPS.put("update.available_commands_update", () -> emit("available_commands_update", + u -> u instanceof AcpSchema.AvailableCommandsUpdate a && a.availableCommands() != null + && a.availableCommands().size() == 1 && "interop".equals(a.availableCommands().get(0).name()) + && a.availableCommands().get(0).input() != null + && "args".equals(a.availableCommands().get(0).input().hint()))); + STEPS.put("update.current_mode_update", () -> emit("current_mode_update", + u -> u instanceof AcpSchema.CurrentModeUpdate m && "interop-mode-b".equals(m.currentModeId()))); + STEPS.put("update.config_option_update", () -> emit("config_option_update", + u -> u instanceof AcpSchema.ConfigOptionUpdate c && "model-b".equals(selectValue(c.configOptions(), "model")))); + STEPS.put("update.session_info_update", Client::sessionInfoUpdate); + STEPS.put("update.usage_update", () -> emit("usage_update", + u -> u instanceof AcpSchema.UsageUpdate x && Long.valueOf(100).equals(x.used()) + && Long.valueOf(1000).equals(x.size()) && x.cost() != null && x.cost().amount() != null + && Math.abs(x.cost().amount() - 0.01) < 1e-9 && "USD".equals(x.cost().currency()))); + STEPS.put("update.unknown", Client::updateUnknown); + STEPS.put("stop.max_tokens", () -> stop("max_tokens", AcpSchema.StopReason.MAX_TOKENS)); + STEPS.put("stop.refusal", () -> stop("refusal", AcpSchema.StopReason.REFUSAL)); + STEPS.put("stop.max_turn_requests", () -> stop("max_turn_requests", AcpSchema.StopReason.MAX_TURN_REQUESTS)); + STEPS.put("enum.tool_call", Client::enumToolCall); + STEPS.put("enum.plan", Client::enumPlan); + STEPS.put("enum.stop", Client::enumStop); + STEPS.put("enum.audience", Client::enumAudience); + STEPS.put("mode.set", Client::modeSet); + STEPS.put("config.on-new", Client::configOnNew); + STEPS.put("config.select", Client::configSelect); + STEPS.put("config.boolean", Client::configBoolean); + STEPS.put("config.grouped", Client::configGrouped); + STEPS.put("perm.selected", Client::permSelected); + STEPS.put("perm.cancelled", Client::permCancelled); + STEPS.put("fs.write", Client::fsWrite); + STEPS.put("fs.read", () -> fsRead("#fs read " + fsReadFile(), c -> c.equals(FS_READ_CONTENT))); + STEPS.put("fs.read-range", + () -> fsRead("#fs read-range " + fsReadFile() + " line=2 limit=1", c -> c.trim().equals("line2"))); + STEPS.put("fs.read-missing", () -> fsRead("#fs read-missing " + dir.resolve("no-such-file.txt"), + c -> c.startsWith("fs read error"))); + STEPS.put("term.run", () -> chunkStep("#terminal run echo hi", "terminal: hi exit=0", T)); + STEPS.put("term.kill", () -> chunkStep("#terminal kill sleep 30", "terminal killed", Duration.ofSeconds(5))); + STEPS.put("elicit.form", Client::elicitForm); + STEPS.put("elicit.complete", Client::elicitComplete); + STEPS.put("cancel.prompt", Client::cancelPrompt); + STEPS.put("cancel.prompt-while-cancelling", Client::cancelWhileCancelling); + STEPS.put("cancel.grace", Client::cancelGrace); + STEPS.put("cancel.max-duration", Client::cancelMaxDuration); + STEPS.put("cancel-request.client", Client::cancelRequestClient); + STEPS.put("cancel-request.agent", Client::cancelRequestAgent); + STEPS.put("cancel-request.unknown", Client::cancelRequestUnknown); + STEPS.put("ext.agent-request", Client::extAgentRequest); + STEPS.put("ext.agent-notification", Client::extAgentNotification); + STEPS.put("ext.client-request", Client::extClientRequest); + STEPS.put("ext.client-notification", Client::extClientNotification); + STEPS.put("meta.prompt", Client::metaPrompt); + STEPS.put("meta.permission", Client::metaPermission); + STEPS.put("error.method-not-found", Client::methodNotFound); + STEPS.put("big.prompt-1m", () -> bigPrompt(1 << 20)); + STEPS.put("big.update-1m", () -> bigUpdate(1 << 20)); + STEPS.put("big.prompt-8m", () -> bigPrompt(8 << 20)); + STEPS.put("big.update-8m", () -> bigUpdate(8 << 20)); + STEPS.put("http.reconnect", Client::httpReconnect); + STEPS.put("stdio.eof-exit", Client::stdioEofExit); + STEPS.put("conn.close", Client::connClose); + } + + static void run(String id) { + Body body = mode.equals("raw") ? Raw.STEPS.get(id) : STEPS.get(id); + if (body == null) { + fail++; + System.out.println("STEP " + id + " FAIL (0 ms) -> unknown-step"); + return; + } + step(id, body); + } + + // ---------------------------------------------------------------- init and auth + + static String initInitialize() { + main = Conn.open(); + AcpSchema.InitializeResponse r = main.initialize(); + initResponse = r; + check(r.protocolVersion() != null && r.protocolVersion() == 1, "protocolVersion " + r.protocolVersion()); + return "protocolVersion=1 agentInfo=" + r.agentInfo(); + } + + static AcpSchema.InitializeResponse init() { + check(initResponse != null, "no initialize response: init.initialize did not pass"); + return initResponse; + } + + static String initAgentCapabilities() { + AcpSchema.AgentCapabilities caps = init().agentCapabilities(); + check(caps != null, "no agentCapabilities"); + check(Boolean.TRUE.equals(caps.loadSession()), "loadSession " + caps.loadSession()); + AcpSchema.SessionCapabilities sc = caps.sessionCapabilities(); + check(sc != null, "no sessionCapabilities"); + List missing = new ArrayList<>(); + if (sc.list() == null) { + missing.add("list"); + } + if (sc.resume() == null) { + missing.add("resume"); + } + if (sc.close() == null) { + missing.add("close"); + } + if (sc.delete() == null) { + missing.add("delete"); + } + check(missing.isEmpty(), "sessionCapabilities missing " + missing); + return "loadSession true; sessionCapabilities list, resume, close, delete"; + } + + static Map echoCaps() throws IOException { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#echo-caps"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> !c.chunks(sid).isEmpty(), "no chunk with the echoed capabilities"); + String json = String.join("", c.chunks(sid)); + Map caps = JSON.readValue(json, new TypeRef>() { + }); + check(caps != null, "the echoed capabilities are null"); + return caps; + } + + static String initClientCapabilities() throws IOException { + Map caps = echoCaps(); + check(Boolean.TRUE.equals(path(caps, "fs", "readTextFile")) && Boolean.TRUE.equals(path(caps, "fs", "writeTextFile")) + && Boolean.TRUE.equals(path(caps, "terminal")), "echoed capabilities " + caps); + return "echoed " + caps; + } + + static String initConfigBoolean() throws IOException { + Map caps = echoCaps(); + check(path(caps, "session", "configOptions", "boolean") != null, + "no session.configOptions.boolean in the echoed capabilities " + caps); + return "echoed session.configOptions.boolean"; + } + + static String initAuthMethods() { + List methods = init().authMethods(); + check(methods != null && methods.stream().anyMatch(m -> "interop-auth".equals(m.id())), "authMethods " + methods); + return "authMethods has interop-auth"; + } + + static String authLogoutCapability() { + AcpSchema.AgentCapabilities caps = init().agentCapabilities(); + check(caps != null && caps.auth() != null && caps.auth().logout() != null, + "agentCapabilities.auth " + (caps == null ? null : caps.auth())); + return "agentCapabilities.auth.logout present"; + } + + static String authTerminal() { + List methods = init().authMethods(); + check(methods != null && methods.stream() + .anyMatch(m -> m instanceof AcpSchema.AuthMethodTerminal t && "interop-terminal-auth".equals(t.id())), + "authMethods " + methods); + return "authMethods has the terminal method interop-terminal-auth"; + } + + static String initAgentInfo() { + AcpSchema.Implementation info = init().agentInfo(); + check(info != null && info.name() != null && info.name().startsWith("interop-"), "agentInfo " + info); + return "agentInfo " + info.name(); + } + + static String authAuthenticate() { + block(main().client.authenticate(new AcpSchema.AuthenticateRequest("interop-auth"))); + return "authenticated with interop-auth"; + } + + static String authLogout() { + block(main().client.logout(new AcpSchema.LogoutRequest())); + return "logged out"; + } + + static String gap(String item, String what) { + throw new StepFailure(item + ": " + what); + } + + /** + * A step blocked by a Java Phase B item: it runs as far as it can, and any failure names the + * item, so its expectation ({@code see: "Pn"}) matches whichever way it fails. Once the item + * lands the body passes and the stale expectation fails the scenario, as it should. + */ + static Body gapped(String item, Body body) { + return () -> { + try { + return body.run(); + } + catch (Throwable e) { + String d = describe(e); + throw new StepFailure(d.startsWith(item + ":") ? d : item + ": " + d); + } + }; + } + + // ---------------------------------------------------------------- sessions + + static String sessionNew() { + String sid = main().newSession(); + check(sid != null && !sid.isEmpty(), "empty sessionId"); + return "sessionId=" + sid; + } + + static String sessionLoad() { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "hello load"); + block(c.client.loadSession(new AcpSchema.LoadSessionRequest(sid, dir.toString(), List.of()))); + int before = c.chunks(sid).size(); + AcpSchema.PromptResponse r = c.prompt(sid, "after load"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).size() > before, "no agent_message_chunk after the load"); + return "loaded " + sid + "; prompt after load end_turn"; + } + + static String sessionLoadReplay() { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "replay me"); + await(() -> String.join("", c.chunks(sid)).equals("echo: replay me"), "the prompt's own chunks did not arrive"); + int from = c.updates(sid).size(); + block(c.client.loadSession(new AcpSchema.LoadSessionRequest(sid, dir.toString(), List.of()))); + await(() -> { + List after = c.updatesFrom(sid, from); + return after.stream().anyMatch(u -> u instanceof AcpSchema.UserMessageChunk m && text(m.content()).equals("replay me")) + && after.stream() + .anyMatch(u -> u instanceof AcpSchema.AgentMessageChunk m && text(m.content()).contains("replay me")); + }, () -> "replayed updates " + kinds(c.updatesFrom(sid, from)), REPLAY_GRACE); + return "replayed " + c.updatesFrom(sid, from).size() + " updates"; + } + + static String sessionResume() throws InterruptedException { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "before resume"); + await(() -> String.join("", c.chunks(sid)).equals("echo: before resume"), "the prompt's own chunks did not arrive"); + int from = c.updates(sid).size(); + block(c.client.resumeSession(new AcpSchema.ResumeSessionRequest(sid, dir.toString(), List.of()))); + Thread.sleep(UPDATE_GRACE.toMillis()); + List during = c.updatesFrom(sid, from); + check(during.isEmpty(), "resume replayed " + kinds(during)); + AcpSchema.PromptResponse r = c.prompt(sid, "after resume"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "after resume: stopReason " + r.stopReason()); + return "resumed without replay; prompt after resume end_turn"; + } + + static String sessionList() throws IOException { + Conn c = main(); + Path cwd = Files.createDirectories(dir.resolve("list")); + String sid = c.newSession(cwd.toString()); + String cursor = null; + for (int page = 0; page < 10; page++) { + AcpSchema.ListSessionsResponse r = block( + c.client.listSessions(new AcpSchema.ListSessionsRequest(cwd.toString(), cursor, null))); + for (AcpSchema.SessionInfo s : r.sessions()) { + if (sid.equals(s.sessionId())) { + check(cwd.toString().equals(s.cwd()), "listed with cwd " + s.cwd()); + return "listed " + sid + " with cwd " + s.cwd() + " on page " + (page + 1); + } + } + cursor = r.nextCursor(); + if (cursor == null) { + break; + } + } + throw new StepFailure(sid + " not listed"); + } + + static String sessionClose() { + Conn c = main(); + String sid = c.newSession(); + CompletableFuture slow = c.promptAsync(sid, "#slow"); + await(() -> c.chunks(sid).contains("tick"), "no tick", T); + AcpSchema.CloseSessionResponse closed = block(c.client.closeSession(new AcpSchema.CloseSessionRequest(sid))); + long t0 = System.nanoTime(); + check(closed != null, "close answered null"); + String ended; + try { + AcpSchema.PromptResponse r = slow.get(5, TimeUnit.SECONDS); + check(AcpSchema.StopReason.CANCELLED.equals(r.stopReason()), "the #slow prompt answered " + r.stopReason()); + ended = "cancelled"; + } + catch (java.util.concurrent.TimeoutException e) { + throw new StepFailure("TIMEOUT: the #slow prompt did not end within 5 s of the close"); + } + catch (java.util.concurrent.ExecutionException e) { + ended = "error " + describe(e.getCause()); + } + catch (InterruptedException e) { + throw new StepFailure("interrupted"); + } + long ms = (System.nanoTime() - t0) / 1_000_000; + try { + AcpSchema.PromptResponse r = c.prompt(sid, "after close"); + throw new StepFailure("the prompt after close answered " + r.stopReason()); + } + catch (StepFailure e) { + throw e; + } + catch (RuntimeException e) { + check(!isTimeout(e), "the prompt after close timed out: TIMEOUT"); + return "close {}; #slow ended (" + ended + ") " + ms + " ms after; prompt after close failed: " + describe(e); + } + } + + static String sessionDelete() { + Conn c = main(); + String sid = c.newSession(); + block(c.client.deleteSession(new AcpSchema.DeleteSessionRequest(sid))); + block(c.client.deleteSession(new AcpSchema.DeleteSessionRequest("no-such-session"))); + return "deleted " + sid + " and no-such-session"; + } + + static String sessionMulti() { + Conn c = main(); + String a = c.newSession(); + String b = c.newSession(); + CompletableFuture fa = c.promptAsync(a, "multi A"); + CompletableFuture fb = c.promptAsync(b, "multi B"); + AcpSchema.PromptResponse ra = join(fa); + AcpSchema.PromptResponse rb = join(fb); + check(AcpSchema.StopReason.END_TURN.equals(ra.stopReason()) && AcpSchema.StopReason.END_TURN.equals(rb.stopReason()), + "stopReasons " + ra.stopReason() + ", " + rb.stopReason()); + await(() -> String.join("", c.chunks(a)).equals("echo: multi A") && String.join("", c.chunks(b)).equals("echo: multi B"), + () -> "chunks A " + c.chunks(a) + ", B " + c.chunks(b)); + return "both end_turn; each session got only its own chunks"; + } + + static String sessionFork() { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "before fork"); + AcpSchema.ForkSessionResponse f = block( + c.client.forkSession(new AcpSchema.ForkSessionRequest(sid, dir.toString(), List.of()))); + check(f.sessionId() != null && !f.sessionId().equals(sid), "fork returned sessionId " + f.sessionId()); + AcpSchema.PromptResponse r = c.prompt(f.sessionId(), "in fork"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "in fork: stopReason " + r.stopReason()); + return "forked " + sid + " into " + f.sessionId(); + } + + // ---------------------------------------------------------------- updates + + static String agentMessageChunk() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "hello"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> String.join("", c.chunks(sid)).equals("echo: hello"), + () -> "chunks " + c.chunks(sid) + " do not spell \"echo: hello\""); + return "end_turn; chunks spell \"echo: hello\""; + } + + /** {@code #emit }: an update matching {@code match} arrives, and the prompt answers end_turn. */ + static String emit(String kind, Predicate match) { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#emit " + kind); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.updates(sid).stream().anyMatch(match), () -> "no matching " + kind + " in " + c.updates(sid)); + return kind + " received; end_turn"; + } + + static String toolCallUpdate() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#emit tool_call_update"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> { + List us = c.updates(sid); + int call = indexOf(us, u -> u instanceof AcpSchema.ToolCall t && "call-1".equals(t.toolCallId())); + int update = indexOf(us, u -> u instanceof AcpSchema.ToolCallUpdateNotification t && "call-1".equals(t.toolCallId()) + && AcpSchema.ToolCallStatus.COMPLETED.equals(t.status()) && t.content() != null + && t.content().stream().anyMatch(x -> x instanceof AcpSchema.ToolCallContentBlock b + && text(b.content()).equals("tool output"))); + return call >= 0 && update > call; + }, () -> "updates " + c.updates(sid)); + return "tool_call call-1, then tool_call_update completed \"tool output\""; + } + + static String toolCallName() { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "#emit tool_call name=read_file"); + await(() -> c.updates(sid).stream().anyMatch(u -> u instanceof AcpSchema.ToolCall), "no tool_call"); + List names = c.updates(sid) + .stream() + .filter(u -> u instanceof AcpSchema.ToolCall) + .map(u -> ((AcpSchema.ToolCall) u).name()) + .toList(); + check(names.contains("read_file"), "tool_call names " + names); + return "tool_call name read_file"; + } + + static String sessionInfoUpdate() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#emit session_info_update"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.updates(sid).stream().anyMatch(u -> u instanceof AcpSchema.SessionInfoUpdate i + && "interop title".equals(i.title())), () -> "no session_info_update titled \"interop title\"; updates " + + kinds(c.updates(sid))); + return "session_info_update title \"interop title\""; + } + + static String updateUnknown() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#emit unknown"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("after-unknown"), () -> "no chunk \"after-unknown\" in " + c.chunks(sid)); + boolean surfaced = c.updates(sid).stream().anyMatch(u -> u instanceof AcpSchema.UnknownSessionUpdate); + return "after-unknown arrived; end_turn (unknown update " + (surfaced ? "surfaced as UnknownSessionUpdate" + : "dropped") + ", connection kept)"; + } + + static String stop(String reason, AcpSchema.StopReason expected) { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#stop " + reason); + check(r.stopReason() == expected, "stopReason " + r.stopReason()); + return "stopReason " + reason; + } + + // ---------------------------------------------------------------- unknown enum values + + /** {@code #enum tool_call}: the unknown status is kept as an open value; the unknown kind reads OTHER. */ + static String enumToolCall() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#enum tool_call"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("after-enum"), () -> "no chunk \"after-enum\" in " + c.chunks(sid)); + AcpSchema.ToolCall call = c.updates(sid) + .stream() + .filter(u -> u instanceof AcpSchema.ToolCall t && "call-enum".equals(t.toolCallId())) + .map(AcpSchema.ToolCall.class::cast) + .findFirst() + .orElseThrow(() -> new StepFailure("no tool_call call-enum; updates " + kinds(c.updates(sid)))); + check(call.status() != null && !call.status().isKnown() && "interop_future_status".equals(call.status().value()), + "status " + call.status()); + check(call.kind() == AcpSchema.ToolKind.OTHER, "kind " + call.kind()); + return "tool_call status interop_future_status (isKnown false), kind OTHER; after-enum; end_turn"; + } + + static String enumPlan() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#enum plan"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("after-enum"), () -> "no chunk \"after-enum\" in " + c.chunks(sid)); + AcpSchema.Plan plan = c.updates(sid) + .stream() + .filter(AcpSchema.Plan.class::isInstance) + .map(AcpSchema.Plan.class::cast) + .findFirst() + .orElseThrow(() -> new StepFailure("no plan; updates " + kinds(c.updates(sid)))); + check(plan.entries() != null && plan.entries().size() == 1, "entries " + plan.entries()); + AcpSchema.PlanEntry e = plan.entries().get(0); + check("future entry".equals(e.content()), "entry " + e); + check(e.priority() != null && !e.priority().isKnown() && "interop_future_priority".equals(e.priority().value()), + "priority " + e.priority()); + check(e.status() != null && !e.status().isKnown() && "interop_future_status".equals(e.status().value()), + "status " + e.status()); + return "plan entry priority interop_future_priority, status interop_future_status (isKnown false); end_turn"; + } + + static String enumStop() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#enum stop"); + check(r.stopReason() != null && !r.stopReason().isKnown() && "interop_future_stop".equals(r.stopReason().value()), + "stopReason " + r.stopReason()); + AcpSchema.PromptResponse after = c.prompt(sid, "after enum"); + check(AcpSchema.StopReason.END_TURN.equals(after.stopReason()), "\"after enum\" answered " + after.stopReason()); + return "stopReason interop_future_stop (isKnown false); \"after enum\" end_turn"; + } + + /** {@code #enum audience}: the client sends a Role v1 does not define, as an open value. */ + static String enumAudience() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.TextContent block = new AcpSchema.TextContent("text", "#enum audience", + new AcpSchema.Annotations(List.of(AcpSchema.Role.USER, new AcpSchema.Role("interop_future_role")), null, null), + null); + AcpSchema.PromptResponse r = block(c.client.prompt(new AcpSchema.PromptRequest(sid, List.of(block)))); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("audience: user,interop_future_role"), + () -> "chunks " + quoteAll(c.chunks(sid))); + return "audience: user,interop_future_role; end_turn"; + } + + // ---------------------------------------------------------------- modes and config + + static String modeSet() { + Conn c = main(); + AcpSchema.NewSessionResponse n = block(c.client.newSession(new AcpSchema.NewSessionRequest(dir.toString(), List.of()))); + check(n.modes() != null && n.modes().availableModes() != null + && n.modes().availableModes().stream().anyMatch(m -> "interop-mode-b".equals(m.id())), "modes " + n.modes()); + block(c.client.setSessionMode(new AcpSchema.SetSessionModeRequest(n.sessionId(), "interop-mode-b"))); + return "session/new listed interop-mode-b; set_mode answered"; + } + + static String configOnNew() { + Conn c = main(); + AcpSchema.NewSessionResponse r = block(c.client.newSession(new AcpSchema.NewSessionRequest(dir.toString(), List.of()))); + check(r != null && "model-a".equals(selectValue(r.configOptions(), "model")), + "configOptions " + (r == null ? null : r.configOptions())); + return "session/new configOptions: model at model-a"; + } + + static String configSelect() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.SetSessionConfigOptionResponse r = block(c.client.setSessionConfigOption( + AcpSchema.SetSessionConfigOptionRequest.select(sid, "model", "model-b"))); + check(r != null && "model-b".equals(selectValue(r.configOptions(), "model")), "configOptions " + (r == null ? null : r.configOptions())); + return "model at model-b in the full list of " + r.configOptions().size(); + } + + /** {@code #config grouped}: a select whose options come in groups; read them all, set one. */ + static String configGrouped() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#config grouped"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.updates(sid).stream().anyMatch(u -> u instanceof AcpSchema.ConfigOptionUpdate), + () -> "no config_option_update; updates " + kinds(c.updates(sid))); + AcpSchema.ConfigOptionUpdate update = c.updates(sid) + .stream() + .filter(AcpSchema.ConfigOptionUpdate.class::isInstance) + .map(AcpSchema.ConfigOptionUpdate.class::cast) + .findFirst() + .orElseThrow(); + AcpSchema.SessionConfigSelect effort = select(update.configOptions(), "effort"); + check(effort != null, "no select effort in " + update.configOptions()); + check(effort.options() instanceof AcpSchema.GroupedSelectOptions g + && g.groups().stream().map(AcpSchema.SessionConfigSelectGroup::group).toList().equals(List.of("fast", "deep")), + "effort options " + effort.options()); + List values = effort.options().allOptions().stream().map(AcpSchema.SessionConfigSelectOption::value).toList(); + check(values.equals(List.of("effort-low", "effort-medium", "effort-high")), "effort values " + values); + AcpSchema.SetSessionConfigOptionResponse set = block(c.client.setSessionConfigOption( + AcpSchema.SetSessionConfigOptionRequest.select(sid, "effort", "effort-high"))); + check(set != null && "effort-high".equals(selectValue(set.configOptions(), "effort")), + "configOptions " + (set == null ? null : set.configOptions())); + return "effort grouped fast/deep, values " + values + "; set to effort-high"; + } + + static AcpSchema.SessionConfigSelect select(List options, String id) { + return options == null ? null : options.stream() + .filter(o -> o instanceof AcpSchema.SessionConfigSelect s && id.equals(s.id())) + .map(AcpSchema.SessionConfigSelect.class::cast) + .findFirst() + .orElse(null); + } + + static String configBoolean() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.SetSessionConfigOptionResponse r; + try { + r = block(c.client.setSessionConfigOption(AcpSchema.SetSessionConfigOptionRequest.bool(sid, "verbose", true))); + } + catch (RuntimeException e) { + throw new StepFailure("set_config_option verbose failed: " + describe(e)); + } + check(r != null && r.configOptions() != null && r.configOptions() + .stream() + .anyMatch(o -> o instanceof AcpSchema.SessionConfigBoolean b && "verbose".equals(b.id()) + && Boolean.TRUE.equals(b.currentValue())), "configOptions " + (r == null ? null : r.configOptions())); + return "verbose at true"; + } + + // ---------------------------------------------------------------- permission, fs, terminal, elicitation + + static String permSelected() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#permission allow"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("permission: selected allow"), + () -> "no chunk \"permission: selected allow\" in " + c.chunks(sid)); + int asked = c.permissionRequests(sid); + check(asked == 1, asked + " permission requests, expected 1"); + return "one permission request; selected allow; end_turn"; + } + + static String permCancelled() { + Conn c = main(); + String sid = c.newSession(); + c.cancelOnPermission.add(sid); + AcpSchema.PromptResponse r = c.prompt(sid, "#permission hold"); + long at = c.cancelSentAt.getOrDefault(sid, 0L); + check(at != 0, "no permission request arrived, so no cancel was sent"); + long ms = (System.nanoTime() - at) / 1_000_000; + check(AcpSchema.StopReason.CANCELLED.equals(r.stopReason()), "stopReason " + r.stopReason()); + check(ms <= 5000, "cancelled " + ms + " ms after the cancel"); + return "cancel sent on the permission request; stopReason cancelled " + ms + " ms later"; + } + + static String fsWrite() throws Exception { + Conn c = main(); + String sid = c.newSession(); + Path file = dir.resolve("fs-write.txt"); + AcpSchema.PromptResponse r = c.prompt(sid, "#fs write " + file + " interop write"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("fs write ok"), () -> "no chunk \"fs write ok\" in " + c.chunks(sid)); + check(Files.exists(file), file + " was not written"); + String content = Files.readString(file); + check(content.equals("interop write"), "file content \"" + content + "\""); + return "written through the client: \"interop write\""; + } + + static Path fsReadFile() throws IOException { + Path file = dir.resolve("fs-read.txt"); + Files.writeString(file, FS_READ_CONTENT, StandardCharsets.UTF_8); + return file; + } + + static String fsRead(String prompt, Predicate chunk) { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, prompt); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).stream().anyMatch(chunk), () -> "chunks " + quoteAll(c.chunks(sid))); + return "chunk " + quoteAll(c.chunks(sid)); + } + + /** Sends the prompt; the chunk {@code expected} must arrive within {@code within} of the start. */ + static String chunkStep(String prompt, String expected, Duration within) { + Conn c = main(); + String sid = c.newSession(); + long t0 = System.nanoTime(); + AcpSchema.PromptResponse r = c.prompt(sid, prompt); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains(expected), () -> "no chunk \"" + expected + "\" in " + quoteAll(c.chunks(sid))); + long ms = (System.nanoTime() - t0) / 1_000_000; + check(ms <= within.toMillis(), "\"" + expected + "\" after " + ms + " ms, more than " + within.toMillis()); + return "\"" + expected + "\" after " + ms + " ms"; + } + + static String elicitForm() throws IOException { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#elicit form"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).stream().anyMatch(s -> s.startsWith("elicit: ")), () -> "chunks " + c.chunks(sid)); + String chunk = c.chunks(sid).stream().filter(s -> s.startsWith("elicit: ")).findFirst().orElseThrow(); + check(chunk.startsWith("elicit: accept "), "chunk " + chunk); + Object content = JSON.readValue(chunk.substring("elicit: accept ".length()), Object.class); + check(Map.of("name", "interop").equals(content), "chunk " + chunk); + return "chunk " + chunk; + } + + static String elicitComplete() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#elicit url"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.completedElicitations.contains("elic-1"), + () -> "no elicitation/complete for elic-1 (received " + c.completedElicitations + "); chunks " + c.chunks(sid)); + return "elicitation/complete elic-1 arrived"; + } + + // ---------------------------------------------------------------- cancellation + + static String cancelPrompt() { + Conn c = main(); + String sid = c.newSession(); + CompletableFuture slow = c.promptAsync(sid, "#slow"); + await(() -> c.chunks(sid).contains("tick"), "no tick", T); + block(c.client.cancel(new AcpSchema.CancelNotification(sid))); + long t0 = System.nanoTime(); + AcpSchema.PromptResponse r = get(slow, Duration.ofSeconds(5), "the prompt did not answer within 5 s of the cancel"); + long ms = (System.nanoTime() - t0) / 1_000_000; + check(AcpSchema.StopReason.CANCELLED.equals(r.stopReason()), "stopReason " + r.stopReason()); + return "stopReason cancelled " + ms + " ms after the cancel"; + } + + /** + * Java SDK rule (G0e, kept by "a cancelled prompt holds its turn until answered"): a prompt sent + * while the session's cancelled prompt has not answered yet is rejected with -32600 (invalid request); once it + * answers, the next prompt is accepted. + */ + static String cancelWhileCancelling() { + Conn c = main(); + String sid = c.newSession(); + CompletableFuture slow = c.promptAsync(sid, "#slow grace=1000"); + await(() -> c.chunks(sid).contains("tick"), "no tick", T); + block(c.client.cancel(new AcpSchema.CancelNotification(sid))); + String during; + try { + AcpSchema.PromptResponse r = c.prompt(sid, "during cancel"); + throw new StepFailure("\"during cancel\" was accepted: " + r.stopReason()); + } + catch (StepFailure e) { + throw e; + } + catch (RuntimeException e) { + int code = codeOf(e); + check(code == -32600, "\"during cancel\" failed with " + describe(e) + ", expected -32600"); + during = describe(e); + } + AcpSchema.PromptResponse first = get(slow, Duration.ofSeconds(5), "the cancelled prompt did not answer within 5 s"); + check(AcpSchema.StopReason.CANCELLED.equals(first.stopReason()), "the cancelled prompt answered " + first.stopReason()); + AcpSchema.PromptResponse after = c.prompt(sid, "after cancel"); + check(AcpSchema.StopReason.END_TURN.equals(after.stopReason()), "\"after cancel\" answered " + after.stopReason()); + return "first cancelled; \"during cancel\" rejected (" + during + "); \"after cancel\" end_turn"; + } + + static String cancelGrace() throws InterruptedException { + Conn c = main(); + String sid = c.newSession(); + CompletableFuture hang = c.promptAsync(sid, "#hang"); + Thread.sleep(200); + block(c.client.cancel(new AcpSchema.CancelNotification(sid))); + long t0 = System.nanoTime(); + AcpSchema.PromptResponse r = get(hang, Duration.ofSeconds(6), "no answer within 6 s of the cancel"); + long ms = (System.nanoTime() - t0) / 1_000_000; + check(AcpSchema.StopReason.CANCELLED.equals(r.stopReason()), "stopReason " + r.stopReason()); + return "the SDK answered cancelled " + ms + " ms after the cancel"; + } + + /** {@code #hang}, never cancelled: the Java agent's SDK answers -32800 at its maxPromptDuration (8 s). */ + static String cancelMaxDuration() { + Conn c = main(); + String sid = c.newSession(); + long t0 = System.nanoTime(); + CompletableFuture hang = c.promptAsync(sid, "#hang"); + AcpSchema.PromptResponse r; + try { + r = get(hang, Duration.ofSeconds(11), "no answer within 11 s"); + } + catch (StepFailure e) { + throw e; + } + catch (RuntimeException e) { + long ms = (System.nanoTime() - t0) / 1_000_000; + check(codeOf(e) == -32800, "the prompt failed with " + describe(e) + ", expected -32800"); + check(ms >= 7_000, "-32800 after " + ms + " ms, before the 8 s maxPromptDuration"); + AcpSchema.PromptResponse after = c.prompt(sid, "after max-duration"); + check(AcpSchema.StopReason.END_TURN.equals(after.stopReason()), + "\"after max-duration\" answered " + after.stopReason()); + return "-32800 after " + ms + " ms; \"after max-duration\" end_turn"; + } + throw new StepFailure("the prompt answered " + r.stopReason() + ", expected error -32800"); + } + + /** + * The client cancels its #slow prompt with $/cancel_request after the first tick, gracefully + * ({@link RequestCancellation}): it keeps waiting, and the prompt must end within 5 s, with + * -32800 or stopReason cancelled. + */ + static String cancelRequestClient() { + Conn c = main(); + String sid = c.newSession(); + AtomicLong cancelAt = new AtomicLong(); + Mono firstTick = reactor.core.publisher.Flux.interval(Duration.ofMillis(20)) + .filter(i -> c.chunks(sid).contains("tick")) + .next() + .doOnNext(i -> cancelAt.set(System.nanoTime())); + CompletableFuture slow = c.client + .prompt(new AcpSchema.PromptRequest(sid, List.of(new AcpSchema.TextContent("#slow")))) + .contextWrite(RequestCancellation.cancelWhen(firstTick)) + .timeout(T.plus(T)) + .toFuture(); + String outcome; + try { + AcpSchema.PromptResponse r = get(slow, Duration.ofSeconds(12), "the prompt did not end"); + check(r.stopReason() == AcpSchema.StopReason.CANCELLED, "the cancelled prompt answered " + r.stopReason()); + outcome = "stopReason cancelled"; + } + catch (StepFailure e) { + throw e; + } + catch (RuntimeException e) { + check(codeOf(e) == -32800, "the cancelled prompt failed with " + describe(e) + ", expected -32800"); + outcome = "error -32800"; + } + check(cancelAt.get() != 0, "the prompt ended before its first tick"); + long ms = (System.nanoTime() - cancelAt.get()) / 1_000_000; + check(ms < 5_000, "the prompt ended " + ms + " ms after $/cancel_request"); + return outcome + " " + ms + " ms after $/cancel_request"; + } + + /** + * The agent cancels its own fs read with $/cancel_request: the client's read handler (it holds + * slow.txt for 10 s) is cancelled and answered -32800, and the prompt ends within 5 s. + */ + static String cancelRequestAgent() { + Conn c = main(); + String sid = c.newSession(); + long t0 = System.nanoTime(); + AcpSchema.PromptResponse r = c.prompt(sid, "#fs read-slow " + dir.resolve("slow.txt")); + long ms = (System.nanoTime() - t0) / 1_000_000; + check(r.stopReason() == AcpSchema.StopReason.END_TURN, "stopReason " + r.stopReason()); + check(c.chunks(sid).contains("cancel-request sent"), "chunks " + quoteAll(c.chunks(sid))); + check(ms < 5_000, "the prompt took " + ms + " ms"); + return "\"cancel-request sent\" and end_turn after " + ms + " ms"; + } + + /** + * $/cancel_request for an id the agent never saw, and for the id of a request it already + * answered: neither gets an answer or breaks the connection. + */ + static String cancelRequestUnknown() { + Conn c = main(); + String done = c.newSession(); + Object answeredId = c.wire.lastRequestId(); + check(answeredId != null, "no request id recorded"); + block(c.wire.sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_CANCEL_REQUEST, new AcpSchema.CancelRequestNotification(999999)))); + block(c.wire.sendMessage(new AcpSchema.JSONRPCNotification(AcpSchema.JSONRPC_VERSION, + AcpSchema.METHOD_CANCEL_REQUEST, new AcpSchema.CancelRequestNotification(answeredId)))); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "after cancel-request"); + check(r.stopReason() == AcpSchema.StopReason.END_TURN, "stopReason " + r.stopReason()); + return "cancelled 999999 and answered " + answeredId + " (session " + done + "); the next prompt answered end_turn"; + } + + // ---------------------------------------------------------------- extensions, _meta, errors, big + + static String extAgentRequest() throws IOException { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#ext request _interop/ping"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).stream().anyMatch(s -> s.startsWith("ext: ")), () -> "chunks " + quoteAll(c.chunks(sid))); + String chunk = c.chunks(sid).stream().filter(s -> s.startsWith("ext: ")).findFirst().orElseThrow(); + Object result; + try { + result = JSON.readValue(chunk.substring(5), Object.class); + } + catch (IOException e) { + throw new StepFailure("chunk " + quote(chunk)); + } + check(result instanceof Map m && m.size() == 1 && Objects.equals(number(m.get("pong")), 1L), "chunk " + quote(chunk)); + return "chunk " + chunk; + } + + static String extAgentNotification() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#ext notify _interop/note"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.extNotes.stream().anyMatch(p -> p instanceof Map m && Objects.equals(number(m.get("n")), 1L)), + () -> "_interop/note notifications " + c.extNotes + "; chunks " + quoteAll(c.chunks(sid))); + return "_interop/note arrived with {n: 1}"; + } + + static String extClientRequest() { + Object result = block(main().client.sendExtRequest("_interop/ping", Map.of("n", 1))); + check(result instanceof Map m && m.size() == 1 && Objects.equals(number(m.get("pong")), 1L), + "result " + result); + return "result " + result; + } + + static String extClientNotification() { + Conn c = main(); + block(c.client.sendExtNotification("_interop/note", Map.of("n", 1))); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#ext last-notification"); + check(r.stopReason() == AcpSchema.StopReason.END_TURN, "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("ext last: _interop/note"), () -> "chunks " + quoteAll(c.chunks(sid))); + return "ext last: _interop/note"; + } + + static String metaPrompt() { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = block(c.client.prompt(new AcpSchema.PromptRequest(sid, + List.of(new AcpSchema.TextContent("#meta")), Map.of("interop", "m1")))); + check(r.meta() != null && "m1".equals(r.meta().get("interop")), "PromptResponse _meta " + r.meta()); + await(() -> c.updates(sid).stream().anyMatch(u -> u instanceof AcpSchema.AgentMessageChunk m + && text(m.content()).equals("meta") && m.meta() != null && "m1".equals(m.meta().get("interop"))), + () -> "updates " + c.updates(sid)); + return "_meta interop=m1 on the chunk and the PromptResponse"; + } + + static String metaPermission() { + Conn c = main(); + String sid = c.newSession(); + c.prompt(sid, "#permission allow meta"); + Map meta = c.permissionMeta.get(sid); + check(meta != null && "m1".equals(meta.get("interop")), "permission request _meta " + meta); + return "the permission request carried _meta interop == m1"; + } + + static String methodNotFound() { + Conn c = main(); + NegotiatedCapabilities caps = c.client.getAgentCapabilities(); + if (caps == null || !caps.supportsProviders()) { + // The SDK refuses a call the agent did not advertise without sending it. + try { + block(c.client.listProviders(new AcpSchema.ListProvidersRequest(null))); + throw new StepFailure("providers/list was sent to an agent that does not advertise providers"); + } + catch (AcpCapabilityException e) { + String sid = c.newSession(); + return "providers/list refused locally (not advertised); session/new after it: " + sid; + } + } + try { + Object r = block(c.client.listProviders(new AcpSchema.ListProvidersRequest(null))); + throw new StepFailure("providers/list answered " + r); + } + catch (StepFailure e) { + throw e; + } + catch (RuntimeException e) { + check(codeOf(e) == -32601, "providers/list failed with " + describe(e) + ", expected -32601"); + } + String sid = c.newSession(); + return "providers/list -32601; session/new after it: " + sid; + } + + static String bigPrompt(int n) { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#len " + "x".repeat(n)); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).contains("len=" + n), () -> "chunks " + quoteAll(c.chunks(sid))); + return "len=" + n; + } + + static String bigUpdate(int n) { + Conn c = main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#big " + n); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + await(() -> c.chunks(sid).stream().anyMatch(s -> s.length() == n), + () -> "chunk lengths " + c.chunks(sid).stream().map(String::length).toList()); + return "one agent_message_chunk of " + n + " characters"; + } + + // ---------------------------------------------------------------- connections + + static String httpReconnect() { + check(transport.equals("http"), "http.reconnect does not apply to " + transport); + Conn first = Conn.open(); + String sid; + try { + first.initialize(); + sid = first.newSession(); + AcpSchema.PromptResponse r = first.prompt(sid, "before reconnect"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "before reconnect: stopReason " + r.stopReason()); + } + finally { + first.close(); + } + Conn second = Conn.open(); + try { + second.initialize(); + block(second.client.loadSession(new AcpSchema.LoadSessionRequest(sid, dir.toString(), List.of()))); + AcpSchema.PromptResponse r = second.prompt(sid, "after reconnect"); + check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "after reconnect: stopReason " + r.stopReason()); + } + finally { + second.close(); + } + return "session " + sid + " loaded and prompted on a new connection"; + } + + /** + * A second agent from AGENT_CMD, driven by hand: the step is about the agent exiting on EOF, + * which the SDK transport hides (it ends the child itself). + */ + static String stdioEofExit() throws Exception { + check(transport.equals("stdio"), "stdio.eof-exit does not apply to " + transport); + Process p = new ProcessBuilder("bash", "-c", "exec " + System.getenv("AGENT_CMD")).start(); + Thread err = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getErrorStream(), StandardCharsets.UTF_8))) { + for (String line; (line = r.readLine()) != null;) { + relay("[eof] " + line); + } + } + catch (IOException e) { + // the child is gone + } + }, "eof-agent-stderr"); + err.setDaemon(true); + err.start(); + try { + OutputStream in = p.getOutputStream(); + in.write(("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":1," + + "\"clientCapabilities\":{},\"clientInfo\":{\"name\":\"interop-java-client\",\"version\":\"1\"}}}\n") + .getBytes(StandardCharsets.UTF_8)); + in.flush(); + BufferedReader out = new BufferedReader(new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)); + CompletableFuture line = CompletableFuture.supplyAsync(() -> { + try { + return out.readLine(); + } + catch (IOException e) { + return null; + } + }); + String response = line.get(T.toMillis(), TimeUnit.MILLISECONDS); + check(response != null && response.contains("\"result\""), "initialize answered " + response); + in.close(); + long t0 = System.nanoTime(); + if (!p.waitFor(5, TimeUnit.SECONDS)) { + throw new StepFailure("no exit on EOF within 5 s"); + } + return "initialized, then exited " + (System.nanoTime() - t0) / 1_000_000 + " ms after EOF (code " + + p.exitValue() + ")"; + } + finally { + p.descendants().forEach(ProcessHandle::destroyForcibly); + p.destroyForcibly(); + } + } + + static String connClose() { + main().close(); + main = null; + return "closed"; + } + + /** One client connection, with what it received. */ + /** + * Delegates to the real transport and remembers the id of the last request sent, so a step + * can name an answered request in a raw $/cancel_request. + */ + static final class RecordingTransport implements AcpClientTransport { + + private final AcpClientTransport delegate; + + private final AtomicReference lastRequestId = new AtomicReference<>(); + + RecordingTransport(AcpClientTransport delegate) { + this.delegate = delegate; + } + + Object lastRequestId() { + return this.lastRequestId.get(); + } + + @Override + public Mono connect( + java.util.function.Function, Mono> handler) { + return this.delegate.connect(handler); + } + + @Override + public Mono sendMessage(AcpSchema.JSONRPCMessage message) { + if (message instanceof AcpSchema.JSONRPCRequest request && request.id() != null) { + this.lastRequestId.set(request.id()); + } + return this.delegate.sendMessage(message); + } + + @Override + public Mono closeGracefully() { + return this.delegate.closeGracefully(); + } + + @Override + public void close() { + this.delegate.close(); + } + + @Override + public T unmarshalFrom(Object data, TypeRef typeRef) { + return this.delegate.unmarshalFrom(data, typeRef); + } + + @Override + public List protocolVersions() { + return this.delegate.protocolVersions(); + } + + @Override + public void setExceptionHandler(java.util.function.Consumer handler) { + this.delegate.setExceptionHandler(handler); + } + + @Override + public Mono awaitTermination() { + return this.delegate.awaitTermination(); + } + + } + + static final class Conn { + + final AcpAsyncClient client; + + final RecordingTransport wire; + + final Map> updates = new ConcurrentHashMap<>(); + + final Map permissions = new ConcurrentHashMap<>(); + + /** Per session, the _meta of the last permission request (echoed into the response). */ + final Map> permissionMeta = new ConcurrentHashMap<>(); + + /** Sessions whose permission request is answered by cancelling the turn (perm.cancelled). */ + final java.util.Set cancelOnPermission = ConcurrentHashMap.newKeySet(); + + final Map cancelSentAt = new ConcurrentHashMap<>(); + + final List completedElicitations = new CopyOnWriteArrayList<>(); + + final List extNotes = new CopyOnWriteArrayList<>(); + + final Terminals terminals = new Terminals(); + + final AtomicReference self = new AtomicReference<>(); + + Conn() { + AcpClientTransport t = switch (transport) { + // One session SSE stream per session, and the catalogue opens more than the + // default 64 sessions on the main connection without closing them. + case "http" -> new StreamableHttpAcpClientTransport(URI.create(url), JSON, + StreamableHttpAcpClientTransportOptions.builder().maxSseStreams(256).build()); + case "ws" -> new WebSocketAcpClientTransport(URI.create(url), JSON); + default -> { + StdioAcpClientTransport stdio = new StdioAcpClientTransport( + AgentParameters.builder("bash").args("-c", "exec " + System.getenv("AGENT_CMD")).build(), JSON); + stdio.setStdErrorHandler(Client::relay); + yield stdio; + } + }; + this.wire = new RecordingTransport(t); + this.client = AcpClient.async(this.wire) + .requestTimeout(T) + .clientCapabilities(capabilities()) + .clientInfo(new AcpSchema.Implementation("interop-java-client", "1")) + .sessionUpdateHandler(n -> { + this.updates.computeIfAbsent(n.sessionId(), k -> new CopyOnWriteArrayList<>()).add(n.update()); + updatesTotal.incrementAndGet(); + updatesByKind.computeIfAbsent(kind(n.update()), k -> new AtomicInteger()).incrementAndGet(); + System.out.println(" update " + n.sessionId() + ": " + abbreviate(String.valueOf(n.update()))); + return Mono.empty(); + }) + .requestPermissionHandler(this::permission) + .writeTextFileHandler(req -> Mono.fromCallable(() -> { + Files.writeString(Path.of(req.path()), req.content(), StandardCharsets.UTF_8); + System.out.println(" fs/write_text_file " + req.path()); + return new AcpSchema.WriteTextFileResponse(); + })) + .readTextFileHandler(this::readTextFile) + .createTerminalHandler(this.terminals::create) + .terminalOutputHandler(this.terminals::output) + .waitForTerminalExitHandler(this.terminals::waitForExit) + .killTerminalHandler(this.terminals::kill) + .releaseTerminalHandler(this.terminals::release) + .createElicitationHandler(req -> { + System.out.println(" elicitation/create " + req.mode() + " " + req.message()); + return Mono.just(AcpSchema.CreateElicitationRequest.MODE_URL.equals(req.mode()) + ? AcpSchema.CreateElicitationResponse.accept() + : AcpSchema.CreateElicitationResponse.accept(Map.of("name", "interop"))); + }) + .completeElicitationHandler(notification -> { + System.out.println(" elicitation/complete " + notification); + this.completedElicitations.add(notification.elicitationId()); + return Mono.empty(); + }) + .extRequestHandler("_interop/ping", params -> { + System.out.println(" _interop/ping " + params); + return Mono.just(Map.of("pong", 1)); + }) + .extNotificationHandler("_interop/note", params -> { + System.out.println(" _interop/note " + params); + this.extNotes.add(params); + return Mono.empty(); + }) + .build(); + this.self.set(this.client); + } + + static Conn open() { + return new Conn(); + } + + static AcpSchema.ClientCapabilities capabilities() { + return new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(true, true), true, + new AcpSchema.ClientSessionCapabilities(AcpSchema.SessionConfigOptionsCapabilities.withBoolean()), + new AcpSchema.AuthCapabilities(true), AcpSchema.ElicitationCapabilities.formAndUrl(), + null); + } + + Mono permission(AcpSchema.RequestPermissionRequest req) { + this.permissions.computeIfAbsent(req.sessionId(), k -> new AtomicInteger()).incrementAndGet(); + if (req.meta() != null) { + this.permissionMeta.put(req.sessionId(), req.meta()); + } + System.out.println(" permission request " + req.sessionId() + ": " + req.options()); + if (this.cancelOnPermission.contains(req.sessionId())) { + return this.client.cancel(new AcpSchema.CancelNotification(req.sessionId())) + .then(Mono.fromCallable(() -> { + this.cancelSentAt.put(req.sessionId(), System.nanoTime()); + return new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionCancelled()); + })); + } + AcpSchema.PermissionOption chosen = req.options() + .stream() + .filter(o -> AcpSchema.PermissionOptionKind.ALLOW_ONCE.equals(o.kind())) + .findFirst() + .orElse(req.options().get(0)); + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionSelected(chosen.optionId()), + req.meta())); + } + + Mono readTextFile(AcpSchema.ReadTextFileRequest req) { + Mono read = Mono.fromCallable(() -> { + System.out.println(" fs/read_text_file " + req.path() + " line=" + req.line() + " limit=" + req.limit()); + String content; + try { + content = Files.readString(Path.of(req.path()), StandardCharsets.UTF_8); + } + catch (NoSuchFileException e) { + throw new AcpProtocolException(-32002, "Resource not found: " + req.path()); + } + if (req.line() != null || req.limit() != null) { + List lines = content.lines().toList(); + int from = Math.max(0, (req.line() == null ? 1 : req.line()) - 1); + int to = req.limit() == null ? lines.size() : Math.min(lines.size(), from + req.limit()); + StringBuilder sb = new StringBuilder(); + for (int i = from; i < to; i++) { + sb.append(lines.get(i)).append('\n'); + } + content = sb.toString(); + } + return new AcpSchema.ReadTextFileResponse(content); + }).subscribeOn(Schedulers.boundedElastic()); + // cancel-request.agent: a read of slow.txt waits up to 10 s, unless the agent cancels + // it with $/cancel_request, which cancels this Mono and answers -32800. + return req.path().endsWith("slow.txt") ? Mono.delay(Duration.ofSeconds(10)).then(read) : read; + } + + AcpSchema.InitializeResponse initialize() { + return block(this.client.initialize()); + } + + String newSession() { + return newSession(dir.toString()); + } + + String newSession(String cwd) { + return block(this.client.newSession(new AcpSchema.NewSessionRequest(cwd, List.of()))).sessionId(); + } + + AcpSchema.PromptResponse prompt(String sid, String text) { + return block(this.client.prompt(new AcpSchema.PromptRequest(sid, List.of(new AcpSchema.TextContent(text))))); + } + + CompletableFuture promptAsync(String sid, String text) { + return this.client.prompt(new AcpSchema.PromptRequest(sid, List.of(new AcpSchema.TextContent(text)))) + .timeout(T.plus(T)) + .toFuture(); + } + + List updates(String sid) { + return List.copyOf(this.updates.getOrDefault(sid, List.of())); + } + + List updatesFrom(String sid, int from) { + List all = updates(sid); + return from >= all.size() ? List.of() : all.subList(from, all.size()); + } + + /** The text of every agent_message_chunk received for the session, in order. */ + List chunks(String sid) { + List out = new ArrayList<>(); + for (AcpSchema.SessionUpdate u : updates(sid)) { + if (u instanceof AcpSchema.AgentMessageChunk c && c.content() instanceof AcpSchema.TextContent t) { + out.add(t.text()); + } + } + return out; + } + + int permissionRequests(String sid) { + AtomicInteger n = this.permissions.get(sid); + return n == null ? 0 : n.get(); + } + + void close() { + try { + block(this.client.closeGracefully()); + } + finally { + this.terminals.releaseAll(); + } + } + + } + + /** The client's terminals: real processes, output collected in memory. */ + static final class Terminals { + + record Term(Process process, StringBuffer output) { + } + + final Map terms = new ConcurrentHashMap<>(); + + final AtomicLong ids = new AtomicLong(); + + Mono create(AcpSchema.CreateTerminalRequest req) { + return Mono.fromCallable(() -> { + List cmd = new ArrayList<>(); + cmd.add(req.command()); + if (req.args() != null) { + cmd.addAll(req.args()); + } + ProcessBuilder pb = new ProcessBuilder(cmd).redirectErrorStream(true); + if (req.cwd() != null) { + pb.directory(Path.of(req.cwd()).toFile()); + } + if (req.env() != null) { + req.env().forEach(e -> pb.environment().put(e.name(), e.value())); + } + Process p = pb.start(); + p.getOutputStream().close(); + StringBuffer out = new StringBuffer(); + Thread reader = new Thread(() -> { + try (var in = new InputStreamReader(p.getInputStream(), StandardCharsets.UTF_8)) { + char[] buf = new char[4096]; + for (int n; (n = in.read(buf)) > 0;) { + out.append(buf, 0, n); + } + } + catch (IOException e) { + // process gone + } + }, "terminal-output"); + reader.setDaemon(true); + reader.start(); + String id = "term-" + this.ids.incrementAndGet(); + this.terms.put(id, new Term(p, out)); + System.out.println(" terminal/create " + id + " " + cmd); + return new AcpSchema.CreateTerminalResponse(id); + }).subscribeOn(Schedulers.boundedElastic()); + } + + Term term(String id) { + Term t = this.terms.get(id); + if (t == null) { + throw new AcpProtocolException(-32602, "unknown terminal " + id); + } + return t; + } + + Mono output(AcpSchema.TerminalOutputRequest req) { + return Mono.fromCallable(() -> { + Term t = term(req.terminalId()); + AcpSchema.TerminalExitStatus status = t.process().isAlive() ? null + : new AcpSchema.TerminalExitStatus(t.process().exitValue(), null); + return new AcpSchema.TerminalOutputResponse(t.output().toString(), false, status); + }); + } + + Mono waitForExit(AcpSchema.WaitForTerminalExitRequest req) { + return Mono.fromCallable(() -> { + Term t = term(req.terminalId()); + int code = t.process().waitFor(); + // let the reader drain what the process wrote before it exited + Thread.sleep(50); + return new AcpSchema.WaitForTerminalExitResponse(code, null); + }).subscribeOn(Schedulers.boundedElastic()); + } + + Mono kill(AcpSchema.KillTerminalCommandRequest req) { + return Mono.fromCallable(() -> { + Term t = term(req.terminalId()); + t.process().descendants().forEach(ProcessHandle::destroy); + t.process().destroy(); + System.out.println(" terminal/kill " + req.terminalId()); + return new AcpSchema.KillTerminalCommandResponse(); + }); + } + + Mono release(AcpSchema.ReleaseTerminalRequest req) { + return Mono.fromCallable(() -> { + Term t = this.terms.remove(req.terminalId()); + if (t != null && t.process().isAlive()) { + t.process().destroyForcibly(); + } + return new AcpSchema.ReleaseTerminalResponse(); + }); + } + + void releaseAll() { + this.terms.values().forEach(t -> t.process().destroyForcibly()); + this.terms.clear(); + } + + } + + static Conn main() { + check(main != null, "no main connection: init.initialize did not run first"); + return main; + } + + static String kind(AcpSchema.SessionUpdate u) { + if (u instanceof AcpSchema.UserMessageChunk) { + return "user_message_chunk"; + } + if (u instanceof AcpSchema.AgentMessageChunk) { + return "agent_message_chunk"; + } + if (u instanceof AcpSchema.AgentThoughtChunk) { + return "agent_thought_chunk"; + } + if (u instanceof AcpSchema.ToolCall) { + return "tool_call"; + } + if (u instanceof AcpSchema.ToolCallUpdateNotification) { + return "tool_call_update"; + } + if (u instanceof AcpSchema.Plan) { + return "plan"; + } + if (u instanceof AcpSchema.AvailableCommandsUpdate) { + return "available_commands_update"; + } + if (u instanceof AcpSchema.CurrentModeUpdate) { + return "current_mode_update"; + } + if (u instanceof AcpSchema.UsageUpdate) { + return "usage_update"; + } + if (u instanceof AcpSchema.ConfigOptionUpdate) { + return "config_option_update"; + } + return "other"; + } + + static List kinds(List us) { + return us.stream().map(Client::kind).toList(); + } + + static String text(AcpSchema.ContentBlock c) { + return c instanceof AcpSchema.TextContent t && t.text() != null ? t.text() : ""; + } + + static boolean planEntry(AcpSchema.PlanEntry e, String content, String priority, String status) { + return content.equals(e.content()) && e.priority() != null && priority.equalsIgnoreCase(e.priority().value()) + && e.status() != null && status.equalsIgnoreCase(e.status().value()); + } + + static String selectValue(List options, String id) { + if (options == null) { + return null; + } + for (AcpSchema.SessionConfigOption o : options) { + if (o instanceof AcpSchema.SessionConfigSelect s && id.equals(s.id())) { + return s.currentValue(); + } + } + return null; + } + + static int indexOf(List us, Predicate p) { + for (int i = 0; i < us.size(); i++) { + if (p.test(us.get(i))) { + return i; + } + } + return -1; + } + + static Object path(Map map, String... keys) { + Object cur = map; + for (String k : keys) { + if (!(cur instanceof Map m)) { + return null; + } + cur = m.get(k); + } + return cur; + } + + static Long number(Object o) { + return o instanceof Number n && n.doubleValue() == n.longValue() ? n.longValue() : null; + } + + // ---------------------------------------------------------------- step plumbing + + static final class StepFailure extends RuntimeException { + + StepFailure(String message) { + super(message); + } + + } + + static void step(String id, Body body) { + long t0 = System.nanoTime(); + String outcome; + String detail; + try { + detail = body.run(); + outcome = "PASS"; + pass++; + } + catch (Throwable e) { + detail = describe(e); + outcome = "FAIL"; + fail++; + } + System.out.println("STEP " + id + " " + outcome + " (" + (System.nanoTime() - t0) / 1_000_000 + " ms) -> " + + abbreviate(detail.replace('\n', ' ').replace('\r', ' '))); + System.out.flush(); + } + + static boolean isTimeout(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof java.util.concurrent.TimeoutException || String.valueOf(t).contains("Timeout on blocking read") + || String.valueOf(t.getMessage()).contains("TIMEOUT")) { + return true; + } + } + return false; + } + + /** A failure detail; a timeout always contains TIMEOUT, so the runner can count hangs. */ + static String describe(Throwable e) { + if (e instanceof StepFailure) { + return e.getMessage(); + } + int code = codeOf(e); + String s = (code != 0 ? "error " + code + ": " : "") + e; + if (isTimeout(e)) { + return "TIMEOUT after " + T.toMillis() + " ms: " + s; + } + return s; + } + + static int codeOf(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof AcpProtocolException p) { + return p.getCode(); + } + if (t instanceof AcpError a) { + return a.getCode(); + } + } + return 0; + } + + static T block(Mono mono) { + return mono.block(T); + } + + static V join(CompletableFuture f) { + return get(f, T, "TIMEOUT"); + } + + static V get(CompletableFuture f, Duration within, String onTimeout) { + try { + return f.get(within.toMillis(), TimeUnit.MILLISECONDS); + } + catch (java.util.concurrent.TimeoutException e) { + throw new StepFailure("TIMEOUT: " + onTimeout); + } + catch (java.util.concurrent.ExecutionException e) { + throw e.getCause() instanceof RuntimeException r ? r : new RuntimeException(e.getCause()); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new StepFailure("interrupted"); + } + } + + static void check(boolean ok, String failure) { + if (!ok) { + throw new StepFailure(failure); + } + } + + /** Wait up to the update grace for a condition on updates, which may trail the response. */ + static void await(Supplier condition, String failure) { + await(condition, () -> failure, UPDATE_GRACE); + } + + static void await(Supplier condition, String failure, Duration within) { + await(condition, () -> failure, within); + } + + static void await(Supplier condition, Supplier failure) { + await(condition, failure, UPDATE_GRACE); + } + + static void await(Supplier condition, Supplier failure, Duration within) { + long deadline = System.nanoTime() + within.toNanos(); + while (!condition.get()) { + if (System.nanoTime() > deadline) { + throw new StepFailure(failure.get()); + } + try { + Thread.sleep(20); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new StepFailure("interrupted"); + } + } + } + + /** Relay one stderr line of a stdio agent: STEP lines verbatim, everything else prefixed. */ + static void relay(String line) { + System.out.println(line.startsWith("STEP ") ? line : "agent| " + line); + } + + static void printResult() { + StringBuilder sb = new StringBuilder("RESULT pass=" + pass + " fail=" + fail + " updates_total=" + updatesTotal.get()); + new TreeMap<>(updatesByKind).forEach((k, v) -> sb.append(" upd_").append(k).append('=').append(v.get())); + System.out.println(sb); + System.out.flush(); + } + + static String env(String name, String fallback) { + String v = System.getenv(name); + return v == null || v.isBlank() ? fallback : v; + } + + static String quote(String s) { + return s == null ? "null" : "\"" + abbreviate(s).replace("\n", "\\n") + "\""; + } + + static String quoteAll(List ss) { + return ss.stream().map(Client::quote).toList().toString(); + } + + static String abbreviate(String s) { + return s.length() > 300 ? s.substring(0, 300) + "... (" + s.length() + " chars)" : s; + } + +} diff --git a/integration-testing/programs/java/src/main/java/interop/JavaAgentMain.java b/integration-testing/programs/java/src/main/java/interop/JavaAgentMain.java deleted file mode 100644 index ebeb5f19..00000000 --- a/integration-testing/programs/java/src/main/java/interop/JavaAgentMain.java +++ /dev/null @@ -1,83 +0,0 @@ -package interop; - -import java.lang.reflect.Field; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -import com.agentclientprotocol.sdk.agent.AcpAgent; -import com.agentclientprotocol.sdk.agent.AcpAgentFactory; -import com.agentclientprotocol.sdk.agent.transport.StreamableHttpAcpAgentTransport; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import org.eclipse.jetty.server.CustomRequestLog; -import org.eclipse.jetty.server.RequestLog; -import org.eclipse.jetty.server.Server; -import reactor.core.publisher.Mono; - -/** - * Interop agent served by the Java Streamable HTTP listener. Each prompt streams two updates - * ("echo: " and the prompt text); a prompt containing "permission" first asks the client for - * permission. Prints {@code READY } on stdout once listening, and one {@code [http]} line per - * request (method, path, protocol, status, ACP headers) so scenarios can assert on HTTP versions. - */ -public class JavaAgentMain { - - public static void main(String[] args) throws Exception { - int port = Integer.parseInt(args.length > 0 ? args[0] : "0"); - AtomicInteger counter = new AtomicInteger(); - AcpAgentFactory factory = AcpAgentFactory.async(transport -> AcpAgent.async(transport) - .initializeHandler(r -> { - System.err.println("[agent] initialize " + r); - return Mono.just(new AcpSchema.InitializeResponse(AcpSchema.LATEST_PROTOCOL_VERSION, - new AcpSchema.AgentCapabilities(true, null, null), List.of())); - }) - .newSessionHandler(r -> { - String id = "java-sess-" + counter.incrementAndGet(); - System.err.println("[agent] session/new " + id); - return Mono.just(new AcpSchema.NewSessionResponse(id, null, null)); - }) - .loadSessionHandler(r -> { - System.err.println("[agent] session/load " + r.sessionId()); - return Mono.just(new AcpSchema.LoadSessionResponse(null, null)); - }) - .resumeSessionHandler(r -> { - System.err.println("[agent] session/resume " + r.sessionId()); - return Mono.just(new AcpSchema.ResumeSessionResponse(null, null)); - }) - .promptHandler((request, context) -> { - String text = request.text(); - System.err.println("[agent] session/prompt " + text); - Mono work = text.contains("permission") - ? context.askPermission("interop permission") - .doOnNext(b -> System.err.println("[agent] permission granted=" + b)) - .flatMap(b -> context.sendMessage("permission granted=" + b)) - : Mono.empty(); - return work.then(context.sendMessage("echo: ")) - .then(context.sendMessage(text)) - .thenReturn(AcpSchema.PromptResponse.endTurn()); - }) - .build()); - StreamableHttpAcpAgentTransport server = new StreamableHttpAcpAgentTransport(port, - StreamableHttpAcpAgentTransport.DEFAULT_ACP_PATH, AcpJsonMapper.createDefault(), factory); - server.start().block(); - attachRequestLog(server); - System.err.println("listening http://127.0.0.1:" + server.getPort() + "/acp"); - System.out.println("READY " + server.getPort()); - server.awaitTermination().block(); - } - - /** - * Harness-only instrumentation: the transport has no public request-log hook, so reach its - * Jetty server reflectively. If the field is renamed the scenario fails on its missing - * {@code [http]} lines rather than silently passing. - */ - private static void attachRequestLog(StreamableHttpAcpAgentTransport transport) throws Exception { - Field f = StreamableHttpAcpAgentTransport.class.getDeclaredField("server"); - f.setAccessible(true); - Server jetty = (Server) f.get(transport); - jetty.setRequestLog(new CustomRequestLog((RequestLog.Writer) line -> System.err.println(line), - "[http] %m %U %H -> %s ct=\"%{Content-Type}i\" accept=\"%{Accept}i\" conn=\"%{Acp-Connection-Id}i\"" - + " sess=\"%{Acp-Session-Id}i\" upgrade=\"%{Upgrade}i\" respct=\"%{Content-Type}o\"")); - } - -} diff --git a/integration-testing/programs/java/src/main/java/interop/JavaClientMain.java b/integration-testing/programs/java/src/main/java/interop/JavaClientMain.java deleted file mode 100644 index a93c833f..00000000 --- a/integration-testing/programs/java/src/main/java/interop/JavaClientMain.java +++ /dev/null @@ -1,120 +0,0 @@ -package interop; - -import java.net.URI; -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -import com.agentclientprotocol.sdk.client.AcpAsyncClient; -import com.agentclientprotocol.sdk.client.AcpClient; -import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; -import com.agentclientprotocol.sdk.json.AcpJsonMapper; -import com.agentclientprotocol.sdk.spec.AcpSchema; -import reactor.core.publisher.Mono; - -/** - * Interop client on the Java Streamable HTTP transport. Runs the interop steps against the agent - * at {@code args[0]} and prints one {@code STEP PASS|FAIL} line per step, then a - * {@code RESULT} line with pass/fail counts and the number of session updates each step received. - * The last four steps reconnect on a fresh client and load the first connection's session. - */ -public class JavaClientMain { - - static final Duration T = Duration.ofSeconds(15); - - static final AtomicInteger updates = new AtomicInteger(); - - static final AtomicInteger total = new AtomicInteger(); - - static final Map updatesPerStep = new LinkedHashMap<>(); - - static int pass; - - static int fail; - - interface Step { - - Object run() throws Exception; - - } - - static Object step(String name, Step s) { - updates.set(0); - long t0 = System.nanoTime(); - try { - Object r = s.run(); - pass++; - System.out.println("STEP " + name + " PASS (" + (System.nanoTime() - t0) / 1_000_000 + " ms) -> " + r); - return r; - } - catch (Throwable e) { - fail++; - System.out.println("STEP " + name + " FAIL (" + (System.nanoTime() - t0) / 1_000_000 + " ms) -> " + e); - return null; - } - finally { - updatesPerStep.put(name, updates.get()); - } - } - - static AcpAsyncClient newClient(URI uri) { - return AcpClient.async(new StreamableHttpAcpClientTransport(uri, AcpJsonMapper.createDefault())) - .requestTimeout(T) - .sessionUpdateConsumer(n -> { - updates.incrementAndGet(); - total.incrementAndGet(); - System.out.println(" update: " + n.update()); - return Mono.empty(); - }) - .requestPermissionHandler(req -> { - System.out.println(" permission request: " + req.options()); - return Mono.just(new AcpSchema.RequestPermissionResponse( - new AcpSchema.PermissionSelected(req.options().get(0).optionId()))); - }) - .build(); - } - - static AcpSchema.PromptRequest prompt(String sid, String text) { - return new AcpSchema.PromptRequest(sid, List.of(new AcpSchema.TextContent(text))); - } - - public static void main(String[] args) { - URI uri = URI.create(args[0]); - String cwd = System.getProperty("user.dir"); - AcpAsyncClient client = newClient(uri); - step("initialize", () -> client.initialize().block(T)); - AcpSchema.NewSessionResponse s = (AcpSchema.NewSessionResponse) step("session/new", - () -> client.newSession(new AcpSchema.NewSessionRequest(cwd, List.of())).block(T)); - String sid = s == null ? "missing" : s.sessionId(); - step("prompt1", () -> client.prompt(prompt(sid, "hello one")).block(T)); - step("prompt2", () -> client.prompt(prompt(sid, "hello two")).block(T)); - step("prompt3-permission", () -> client.prompt(prompt(sid, "please ask permission")).block(T)); - step("session/load", () -> client.loadSession(new AcpSchema.LoadSessionRequest(sid, cwd, List.of())).block(T)); - step("prompt-after-load", () -> client.prompt(prompt(sid, "after load")).block(T)); - step("close", () -> { - client.closeGracefully().block(T); - return "closed"; - }); - AcpAsyncClient c2 = newClient(uri); - step("reconnect-initialize", () -> c2.initialize().block(T)); - step("reconnect-session/load", - () -> c2.loadSession(new AcpSchema.LoadSessionRequest(sid, cwd, List.of())).block(T)); - step("reconnect-prompt", () -> c2.prompt(prompt(sid, "after reconnect")).block(T)); - step("reconnect-close", () -> { - c2.closeGracefully().block(T); - return "closed"; - }); - printResult(); - System.exit(0); - } - - static void printResult() { - StringBuilder sb = new StringBuilder( - "RESULT pass=" + pass + " fail=" + fail + " updates_total=" + total.get()); - updatesPerStep.forEach((k, v) -> sb.append(' ').append(k.replaceAll("[^A-Za-z0-9]+", "_")).append("_updates=").append(v)); - System.out.println(sb); - } - -} diff --git a/integration-testing/programs/java/src/main/java/interop/Raw.java b/integration-testing/programs/java/src/main/java/interop/Raw.java new file mode 100644 index 00000000..26e8d4ac --- /dev/null +++ b/integration-testing/programs/java/src/main/java/interop/Raw.java @@ -0,0 +1,100 @@ +package interop; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.agentclientprotocol.sdk.spec.AcpSchema; + +/** + * {@code client.sh --transport stdio --mode raw}: the Java client against the raw conformance + * driver (programs/raw) playing the agent. The driver puts frames on the wire that no SDK's typed + * API produces; these steps check that the Java client survives each one and does not hang. + * + *

    + * Each case is one prompt, {@code #raw }, on a fresh session; the driver answers + * {@code initialize} and {@code session/new} normally. What the driver sends per case, and what + * PASS means here: + *

    + *
      + *
    • {@code raw.error-no-message} ({@code #raw error-no-message}): the prompt is answered with an + * error object that has a {@code code} and no {@code message}. PASS when the prompt fails with an + * error (not a TIMEOUT) and a following {@code session/new} succeeds.
    • + *
    • {@code raw.null-id-response} ({@code #raw null-id-response}): a stray response + * {@code {"jsonrpc":"2.0","id":null,"result":{}}}, then the prompt's own response, end_turn. PASS + * when the prompt answers end_turn and a following {@code session/new} succeeds.
    • + *
    • {@code raw.unknown-update} ({@code #raw unknown-update}): a {@code session/update} with + * {@code sessionUpdate: "interop_future_update"}, then an {@code agent_message_chunk} + * "after-unknown", then end_turn. PASS when "after-unknown" arrives and the prompt answers + * end_turn.
    • + *
    • {@code raw.null-result} ({@code #raw null-result}): the prompt is answered + * {@code "result": null}, which the schema forbids for a PromptResponse. PASS when the prompt ends + * (an error or a response, never a TIMEOUT) and a following {@code session/new} succeeds.
    • + *
    + * {@code init.initialize} and {@code conn.close} are the catalogue's. + */ +final class Raw { + + static final Map STEPS = new LinkedHashMap<>(); + + static { + STEPS.put("init.initialize", Client::initInitialize); + STEPS.put("raw.error-no-message", () -> { + Client.Conn c = Client.main(); + String sid = c.newSession(); + try { + AcpSchema.PromptResponse r = c.prompt(sid, "#raw error-no-message"); + throw new Client.StepFailure("the prompt answered " + r.stopReason() + ", expected an error"); + } + catch (Client.StepFailure e) { + throw e; + } + catch (RuntimeException e) { + Client.check(!Client.isTimeout(e), Client.describe(e)); + return "failed with " + Client.describe(e) + "; " + survived(c); + } + }); + STEPS.put("raw.null-id-response", () -> { + Client.Conn c = Client.main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#raw null-id-response"); + Client.check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + return "end_turn; " + survived(c); + }); + STEPS.put("raw.unknown-update", () -> { + Client.Conn c = Client.main(); + String sid = c.newSession(); + AcpSchema.PromptResponse r = c.prompt(sid, "#raw unknown-update"); + Client.check(AcpSchema.StopReason.END_TURN.equals(r.stopReason()), "stopReason " + r.stopReason()); + Client.await(() -> c.chunks(sid).contains("after-unknown"), + () -> "no chunk \"after-unknown\" in " + c.chunks(sid)); + return "after-unknown arrived; end_turn"; + }); + STEPS.put("raw.null-result", () -> { + Client.Conn c = Client.main(); + String sid = c.newSession(); + String outcome; + try { + AcpSchema.PromptResponse r = c.prompt(sid, "#raw null-result"); + outcome = "answered " + r; + } + catch (RuntimeException e) { + Client.check(!Client.isTimeout(e), Client.describe(e)); + outcome = "failed with " + Client.describe(e); + } + return outcome + "; " + survived(c); + }); + STEPS.put("conn.close", Client::connClose); + } + + static String survived(Client.Conn c) { + String sid = c.client.newSession(new AcpSchema.NewSessionRequest(Client.dir.toString(), List.of())) + .block(Client.T) + .sessionId(); + return "session/new after it: " + sid; + } + + private Raw() { + } + +} diff --git a/integration-testing/programs/java/src/main/java/load/LoadClients.java b/integration-testing/programs/java/src/main/java/load/LoadClients.java index ce45e600..dea8e4eb 100644 --- a/integration-testing/programs/java/src/main/java/load/LoadClients.java +++ b/integration-testing/programs/java/src/main/java/load/LoadClients.java @@ -14,6 +14,7 @@ import com.agentclientprotocol.sdk.client.AcpAsyncClient; import com.agentclientprotocol.sdk.client.AcpClient; import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransport; +import com.agentclientprotocol.sdk.client.transport.StreamableHttpAcpClientTransportOptions; import com.agentclientprotocol.sdk.json.AcpJsonMapper; import com.agentclientprotocol.sdk.spec.AcpSchema; import reactor.core.publisher.Mono; @@ -47,14 +48,23 @@ public static void main(String[] args) throws Exception { CountDownLatch done = new CountDownLatch(clients); // One platform thread per client (the SDK baseline is Java 17, so no virtual threads). ExecutorService drivers = Executors.newCachedThreadPool(daemon("driver")); + // The load scenarios measure the server, so the generator's client readers (the transport's + // SSE readers) use platform threads on every JDK, like the rest of the generator. The SDK + // client's default, a virtual thread per reader on JDK 21 and later, would put hundreds of + // clients' readers on a few carrier threads in this one JVM and add the generator's own + // scheduling to the latency it reports. + StreamableHttpAcpClientTransportOptions options = StreamableHttpAcpClientTransportOptions.builder() + .virtualThreads(false) + .build(); long t0 = System.nanoTime(); for (int c = 0; c < clients; c++) { AcpAsyncClient client = AcpClient .async(new StreamableHttpAcpClientTransport(endpoint, AcpJsonMapper.createDefault(), sharedConnection ? shared - : HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).executor(httpExecutor).build())) + : HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).executor(httpExecutor).build(), + options)) .requestTimeout(Duration.ofSeconds(60)) - .sessionUpdateConsumer(n -> { + .sessionUpdateHandler(n -> { updates.incrementAndGet(); return Mono.empty(); }) diff --git a/integration-testing/programs/java/src/main/resources/logback.xml b/integration-testing/programs/java/src/main/resources/logback.xml index b32013cd..5b5d1f56 100644 --- a/integration-testing/programs/java/src/main/resources/logback.xml +++ b/integration-testing/programs/java/src/main/resources/logback.xml @@ -1,10 +1,12 @@ - + + + System.err %d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} - %msg%n - + diff --git a/integration-testing/programs/kotlin/.gitignore b/integration-testing/programs/kotlin/.gitignore new file mode 100644 index 00000000..f982f49d --- /dev/null +++ b/integration-testing/programs/kotlin/.gitignore @@ -0,0 +1,4 @@ +# Gradle and Kotlin build state (launch/build.sh) +build/ +.gradle/ +.kotlin/ diff --git a/integration-testing/programs/kotlin/build.gradle.kts b/integration-testing/programs/kotlin/build.gradle.kts new file mode 100644 index 00000000..2b1bf5ab --- /dev/null +++ b/integration-testing/programs/kotlin/build.gradle.kts @@ -0,0 +1,43 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + // The Kotlin SDK's own Kotlin version (its gradle/libs.versions.toml). + kotlin("jvm") version "2.2.20" + application +} + +// Any version: the composite build substitutes the included Kotlin SDK checkout for these modules. +val acp = "0.0.0-included" +val ktor = "3.1.3" + +dependencies { + implementation("com.agentclientprotocol:acp:$acp") + implementation("com.agentclientprotocol:acp-ktor-client:$acp") + implementation("com.agentclientprotocol:acp-ktor-server:$acp") + implementation("io.ktor:ktor-client-cio:$ktor") + implementation("io.ktor:ktor-server-cio:$ktor") + implementation("io.ktor:ktor-server-websockets:$ktor") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + // kotlin-logging (the SDK's logger) binds to slf4j; slf4j-simple logs to stderr, never stdout. + runtimeOnly("org.slf4j:slf4j-simple:2.0.16") +} + +// The runtime JDK may be 17 (the suite's JDK); the SDK itself targets 1.8. +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + optIn.add("com.agentclientprotocol.annotations.UnstableApi") + optIn.add("kotlinx.coroutines.ExperimentalCoroutinesApi") + } +} + +application { + applicationName = "kt-peer" + mainClass.set("interop.MainKt") +} diff --git a/integration-testing/programs/kotlin/gradle.properties b/integration-testing/programs/kotlin/gradle.properties new file mode 100644 index 00000000..cc32cb0e --- /dev/null +++ b/integration-testing/programs/kotlin/gradle.properties @@ -0,0 +1,5 @@ +# Built by launch/build.sh only: no daemon left behind, compiler in process. +org.gradle.daemon=false +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.compiler.execution.strategy=in-process +kotlin.code.style=official diff --git a/integration-testing/programs/kotlin/launch/agent.sh b/integration-testing/programs/kotlin/launch/agent.sh new file mode 100755 index 00000000..6b8af55c --- /dev/null +++ b/integration-testing/programs/kotlin/launch/agent.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the Kotlin interop agent: --transport stdio | --transport ws --port (no http: the +# Kotlin SDK has no Streamable HTTP). Launcher contract: integration-testing/README.md, "Contracts". +# Prints nothing itself: on stdio, stdout is the protocol stream. JAVA_OPTS, if set, reaches the JVM. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$here/build/install/kt-peer/bin/kt-peer" agent "$@" diff --git a/integration-testing/programs/kotlin/launch/build.sh b/integration-testing/programs/kotlin/launch/build.sh new file mode 100755 index 00000000..e45ee5d2 --- /dev/null +++ b/integration-testing/programs/kotlin/launch/build.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Builds the Kotlin interop programs (installDist) against the Kotlin SDK checkout in KOTLIN_SDK, with +# that checkout's own Gradle wrapper and a composite build (settings.gradle.kts). Skips Gradle when +# neither the sources nor the SDK commit changed since the last build. +# Launcher contract: integration-testing/README.md, "Contracts". Env: KOTLIN_SDK. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +sdk="$(cd "${KOTLIN_SDK:?KOTLIN_SDK is not set}" && pwd)" +stamp="$here/build/.kt-peer-stamp" +key="$( { git -C "$sdk" rev-parse HEAD 2>/dev/null || echo "$sdk"; echo "$sdk"; + cd "$here" && find settings.gradle.kts build.gradle.kts gradle.properties src -type f -print0 \ + | sort -z | xargs -0 sha256sum; } | sha256sum | cut -d' ' -f1)" +if [[ -x "$here/build/install/kt-peer/bin/kt-peer" && -f "$stamp" && "$(cat "$stamp")" == "$key" ]]; then + exit 0 +fi +# A failed build must not leave the previous programs behind to run. +rm -rf "$here/build/install" "$stamp" +# The SDK's Gradle toolchain is JDK 21: let Gradle also find one that setup-java exported (CI). +"$sdk/gradlew" -p "$here" --quiet --console=plain -PkotlinSdk="$sdk" \ + -Porg.gradle.java.installations.fromEnv=JAVA_HOME_21_X64,JAVA_HOME_21_ARM64 installDist +echo "$key" > "$stamp" diff --git a/integration-testing/programs/kotlin/launch/client.sh b/integration-testing/programs/kotlin/launch/client.sh new file mode 100755 index 00000000..58c674fb --- /dev/null +++ b/integration-testing/programs/kotlin/launch/client.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the Kotlin interop client: --transport stdio (env AGENT_CMD) | --transport ws --url . +# Env STEPS (required), STEP_TIMEOUT_MS (default 15000). Launcher contract: +# integration-testing/README.md, "Contracts". JAVA_OPTS, if set, reaches the JVM. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "$here/build/install/kt-peer/bin/kt-peer" client "$@" diff --git a/integration-testing/programs/kotlin/settings.gradle.kts b/integration-testing/programs/kotlin/settings.gradle.kts new file mode 100644 index 00000000..d3d08ae4 --- /dev/null +++ b/integration-testing/programs/kotlin/settings.gradle.kts @@ -0,0 +1,25 @@ +// The Kotlin interop programs of the cross-SDK suite (integration-testing/README.md, "Contracts"). +// They build against a Kotlin SDK checkout through a composite build, so the peer ref under test is +// what runs: no publishing, no version to track. The checkout comes from -PkotlinSdk= or the +// KOTLIN_SDK environment variable (matrix.json sets it to ${peer.kotlin-sdk}). +rootProject.name = "kt-peer" + +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +val kotlinSdk: String = providers.gradleProperty("kotlinSdk") + .orElse(providers.environmentVariable("KOTLIN_SDK")) + .orNull + ?: throw GradleException("Set -PkotlinSdk= or KOTLIN_SDK") + +includeBuild(kotlinSdk) diff --git a/integration-testing/programs/kotlin/src/main/kotlin/interop/Agent.kt b/integration-testing/programs/kotlin/src/main/kotlin/interop/Agent.kt new file mode 100644 index 00000000..28362a07 --- /dev/null +++ b/integration-testing/programs/kotlin/src/main/kotlin/interop/Agent.kt @@ -0,0 +1,836 @@ +package interop + +import com.agentclientprotocol.agent.Agent +import com.agentclientprotocol.agent.AgentInfo +import com.agentclientprotocol.agent.AgentSession +import com.agentclientprotocol.agent.AgentSupport +import com.agentclientprotocol.agent.client +import com.agentclientprotocol.client.ClientInfo +import com.agentclientprotocol.common.Event +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.model.* +import com.agentclientprotocol.protocol.JsonRpcException +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.protocol.invoke +import com.agentclientprotocol.protocol.jsonRpcInvalidParams +import com.agentclientprotocol.protocol.jsonRpcMethodNotFound +import com.agentclientprotocol.protocol.jsonRpcRequest +import com.agentclientprotocol.rpc.JsonRpcErrorResponse +import com.agentclientprotocol.rpc.JsonRpcSuccessResponse +import com.agentclientprotocol.rpc.MethodName +import com.agentclientprotocol.transport.StdioTransport +import com.agentclientprotocol.transport.WebSocketTransport +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.request.httpVersion +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow +import kotlinx.serialization.json.* +import java.io.FileDescriptor +import java.io.FileOutputStream +import java.io.PrintStream +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlin.system.exitProcess + +/** + * The Kotlin interop agent: `agent --transport stdio` or `agent --transport ws --port `. + * Kotlin has no Streamable HTTP, so `http` is a usage error. Behaviour is driven by the prompt + * text (steps.json "directives"); `STEP agent.` lines go to stderr. + */ +object AgentMain { + private const val USAGE = "kt-peer agent --transport stdio | --transport ws --port " + + fun run(argv: List) { + val args = Args(argv, "agent", USAGE, setOf("transport", "port")) + when (args["transport"]) { + "stdio" -> stdio() + "ws" -> ws(args["port"]?.toIntOrNull() ?: usage("agent", "--port is required for ws", USAGE)) + "http" -> usage("agent", "the Kotlin SDK has no Streamable HTTP transport", USAGE) + else -> usage("agent", "--transport stdio|ws is required", USAGE) + } + } + + /** Newline-delimited JSON-RPC on stdin/stdout; nothing else on stdout; exit at EOF. */ + private fun stdio() { + val protocolOut = PrintStream(FileOutputStream(FileDescriptor.out), false, Charsets.UTF_8) + System.setOut(System.err) // anything that prints by mistake goes to stderr, not into the stream + val reader = System.`in`.bufferedReader(Charsets.UTF_8) + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val transport = TapTransport( + StdioTransport( + parentScope = scope, + ioDispatcher = Dispatchers.IO, + input = flow { + while (true) emit(reader.readLine() ?: break) + }, + output = { line -> + protocolOut.print(line) + protocolOut.print('\n') + protocolOut.flush() + }, + name = "agent", + ) + ) + val closed = CompletableDeferred() + transport.onClose { closed.complete(Unit) } + val protocol = Protocol(scope, transport, ProtocolOptions(protocolDebugName = "kotlin-agent")) + AgentConnection(protocol, transport) + protocol.start() + closed.await() + } + System.err.println("agent: stdin closed, exiting") + exitProcess(0) + } + + /** One WebSocket endpoint, GET /acp with Upgrade; one [AgentConnection] per socket. */ + private fun ws(port: Int) { + val server = embeddedServer(CIO, host = "127.0.0.1", port = port) { + install(WebSockets) { + maxFrameSize = Long.MAX_VALUE + } + routing { + // What acp-ktor-server's acpProtocolOnServerWebSocket does, with the transport tapped + // and the handler returning when the socket closes. + webSocket("/acp") { + System.err.println("[http] GET /acp ${call.request.httpVersion} -> 101 upgrade=\"websocket\"") + val transport = TapTransport(WebSocketTransport(parentScope = this, wss = this)) + val closed = CompletableDeferred() + transport.onClose { closed.complete(Unit) } + val protocol = Protocol(this, transport, ProtocolOptions(protocolDebugName = "kotlin-agent")) + AgentConnection(protocol, transport) + protocol.start() + closed.await() + } + } + }.start(wait = false) + val bound = runBlocking { server.engine.resolvedConnectors().first().port } + println("READY $bound") + System.out.flush() + Thread.currentThread().join() + } +} + +/** Sessions outlive connections (load and resume on a new socket), so they are per process. */ +class SessionState(val id: String, val cwd: String) { + /** Earlier turns: the prompt text and the agent_message_chunk texts sent for it. */ + val history = CopyOnWriteArrayList>>() + @Volatile var model = "model-a" + @Volatile var verbose = false + @Volatile var mode = Fixtures.MODE_A + /** Whether #config grouped added fixtures.groupedConfigOption, and its value. */ + @Volatile var grouped = false + @Volatile var effort = "effort-low" +} + +object Sessions { + private val counter = AtomicInteger() + val all = ConcurrentHashMap() + + fun create(cwd: String): SessionState { + val s = SessionState("kt-session-${ProcessHandle.current().pid()}-${counter.incrementAndGet()}", cwd) + all[s.id] = s + return s + } +} + +fun agentStep(id: String, ok: Boolean, t0: Long, detail: String) { + System.err.println(stepLine("agent.$id", ok, (System.nanoTime() - t0) / 1_000_000, detail)) + System.err.flush() +} + +/** The agent side of one connection: the SDK's v1 [Agent] over [protocol], plus extension handlers. */ +class AgentConnection(val protocol: Protocol, val tap: TapTransport) : AgentSupport { + /** The client capabilities exactly as received: the typed ClientCapabilities drops what it does not model. */ + @Volatile var rawCaps: JsonObject? = null + @Volatile var lastExtNotification: String? = null + + init { + Agent(protocol, this) + protocol.setRequestHandlerRaw(Fixtures.extRequestMethod) { Fixtures.extResult } + protocol.setNotificationHandlerRaw(Fixtures.extNotificationMethod) { lastExtNotification = it.method.name } + } + + fun clientCap(vararg path: String): Boolean { + var e: JsonElement? = rawCaps + for (p in path) e = (e as? JsonObject)?.get(p) + return e != null && e !is JsonNull && (e as? JsonPrimitive)?.booleanOrNull != false + } + + val booleanConfig: Boolean get() = clientCap("session", "configOptions", "boolean") + + override suspend fun initialize(clientInfo: ClientInfo): AgentInfo { + val params = currentCoroutineContext().jsonRpcRequest.params as? JsonObject + rawCaps = params?.get("clientCapabilities") as? JsonObject + val methods = mutableListOf( + AuthMethod.AgentAuth(AuthMethodId(Fixtures.AUTH_METHOD), "Interop auth", "Accepts any authenticate call") + ) + if (clientCap("auth", "terminal")) { + methods += AuthMethod.TerminalAuth( + AuthMethodId(Fixtures.TERMINAL_AUTH_METHOD), "Interop terminal auth", args = listOf("--login") + ) + } + return AgentInfo( + protocolVersion = LATEST_PROTOCOL_VERSION, + capabilities = AgentCapabilities( + loadSession = true, + promptCapabilities = PromptCapabilities(audio = false, image = false, embeddedContext = false), + mcpCapabilities = McpCapabilities(http = false, sse = false), + sessionCapabilities = SessionCapabilities( + list = SessionListCapabilities(), + resume = SessionResumeCapabilities(), + close = SessionCloseCapabilities(), + delete = SessionDeleteCapabilities(), + ), + auth = AgentAuthCapabilities(logout = LogoutCapabilities()), + ), + authMethods = methods, + implementation = Implementation("interop-kotlin-agent", "1"), + ) + } + + override suspend fun authenticate(methodId: AuthMethodId, _meta: JsonElement?): AuthenticateResponse { + val t0 = System.nanoTime() + val ok = methodId.value == Fixtures.AUTH_METHOD + agentStep("auth.authenticate", ok, t0, "methodId ${methodId.value}") + if (!ok) jsonRpcInvalidParams("unknown auth method ${methodId.value}") + return AuthenticateResponse() + } + + override suspend fun logout(_meta: JsonElement?): LogoutResponse = LogoutResponse() + + // Not implemented by this agent. The SDK registers these handlers anyway and its defaults throw + // NotImplementedError, which goes out as -32603; answer -32601 as for any unknown method. + override suspend fun listProviders(_meta: JsonElement?): ListProvidersResponse = + jsonRpcMethodNotFound("Method not supported: providers/list") + + override suspend fun authStatus(_meta: JsonElement?): AuthStatusResponse = + jsonRpcMethodNotFound("Method not supported: auth/status") + + override suspend fun listSessions(cwd: String?, additionalDirectories: List?, _meta: JsonElement?): Sequence = + Sessions.all.values.filter { cwd == null || it.cwd == cwd }.map { SessionInfo(SessionId(it.id), it.cwd) }.asSequence() + + override suspend fun deleteSession(sessionId: SessionId, _meta: JsonElement?): DeleteSessionResponse { + Sessions.all.remove(sessionId.value) + return DeleteSessionResponse() + } + + override suspend fun createSession(sessionParameters: SessionCreationParameters): AgentSession = + InteropSession(this, Sessions.create(sessionParameters.cwd)) + + override suspend fun loadSession(sessionId: SessionId, sessionParameters: SessionCreationParameters): AgentSession { + val state = Sessions.all[sessionId.value] ?: jsonRpcInvalidParams("unknown session ${sessionId.value}") + // Replay the history before the load response, as the spec describes. The session is not + // registered with the SDK yet, so the updates go out through the protocol directly. + for ((prompt, chunks) in state.history) { + notify(state.id, SessionUpdate.UserMessageChunk(ContentBlock.Text(prompt))) + for (c in chunks) notify(state.id, SessionUpdate.AgentMessageChunk(ContentBlock.Text(c))) + } + return InteropSession(this, state) + } + + override suspend fun resumeSession(sessionId: SessionId, sessionParameters: SessionCreationParameters): AgentSession { + val state = Sessions.all[sessionId.value] ?: jsonRpcInvalidParams("unknown session ${sessionId.value}") + return InteropSession(this, state) + } + + override suspend fun forkSession(sessionId: SessionId, sessionParameters: SessionCreationParameters): AgentSession { + val from = Sessions.all[sessionId.value] ?: jsonRpcInvalidParams("unknown session ${sessionId.value}") + val forked = Sessions.create(sessionParameters.cwd) + forked.history.addAll(from.history) + forked.model = from.model + forked.mode = from.mode + return InteropSession(this, forked) + } + + private fun notify(sessionId: String, update: SessionUpdate) { + AcpMethod.ClientMethods.V1.SessionUpdate(protocol, SessionNotification(SessionId(sessionId), update)) + } +} + +/** One session as served on one connection. */ +class InteropSession(private val conn: AgentConnection, private val state: SessionState) : AgentSession { + override val sessionId = SessionId(state.id) + + /** The directive of the running turn, and its job, for cancel and close. */ + @Volatile private var running: String? = null + @Volatile private var runningJob: Job? = null + @Volatile private var closing = false + + override val availableModes: List get() = Fixtures.modes + override val defaultMode: SessionModeId get() = SessionModeId(state.mode) + + override suspend fun setMode(modeId: SessionModeId, _meta: JsonElement?): SetSessionModeResponse { + val t0 = System.nanoTime() + val known = Fixtures.modes.any { it.id == modeId } + agentStep("mode.set", known && modeId.value == Fixtures.MODE_B, t0, "set_mode ${modeId.value} on ${state.id}") + if (!known) jsonRpcInvalidParams("unknown mode ${modeId.value}") + state.mode = modeId.value + return SetSessionModeResponse() + } + + override val configOptions: List + get() = buildList { + add(Fixtures.modelOption(state.model)) + if (conn.booleanConfig) add(Fixtures.verboseOption(state.verbose)) + if (state.grouped) add(Fixtures.effortOption(state.effort)) + } + + override suspend fun setConfigOption(configId: SessionConfigId, value: SessionConfigOptionValue, _meta: JsonElement?): SetSessionConfigOptionResponse { + when { + configId.value == "model" && value is SessionConfigOptionValue.StringValue && value.value in setOf("model-a", "model-b") -> + state.model = value.value + configId.value == "verbose" && conn.booleanConfig && value is SessionConfigOptionValue.BoolValue -> + state.verbose = value.value + configId.value == "effort" && state.grouped && value is SessionConfigOptionValue.StringValue && + value.value in Fixtures.effortValues -> { + val t0 = System.nanoTime() + state.effort = value.value + agentStep("config.grouped", value.value == "effort-high", t0, "effort set to ${value.value}") + } + else -> jsonRpcInvalidParams("unknown config option or value: ${configId.value} = $value") + } + return SetSessionConfigOptionResponse(configOptions) + } + + override suspend fun cancel() { + if (running?.startsWith("#slow") == true) { + agentStep("cancel.prompt", true, System.nanoTime(), "session/cancel arrived for ${state.id} during #slow") + } + } + + override suspend fun close(_meta: JsonElement?): CloseSessionResponse { + val t0 = System.nanoTime() + closing = true + val job = runningJob + val directive = running + val wasRunning = job != null && job.isActive + job?.cancel(CancellationException("session closed")) + job?.join() + Sessions.all.remove(state.id) + agentStep("session.close", wasRunning, t0, + if (wasRunning) "closed ${state.id}; its running turn ($directive) was cancelled" else "closed ${state.id}; no turn was running") + return CloseSessionResponse() + } + + override suspend fun prompt(content: List, _meta: JsonElement?): Flow = flow { + val text = content.firstOrNull()?.let(::textOf) ?: "" + running = text.substringBefore(' ').takeIf { text.startsWith("#") } ?: "plain" + runningJob = currentCoroutineContext()[Job] + try { + if (!text.startsWith("#")) { + plain(text) + } else { + directive(text, _meta) + } + } finally { + running = null + runningJob = null + } + } + + private suspend fun FlowCollector.chunk(text: String, meta: JsonElement? = null) = + emit(Event.SessionUpdateEvent(SessionUpdate.AgentMessageChunk(ContentBlock.Text(text), _meta = meta))) + + private suspend fun FlowCollector.update(u: SessionUpdate) = emit(Event.SessionUpdateEvent(u)) + + private suspend fun FlowCollector.done(reason: StopReason = StopReason.END_TURN, meta: JsonElement? = null) = + emit(Event.PromptResponseEvent(PromptResponse(reason, _meta = meta))) + + private suspend fun FlowCollector.plain(text: String) { + val chunks = listOf("echo: ", text) + for (c in chunks) chunk(c) + state.history += text to chunks + done() + } + + private suspend fun FlowCollector.directive(text: String, meta: JsonElement?) { + val name = text.substring(1).substringBefore(' ') + val rest = text.substringAfter(' ', "") + val args = if (rest.isEmpty()) emptyList() else rest.split(' ') + when (name) { + "permission" -> permission(args.joinToString(" ")) + "fs" -> fs(args, rest) + "emit" -> emitKind(args) + "stop" -> { + chunk("stop") + done(stopReason(args.firstOrNull() ?: "")) + } + "slow" -> slow() + "hang" -> awaitCancellation() + "enum" -> unknownEnum(args.firstOrNull() ?: "", text) + "config" -> { + if (args.firstOrNull() != "grouped") throw JsonRpcException(-32602, "unknown directive: #config ${args.firstOrNull()}") + state.grouped = true + update(SessionUpdate.ConfigOptionUpdate(configOptions)) + done() + } + "terminal" -> terminal(args) + "elicit" -> elicit(args.firstOrNull() ?: "") + "ext" -> ext(args) + "meta" -> { + chunk("meta", meta) + done(meta = meta) + } + "echo-caps" -> { + val t0 = System.nanoTime() + val caps = conn.rawCaps + agentStep("init.client-capabilities", caps != null, t0, + if (caps != null) "initialize carried clientCapabilities" else "initialize carried no clientCapabilities") + chunk(compact(caps ?: JsonObject(emptyMap()))) + done() + } + "len" -> { + chunk("len=${text.removePrefix("#len ").length}") + done() + } + "big" -> { + chunk("x".repeat(args.firstOrNull()?.toIntOrNull() ?: 0)) + done() + } + else -> throw JsonRpcException(-32602, "unknown directive: #$name") + } + } + + /** + * `#enum tool_call|plan|stop|audience`. Every v1 enum is a closed Kotlin enum, so the tool call + * and the plan go out as UnknownSessionUpdate (raw JSON, as for name=). A PromptResponse can + * only carry a StopReason constant, so `#enum stop` cannot be answered; and a prompt whose + * audience holds an unknown Role fails to decode in the SDK before it reaches this handler. + */ + private suspend fun FlowCollector.unknownEnum(what: String, text: String) { + when (what) { + "tool_call" -> update( + SessionUpdate.UnknownSessionUpdate( + sessionUpdateType = "tool_call", + rawJson = buildJsonObject { + put("toolCallId", "call-enum") + put("title", "interop enum tool") + put("kind", "interop_future_kind") + put("status", Fixtures.FUTURE_STATUS) + }, + ) + ) + "plan" -> update( + SessionUpdate.UnknownSessionUpdate( + sessionUpdateType = "plan", + rawJson = buildJsonObject { + putJsonArray("entries") { + addJsonObject { + put("content", "future entry") + put("priority", "interop_future_priority") + put("status", Fixtures.FUTURE_STATUS) + } + } + }, + ) + ) + "stop" -> { + chunk("stop") + throw JsonRpcException(-32603, + "the Kotlin SDK cannot send stopReason ${Fixtures.FUTURE_STOP}: StopReason is a closed enum") + } + "audience" -> { + // Reached only if the SDK decoded the prompt (it cannot: Role is a closed enum). + val t0 = System.nanoTime() + agentStep("enum.audience", false, t0, "the prompt decoded, but a Kotlin Role cannot hold interop_future_role") + chunk("audience: unreadable") + return done() + } + else -> throw JsonRpcException(-32602, "unknown directive: #enum $what") + } + chunk("after-enum") + done() + } + + private fun stopReason(s: String): StopReason = when (s) { + "max_tokens" -> StopReason.MAX_TOKENS + "refusal" -> StopReason.REFUSAL + "max_turn_requests" -> StopReason.MAX_TURN_REQUESTS + "cancelled" -> StopReason.CANCELLED + "end_turn" -> StopReason.END_TURN + else -> throw JsonRpcException(-32602, "unknown stop reason: $s") + } + + /** `allow`: perm.selected; `allow meta`: meta.permission (_meta on the request); `hold`: perm.cancelled. */ + private suspend fun FlowCollector.permission(mode: String) { + if (mode !in setOf("allow", "allow meta", "hold")) throw JsonRpcException(-32602, "unknown directive: #permission $mode") + val t0 = System.nanoTime() + val client = currentCoroutineContext().client + val step = when (mode) { + "hold" -> "perm.cancelled" + "allow meta" -> "meta.permission" + else -> "perm.selected" + } + try { + val r = client.requestPermissions(Fixtures.permissionToolCall, Fixtures.permissionOptions, + if (mode == "allow meta") Fixtures.meta else null) + val said = when (val o = r.outcome) { + is RequestPermissionOutcome.Selected -> "selected ${o.optionId.value}" + RequestPermissionOutcome.Cancelled -> "cancelled" + } + when (mode) { + "hold" -> agentStep(step, said == "cancelled", t0, "outcome $said") + "allow meta" -> agentStep(step, Fixtures.hasMeta(r._meta), t0, "response _meta ${r._meta}") + else -> agentStep(step, said == "selected allow", t0, "outcome $said") + } + chunk("permission: $said") + done(if (said == "cancelled") StopReason.CANCELLED else StopReason.END_TURN) + } catch (ce: CancellationException) { + // session/cancel: the SDK cancels the whole turn, the pending permission request included, + // so the outcome never reaches this code. Check what the client answered on the wire. + withContext(NonCancellable) { + val id = tap().lastOutgoingId("session/request_permission") + val resp = id?.let { withTimeoutOrNull(3_000) { tap().response(it).await() } } + val outcome = ((resp as? JsonRpcSuccessResponse)?.result as? JsonObject) + ?.get("outcome")?.jsonObject?.get("outcome")?.jsonPrimitive?.contentOrNull + val detail = when (resp) { + null -> "no response to the permission request" + is JsonRpcErrorResponse -> "permission request answered with error ${resp.error.code}" + else -> "turn cancelled; the client answered the permission request with outcome $outcome" + } + agentStep(step, mode == "hold" && outcome == "cancelled", t0, detail) + } + throw ce + } + } + + private fun tap() = conn.tap + + private suspend fun FlowCollector.fs(args: List, rest: String) { + val t0 = System.nanoTime() + val client = currentCoroutineContext().client + when (args.firstOrNull()) { + "write" -> { + val path = args.getOrNull(1) ?: "" + val content = rest.removePrefix("write ").substringAfter(' ', "") + if (!conn.clientCap("fs", "writeTextFile")) { + agentStep("fs.write", false, t0, "the client did not advertise fs.writeTextFile") + chunk("fs write error capability") + return done() + } + try { + client.fsWriteTextFile(path, content) + agentStep("fs.write", true, t0, "fs/write_text_file answered without error") + chunk("fs write ok") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + agentStep("fs.write", false, t0, "fs/write_text_file failed: ${errorCode(e)} $e") + chunk("fs write error ${errorCode(e)}") + } + done() + } + "read", "read-range", "read-missing" -> { + val path = args.getOrNull(1) ?: "" + val line = args.firstOrNull { it.startsWith("line=") }?.removePrefix("line=")?.toUIntOrNull() + val limit = args.firstOrNull { it.startsWith("limit=") }?.removePrefix("limit=")?.toUIntOrNull() + val step = "fs.${args.first()}" + if (!conn.clientCap("fs", "readTextFile")) { + agentStep(step, false, t0, "the client did not advertise fs.readTextFile") + chunk("fs read error capability") + return done() + } + try { + val content = client.fsReadTextFile(path, line, limit).content + when (step) { + "fs.read" -> agentStep(step, content == Fixtures.FS_READ_CONTENT, t0, "content ${JsonPrimitive(content).toString()}") + "fs.read-range" -> agentStep(step, content.trim() == "line2", t0, "content ${JsonPrimitive(content).toString()}") + else -> agentStep(step, false, t0, "reading a missing file succeeded") + } + chunk(content) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + val code = errorCode(e) + agentStep(step, step == "fs.read-missing" && code != null, t0, "fs/read_text_file failed: $code $e") + chunk("fs read error $code") + } + done() + } + "read-slow" -> { + val path = args.getOrNull(1) ?: "" + if (!conn.clientCap("fs", "readTextFile")) { + agentStep("cancel-request.agent", false, t0, "the client did not advertise fs.readTextFile") + chunk("fs read error capability") + return done() + } + // Cancelling the call is how the SDK sends $/cancel_request; it then waits up to its + // graceful timeout for the client's answer. Check that answer on the wire. + coroutineScope { + val call = async { client.fsReadTextFile(path) } + delay(200) + call.cancel() + call.join() + } + val id = tap().lastOutgoingId("fs/read_text_file") + val sentCancel = tap().sentNotifications.any { + it.method.name == "\$/cancel_request" && (it.params as? JsonObject)?.get("requestId")?.toString() == id?.toString() + } + val resp = id?.let { withTimeoutOrNull(5_000) { tap().response(it).await() } } + val code = (resp as? JsonRpcErrorResponse)?.error?.code + agentStep("cancel-request.agent", sentCancel && code == -32800, t0, + "\$/cancel_request sent=$sentCancel; fs/read_text_file " + + when (resp) { + null -> "never answered" + is JsonRpcErrorResponse -> "ended with error ${resp.error.code} ${resp.error.message}" + else -> "answered with a result" + }) + chunk("cancel-request sent") + done() + } + else -> throw JsonRpcException(-32602, "unknown directive: #fs ${args.firstOrNull()}") + } + } + + private suspend fun FlowCollector.emitKind(args: List) { + val kind = args.firstOrNull() ?: "" + val name = args.firstOrNull { it.startsWith("name=") }?.removePrefix("name=") + when (kind) { + "user_message_chunk" -> update(SessionUpdate.UserMessageChunk(ContentBlock.Text("user-chunk"))) + "agent_thought_chunk" -> update(SessionUpdate.AgentThoughtChunk(ContentBlock.Text("thinking"))) + "tool_call" -> update(toolCall(name)) + "tool_call_update" -> { + update(toolCall(name)) + update( + SessionUpdate.ToolCallUpdate( + toolCallId = ToolCallId("call-1"), + status = ToolCallStatus.COMPLETED, + content = listOf(ToolCallContent.Content(ContentBlock.Text("tool output"))), + ) + ) + } + "plan" -> update( + SessionUpdate.PlanUpdate( + listOf( + PlanEntry("step one", PlanEntryPriority.HIGH, PlanEntryStatus.PENDING), + PlanEntry("step two", PlanEntryPriority.LOW, PlanEntryStatus.COMPLETED), + ) + ) + ) + "available_commands_update" -> update( + SessionUpdate.AvailableCommandsUpdate( + listOf(AvailableCommand("interop", "interop command", AvailableCommandInput.Unstructured("args"))) + ) + ) + "current_mode_update" -> update(SessionUpdate.CurrentModeUpdate(SessionModeId(Fixtures.MODE_B))) + "config_option_update" -> update( + SessionUpdate.ConfigOptionUpdate( + buildList { + add(Fixtures.modelOption("model-b")) + if (conn.booleanConfig) add(Fixtures.verboseOption(state.verbose)) + } + ) + ) + "session_info_update" -> update(SessionUpdate.SessionInfoUpdate(title = "interop title")) + "usage_update" -> update(SessionUpdate.UsageUpdate(100, 1000, Cost(0.01, "USD"))) + "unknown" -> { + // The SDK keeps unknown variants as UnknownSessionUpdate and serializes them back as is. + update( + SessionUpdate.UnknownSessionUpdate( + sessionUpdateType = "interop_future_update", + rawJson = buildJsonObject { put("payload", buildJsonObject { put("x", 1) }) }, + ) + ) + chunk("after-unknown") + } + else -> throw JsonRpcException(-32602, "unknown directive: #emit $kind") + } + done() + } + + /** + * The fixture tool call. The v1 ToolCall has no `name` field, so with name= the same tool_call goes + * out through the SDK's forward-compatible UnknownSessionUpdate, which serializes its raw JSON. + */ + private fun toolCall(name: String?): SessionUpdate = + if (name == null) { + SessionUpdate.ToolCall( + toolCallId = ToolCallId("call-1"), + title = "interop tool", + kind = ToolKind.READ, + status = ToolCallStatus.PENDING, + ) + } else { + SessionUpdate.UnknownSessionUpdate( + sessionUpdateType = "tool_call", + rawJson = buildJsonObject { + put("toolCallId", "call-1") + put("title", "interop tool") + put("kind", "read") + put("status", "pending") + put("name", name) + }, + ) + } + + private suspend fun FlowCollector.slow() { + val t0 = System.nanoTime() + try { + val deadline = System.nanoTime() + 10_000_000_000L + while (System.nanoTime() < deadline) { + chunk("tick") + delay(100) + } + done() + } catch (ce: CancellationException) { + if (!closing && byCounterpart(ce)) { + agentStep("cancel-request.client", true, t0, "the prompt's handler was cancelled by \$/cancel_request") + } + throw ce + } + } + + /** A cancellation that the SDK raised for the counterpart's `$/cancel_request`. */ + private fun byCounterpart(e: Throwable): Boolean = + generateSequence(e) { it.cause }.take(10).any { + it.javaClass.simpleName == "IncomingRequestCancelledException" || it.message?.contains("by the counterpart") == true + } + + private suspend fun FlowCollector.terminal(args: List) { + val t0 = System.nanoTime() + val client = currentCoroutineContext().client + val sub = args.firstOrNull() ?: "" + val step = if (sub == "kill") "term.kill" else "term.run" + if (!conn.clientCap("terminal")) { + agentStep(step, false, t0, "the client did not advertise terminal") + chunk("terminal error capability") + return done() + } + val cmd = args.getOrNull(1) ?: "" + val cmdArgs = args.drop(2) + try { + val id = client.terminalCreate(cmd, cmdArgs, cwd = null).terminalId + if (sub == "kill") { + delay(200) + client.terminalKill(id) + val killedAt = System.nanoTime() + val exit = withTimeoutOrNull(5_000) { client.terminalWaitForExit(id) } + client.terminalRelease(id) + val ms = (System.nanoTime() - killedAt) / 1_000_000 + agentStep(step, exit != null, t0, + if (exit != null) "wait_for_exit returned $ms ms after the kill (exitCode=${exit.exitCode} signal=${exit.signal})" + else "wait_for_exit did not return within 5 s of the kill") + chunk("terminal killed") + } else { + val exit = client.terminalWaitForExit(id) + val out = client.terminalOutput(id) + client.terminalRelease(id) + val code = exit.exitCode?.toInt() + agentStep(step, out.output.contains("hi") && code == 0, t0, "output ${JsonPrimitive(out.output).toString()} exit=$code") + chunk("terminal: ${out.output.trim()} exit=$code") + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + agentStep(step, false, t0, "terminal call failed: ${errorCode(e)} $e") + chunk("terminal error ${errorCode(e)}") + } + done() + } + + private suspend fun FlowCollector.elicit(mode: String) { + val t0 = System.nanoTime() + val client = currentCoroutineContext().client + when (mode) { + "form" -> { + if (!conn.clientCap("elicitation")) { + agentStep("elicit.form", false, t0, "the client did not advertise elicitation") + chunk("elicit error capability") + return done() + } + val r = client.createElicitation( + CreateElicitationRequest( + scope = ElicitationScope.Session(sessionId), + mode = ElicitationMode.Form( + ElicitationSchema( + properties = mapOf("name" to ElicitationPropertySchema.StringProperty()), + required = listOf("name"), + ) + ), + message = "interop form", + ) + ) + val (action, content) = when (val a = r.action) { + is ElicitationAction.Accept -> "accept" to JsonObject( + (a.content ?: emptyMap()).mapValues { (_, v) -> contentJson(v) } + ) + ElicitationAction.Decline -> "decline" to JsonObject(emptyMap()) + ElicitationAction.Cancel -> "cancel" to JsonObject(emptyMap()) + } + val name = (content["name"] as? JsonPrimitive)?.contentOrNull + agentStep("elicit.form", action == "accept" && name == "interop", t0, "action $action content ${compact(content)}") + chunk("elicit: $action ${compact(content)}") + } + "url" -> { + if (!conn.clientCap("elicitation", "url")) { + chunk("elicit error capability") + return done() + } + client.createElicitation( + CreateElicitationRequest( + scope = ElicitationScope.Session(sessionId), + mode = ElicitationMode.Url(ElicitationId("elic-1"), "https://example.invalid/interop"), + message = "interop url", + ) + ) + client.completeElicitation(CompleteElicitationNotification(ElicitationId("elic-1"))) + chunk("elicit url done") + } + else -> throw JsonRpcException(-32602, "unknown directive: #elicit $mode") + } + done() + } + + private fun contentJson(v: ElicitationContentValue): JsonElement = when (v) { + is ElicitationContentValue.StringValue -> JsonPrimitive(v.value) + is ElicitationContentValue.IntegerValue -> JsonPrimitive(v.value) + is ElicitationContentValue.NumberValue -> JsonPrimitive(v.value) + is ElicitationContentValue.BooleanValue -> JsonPrimitive(v.value) + is ElicitationContentValue.StringArrayValue -> JsonArray(v.value.map { JsonPrimitive(it) }) + } + + private suspend fun FlowCollector.ext(args: List) { + val t0 = System.nanoTime() + when (args.firstOrNull()) { + "request" -> { + val method = args.getOrNull(1) ?: Fixtures.EXT_METHOD + try { + val result = conn.protocol.sendRequestRaw(MethodName(method), Fixtures.extParams) + agentStep("ext.agent-request", result == Fixtures.extResult, t0, "result ${compact(result)}") + chunk("ext: ${compact(result)}") + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + agentStep("ext.agent-request", false, t0, "$method failed: ${errorCode(e)} $e") + chunk("ext error ${errorCode(e)}") + } + } + "notify" -> { + conn.protocol.sendNotificationRaw(Fixtures.notificationMethod(args.getOrNull(1) ?: Fixtures.EXT_NOTIFICATION), Fixtures.extParams) + chunk("ext notified") + } + "last-notification" -> chunk("ext last: ${conn.lastExtNotification ?: "none"}") + else -> throw JsonRpcException(-32602, "unknown directive: #ext ${args.firstOrNull()}") + } + done() + } +} + +/** The JSON-RPC error code behind an SDK exception, when there is one. */ +fun errorCode(e: Throwable): Int? = when (e) { + is JsonRpcException -> e.code + is com.agentclientprotocol.protocol.AcpRequestCancelledException -> -32800 + is com.agentclientprotocol.protocol.AcpExpectedError -> -32602 + is kotlinx.serialization.SerializationException -> -32700 + else -> generateSequence(e.cause) { it.cause }.take(5).firstNotNullOfOrNull { (it as? JsonRpcException)?.code } +} diff --git a/integration-testing/programs/kotlin/src/main/kotlin/interop/Client.kt b/integration-testing/programs/kotlin/src/main/kotlin/interop/Client.kt new file mode 100644 index 00000000..f7405b6d --- /dev/null +++ b/integration-testing/programs/kotlin/src/main/kotlin/interop/Client.kt @@ -0,0 +1,1208 @@ +package interop + +import com.agentclientprotocol.client.Client +import com.agentclientprotocol.client.ClientInfo +import com.agentclientprotocol.client.ClientSession +import com.agentclientprotocol.common.ClientSessionOperations +import com.agentclientprotocol.common.Event +import com.agentclientprotocol.common.SessionCreationParameters +import com.agentclientprotocol.model.* +import com.agentclientprotocol.protocol.JsonRpcException +import com.agentclientprotocol.protocol.Protocol +import com.agentclientprotocol.protocol.ProtocolOptions +import com.agentclientprotocol.protocol.invoke +import com.agentclientprotocol.rpc.JsonRpcErrorResponse +import com.agentclientprotocol.rpc.JsonRpcSuccessResponse +import com.agentclientprotocol.rpc.MethodName +import com.agentclientprotocol.rpc.RequestId +import com.agentclientprotocol.transport.StdioTransport +import com.agentclientprotocol.transport.Transport +import com.agentclientprotocol.transport.WebSocketTransport +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocketSession +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.serialization.json.* +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.absolutePathString +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.system.exitProcess +import io.ktor.client.engine.cio.CIO as ClientCIO + +/** + * The Kotlin interop client: runs the step ids in `STEPS` in order and prints one `STEP` line per + * step, then `RESULT`. `--transport stdio` spawns `bash -c "exec $AGENT_CMD"` (the sample's + * createProcessStdioTransport pattern) and relays its stderr; `--transport ws --url ` connects + * through Ktor. + */ +object ClientMain { + private const val USAGE = "STEPS= kt-peer client --transport stdio (with AGENT_CMD) | --transport ws --url " + + val stepTimeoutMs: Long = System.getenv("STEP_TIMEOUT_MS")?.toLongOrNull() ?: 15_000 + const val UPDATE_GRACE_MS = 1_000L + const val REPLAY_GRACE_MS = 2_000L + + lateinit var transport: String + var url: String? = null + lateinit var dir: Path + var pass = 0 + var fail = 0 + val updatesTotal = AtomicInteger() + val updatesByKind = ConcurrentHashMap() + + /** The main connection, opened by init.initialize and closed by conn.close. */ + var main: Conn? = null + var agentInfo: com.agentclientprotocol.agent.AgentInfo? = null + + fun run(argv: List) { + val args = Args(argv, "client", USAGE, setOf("transport", "url")) + transport = args["transport"] ?: usage("client", "--transport stdio|ws is required", USAGE) + url = args["url"] + when (transport) { + "stdio" -> if (System.getenv("AGENT_CMD").isNullOrBlank()) usage("client", "AGENT_CMD is required for stdio", USAGE) + "ws" -> if (url == null) usage("client", "--url is required for ws", USAGE) + "http" -> usage("client", "the Kotlin SDK has no Streamable HTTP transport", USAGE) + else -> usage("client", "--transport stdio|ws is required", USAGE) + } + val steps = System.getenv("STEPS")?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } + if (steps.isNullOrEmpty()) usage("client", "STEPS is required", USAGE) + dir = Files.createTempDirectory("acp-interop-kt-") + runBlocking(Dispatchers.Default) { + for (id in steps) runStep(id) + } + val sb = StringBuilder("RESULT pass=$pass fail=$fail updates_total=${updatesTotal.get()}") + updatesByKind.toSortedMap().forEach { (k, v) -> sb.append(" upd_").append(k).append('=').append(v.get()) } + println(sb) + System.out.flush() + main?.kill() + exitProcess(0) + } + + class StepFailure(message: String) : Exception(message) + + fun check(ok: Boolean, failure: () -> String) { + if (!ok) throw StepFailure(failure()) + } + + private suspend fun runStep(id: String) { + val body = Steps.all[id] + if (body == null) { + fail++ + println(stepLine(id, false, 0, "unknown-step")) + return + } + val t0 = System.nanoTime() + val (ok, detail) = try { + true to withTimeout(stepTimeoutMs) { body() } + } catch (e: TimeoutCancellationException) { + false to "TIMEOUT after $stepTimeoutMs ms: $e" + } catch (e: StepFailure) { + false to (e.message ?: "failed") + } catch (e: Throwable) { + false to describe(e) + } + if (ok) pass++ else fail++ + println(stepLine(id, ok, (System.nanoTime() - t0) / 1_000_000, detail)) + System.out.flush() + } + + fun describe(e: Throwable): String { + val code = errorCode(e) + return if (code != null) "error $code: ${e.message}" else e.toString() + } + + fun main(): Conn = main ?: throw StepFailure("no main connection: init.initialize did not run first") + + fun record(conn: Conn, sid: String, u: SessionUpdate) { + conn.updates.computeIfAbsent(sid) { CopyOnWriteArrayList() }.add(u) + updatesTotal.incrementAndGet() + updatesByKind.computeIfAbsent(kindOf(u)) { AtomicInteger() }.incrementAndGet() + println(" update $sid: ${abbreviate(u.toString(), 200)}") + } +} + +/** One client connection: the SDK [Client] over a (tapped) transport, and what it received. */ +class Conn private constructor( + val scope: CoroutineScope, + val protocol: Protocol, + val tap: TapTransport, + private val closer: suspend () -> Unit, + private val killer: () -> Unit, +) { + val client = Client(protocol) + val updates = ConcurrentHashMap>() + val permissions = ConcurrentHashMap() + val permissionMeta = ConcurrentHashMap() + val holdCancel: MutableSet = ConcurrentHashMap.newKeySet() + val cancelledAt = ConcurrentHashMap() + val sessions = ConcurrentHashMap() + val extNotifications = CopyOnWriteArrayList() + val elicitationsCompleted = ConcurrentHashMap() + private val terminals = ConcurrentHashMap() + + init { + protocol.setRequestHandlerRaw(Fixtures.extRequestMethod) { Fixtures.extResult } + protocol.setNotificationHandlerRaw(Fixtures.extNotificationMethod) { extNotifications += it.params } + } + + companion object { + suspend fun open(): Conn { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + return when (ClientMain.transport) { + "ws" -> { + val http = HttpClient(ClientCIO) { + install(WebSockets) { maxFrameSize = Long.MAX_VALUE } + } + // What acp-ktor-client's acpProtocolOnClientWebSocket does, with the transport tapped. + val wss = http.webSocketSession(urlString = ClientMain.url!!) + val tap = TapTransport(WebSocketTransport(parentScope = wss, wss = wss)) + val protocol = Protocol(wss, tap, ProtocolOptions(protocolDebugName = "kotlin-client")) + Conn(scope, protocol, tap, closer = { + val closed = CompletableDeferred() + tap.onClose { closed.complete(Unit) } + protocol.close() + withTimeoutOrNull(5_000) { closed.await() } + http.close() + }, killer = { http.close() }) + } + else -> { + val (process, transport) = spawn(scope) + val tap = TapTransport(transport) + val protocol = Protocol(scope, tap, ProtocolOptions(protocolDebugName = "kotlin-client")) + Conn(scope, protocol, tap, closer = { + // End the child: EOF on its stdin, then wait for it to exit. + runCatching { process.outputStream.close() } + val exited = runInterruptible(Dispatchers.IO) { process.waitFor(5, TimeUnit.SECONDS) } + protocol.close() + if (!exited) { + destroy(process) + throw ClientMain.StepFailure("the agent did not exit within 5 s of EOF") + } + }, killer = { destroy(process) }) + } + } + } + + fun destroy(p: Process) { + p.descendants().forEach { it.destroyForcibly() } + p.destroyForcibly() + } + + /** + * `bash -c "exec $AGENT_CMD"` with this process's environment, wired like the Kotlin sample's + * createProcessStdioTransport (ProcessTransportUtils.kt), plus the stderr relay. + */ + fun spawn(scope: CoroutineScope): Pair { + val process = ProcessBuilder("bash", "-c", "exec " + System.getenv("AGENT_CMD")) + .redirectInput(ProcessBuilder.Redirect.PIPE) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + Thread({ + process.errorStream.bufferedReader().forEachLine { line -> + println(if (line.startsWith("STEP ")) line else "agent| $line") + } + }, "agent-stderr-relay").apply { isDaemon = true }.start() + val stdout = process.inputStream.bufferedReader(Charsets.UTF_8) + val stdin = process.outputStream.bufferedWriter(Charsets.UTF_8) + val transport = StdioTransport( + parentScope = scope, + ioDispatcher = Dispatchers.IO, + input = flow { while (true) emit(stdout.readLine() ?: break) }, + output = { line -> + stdin.write(line) + stdin.write("\n") + stdin.flush() + }, + name = "client", + ) + return process to transport + } + } + + suspend fun initialize(): com.agentclientprotocol.agent.AgentInfo { + protocol.start() + return client.initialize( + ClientInfo( + protocolVersion = 1, + capabilities = ClientCapabilities( + fs = FileSystemCapability(readTextFile = true, writeTextFile = true), + terminal = true, + elicitation = ElicitationCapabilities(form = ElicitationFormCapabilities(), url = ElicitationUrlCapabilities()), + // Not expressible with the Kotlin model: auth.terminal (AuthCapabilities has no + // terminal field) and session.configOptions.boolean (no session field). + ), + implementation = Implementation("interop-kotlin-client", "1"), + ) + ) + } + + suspend fun close() = closer() + + fun kill() = runCatching { killer() } + + fun params(cwd: String = ClientMain.dir.absolutePathString()) = SessionCreationParameters(cwd, emptyList()) + + private val factory = com.agentclientprotocol.client.ClientOperationsFactory { sid, _ -> Ops(sid.value) } + + suspend fun newSession(cwd: String = ClientMain.dir.absolutePathString()): ClientSession = + client.newSession(params(cwd), factory).also { sessions[it.sessionId.value] = it } + + suspend fun loadSession(sid: String): ClientSession = + client.loadSession(SessionId(sid), params(), factory).also { sessions[sid] = it } + + suspend fun resumeSession(sid: String): ClientSession = + client.resumeSession(SessionId(sid), params(), factory).also { sessions[sid] = it } + + suspend fun forkSession(sid: String): ClientSession = + client.forkSession(SessionId(sid), params(), factory).also { sessions[it.sessionId.value] = it } + + /** A prompt through the SDK's ClientSession.prompt flow; updates are recorded as they arrive. */ + suspend fun prompt( + session: ClientSession, + text: String, + meta: JsonElement? = null, + onUpdate: suspend (SessionUpdate) -> Unit = {}, + ): PromptResponse { + var response: PromptResponse? = null + session.prompt(listOf(ContentBlock.Text(text)), meta).collect { e -> + when (e) { + is Event.SessionUpdateEvent -> { + ClientMain.record(this, session.sessionId.value, e.update) + onUpdate(e.update) + } + is Event.PromptResponseEvent -> response = e.response + } + } + return response ?: throw ClientMain.StepFailure("the prompt flow ended without a response") + } + + fun updates(sid: String): List = updates[sid] ?: emptyList() + + fun chunks(sid: String): List = + updates(sid).filterIsInstance().mapNotNull { textOf(it.content) } + + private class Term(val process: Process, val output: StringBuffer, val reader: Thread) + + /** What the client does for the agent, per session. */ + inner class Ops(private val sid: String) : ClientSessionOperations { + override suspend fun requestPermissions( + toolCall: SessionUpdate.ToolCallUpdate, + permissions: List, + _meta: JsonElement?, + ): RequestPermissionResponse { + this@Conn.permissions.computeIfAbsent(sid) { AtomicInteger() }.incrementAndGet() + if (_meta != null) permissionMeta[sid] = _meta + println(" permission request $sid: ${permissions.map { it.optionId.value }} _meta=$_meta") + if (sid in holdCancel) { + // perm.cancelled: cancel the turn while the request is pending, then answer cancelled. + cancelledAt[sid] = System.nanoTime() + sessions[sid]?.cancel() + return RequestPermissionResponse(RequestPermissionOutcome.Cancelled, _meta) + } + val chosen = permissions.firstOrNull { it.kind == PermissionOptionKind.ALLOW_ONCE } ?: permissions.first() + // The request's _meta goes back on the response (meta.permission). + return RequestPermissionResponse(RequestPermissionOutcome.Selected(chosen.optionId), _meta) + } + + override suspend fun notify(notification: SessionUpdate, _meta: JsonElement?) { + ClientMain.record(this@Conn, sid, notification) + } + + override suspend fun fsReadTextFile(path: String, line: UInt?, limit: UInt?, _meta: JsonElement?): ReadTextFileResponse { + println(" fs/read_text_file $path line=$line limit=$limit") + if (path.endsWith("slow.txt")) { + // cancel-request.agent: wait until the agent's $/cancel_request cancels this handler. + delay(10_000) + } + val file = Path.of(path) + if (!Files.isRegularFile(file)) throw JsonRpcException(-32002, "Resource not found: $path") + val content = withContext(Dispatchers.IO) { file.readText() } + if (line == null && limit == null) return ReadTextFileResponse(content) + val lines = content.split('\n').let { if (content.endsWith("\n")) it.dropLast(1) else it } + val from = ((line ?: 1u).toInt() - 1).coerceAtLeast(0) + val selected = lines.drop(from).let { if (limit != null) it.take(limit.toInt()) else it } + return ReadTextFileResponse(selected.joinToString("") { it + "\n" }) + } + + override suspend fun fsWriteTextFile(path: String, content: String, _meta: JsonElement?): WriteTextFileResponse { + println(" fs/write_text_file $path") + withContext(Dispatchers.IO) { Path.of(path).writeText(content) } + return WriteTextFileResponse() + } + + override suspend fun terminalCreate( + command: String, + args: List, + cwd: String?, + env: List, + outputByteLimit: ULong?, + _meta: JsonElement?, + ): CreateTerminalResponse { + println(" terminal/create $command $args") + val pb = ProcessBuilder(listOf(command) + args).redirectErrorStream(true) + if (cwd != null) pb.directory(File(cwd)) + env.forEach { pb.environment()[it.name] = it.value } + val process = withContext(Dispatchers.IO) { pb.start() } + val output = StringBuffer() + val reader = Thread({ + runCatching { + process.inputStream.bufferedReader().use { r -> + val buf = CharArray(4096) + while (true) { + val n = r.read(buf) + if (n < 0) break + output.append(buf, 0, n) + } + } + } + }, "terminal-output").apply { isDaemon = true; start() } + val id = "term-${process.pid()}" + terminals[id] = Term(process, output, reader) + return CreateTerminalResponse(id) + } + + private fun term(id: String) = terminals[id] ?: throw JsonRpcException(-32002, "Terminal not found: $id") + + override suspend fun terminalOutput(terminalId: String, _meta: JsonElement?): TerminalOutputResponse { + val t = term(terminalId) + val status = if (t.process.isAlive) null else { + withContext(Dispatchers.IO) { t.reader.join(1_000) } + TerminalExitStatus(exitCode = t.process.exitValue().toUInt()) + } + return TerminalOutputResponse(t.output.toString(), truncated = false, exitStatus = status) + } + + override suspend fun terminalWaitForExit(terminalId: String, _meta: JsonElement?): WaitForTerminalExitResponse { + val t = term(terminalId) + val code = runInterruptible(Dispatchers.IO) { t.process.waitFor() } + withContext(Dispatchers.IO) { t.reader.join(1_000) } + // A process killed by a signal reports 128+n on the JVM. + return if (code > 128) WaitForTerminalExitResponse(signal = "SIG${code - 128}") + else WaitForTerminalExitResponse(exitCode = code.toUInt()) + } + + override suspend fun terminalKill(terminalId: String, _meta: JsonElement?): KillTerminalCommandResponse { + val t = term(terminalId) + t.process.descendants().forEach { it.destroy() } + t.process.destroy() + return KillTerminalCommandResponse() + } + + override suspend fun terminalRelease(terminalId: String, _meta: JsonElement?): ReleaseTerminalResponse { + terminals.remove(terminalId)?.let { t -> + if (t.process.isAlive) { + t.process.descendants().forEach { it.destroyForcibly() } + t.process.destroyForcibly() + } + } + return ReleaseTerminalResponse() + } + + override suspend fun createElicitation(request: CreateElicitationRequest): CreateElicitationResponse { + println(" elicitation/create ${request.mode}") + return when (request.mode) { + is ElicitationMode.Form -> CreateElicitationResponse( + ElicitationAction.Accept(mapOf("name" to ElicitationContentValue.StringValue("interop"))) + ) + is ElicitationMode.Url -> CreateElicitationResponse(ElicitationAction.Accept(null)) + } + } + + override suspend fun completeElicitation(notification: CompleteElicitationNotification) { + println(" elicitation/complete ${notification.elicitationId}") + elicitationsCompleted[notification.elicitationId.value] = System.nanoTime() + } + } +} + +/** The step catalogue, client side. Each body returns its PASS detail or throws. */ +object Steps { + private val c = ClientMain + private fun check(ok: Boolean, failure: () -> String) = c.check(ok, failure) + private fun main() = c.main() + private fun ms(since: Long) = (System.nanoTime() - since) / 1_000_000 + + /** Wait up to [graceMs] for a condition on updates, which may trail the response. */ + private suspend fun await(graceMs: Long = ClientMain.UPDATE_GRACE_MS, failure: () -> String, condition: () -> Boolean) { + val deadline = System.nanoTime() + graceMs * 1_000_000 + while (!condition()) { + if (System.nanoTime() > deadline) throw ClientMain.StepFailure(failure()) + delay(20) + } + } + + private fun endTurn(r: PromptResponse, what: String = "stopReason") = + check(r.stopReason == StopReason.END_TURN) { "$what ${r.stopReason}" } + + /** A new session and one prompt; returns the session id and the response. */ + private suspend fun promptNew(text: String, meta: JsonElement? = null): Pair { + val conn = main() + val s = conn.newSession() + return s.sessionId.value to conn.prompt(s, text, meta) + } + + private inline fun first(sid: String): T? = main().updates(sid).filterIsInstance().firstOrNull() + + private suspend fun expectChunk(sid: String, text: String) = + await(failure = { "no chunk ${JsonPrimitive(abbreviate(text)).toString()} in ${abbreviate(main().chunks(sid).toString())}" }) { + main().chunks(sid).contains(text) + } + + private suspend fun echoedCaps(): JsonObject { + val (sid, r) = promptNew("#echo-caps") + endTurn(r) + await(failure = { "no capabilities chunk" }) { main().chunks(sid).isNotEmpty() } + val chunk = main().chunks(sid).joinToString("") + return try { + Json.parseToJsonElement(chunk).jsonObject + } catch (e: Exception) { + throw ClientMain.StepFailure("the chunk is not a JSON object: ${abbreviate(chunk)}") + } + } + + private fun JsonObject.at(vararg path: String): JsonElement? { + var e: JsonElement? = this + for (p in path) e = (e as? JsonObject)?.get(p) + return e + } + + private fun info() = ClientMain.agentInfo ?: throw ClientMain.StepFailure("no initialize response: init.initialize did not run first") + + /** Starts a prompt in the background; [firstTick] completes on the first "tick" chunk. */ + private class Running(val job: Deferred, val firstTick: CompletableDeferred) + + private fun CoroutineScope.startSlow(session: ClientSession, text: String = "#slow"): Running { + val tick = CompletableDeferred() + val job = async { + main().prompt(session, text) { u -> + if (u is SessionUpdate.AgentMessageChunk && textOf(u.content) == "tick") tick.complete(Unit) + } + } + return Running(job, tick) + } + + private fun stop(reason: String, expected: StopReason): suspend () -> String = { + val (_, r) = promptNew("#stop $reason") + check(r.stopReason == expected) { "stopReason ${r.stopReason}" } + "stopReason $reason" + } + + private fun bigPrompt(n: Int): suspend () -> String = { + val (sid, r) = promptNew("#len " + "x".repeat(n)) + endTurn(r) + expectChunk(sid, "len=$n") + "the agent counted $n characters" + } + + private fun bigUpdate(n: Int): suspend () -> String = { + val (sid, r) = promptNew("#big $n") + endTurn(r) + await(failure = { "no chunk of $n characters; chunk sizes ${main().chunks(sid).map { it.length }}" }) { + main().chunks(sid).any { it.length == n && it.all { ch -> ch == 'x' } } + } + "one agent_message_chunk of $n characters" + } + + private fun emitStep(kind: String, verify: suspend (String, PromptResponse) -> String): suspend () -> String = { + val (sid, r) = promptNew("#emit $kind") + verify(sid, r) + } + + val all: Map String> = linkedMapOf( + "init.initialize" to { + val conn = Conn.open() + ClientMain.main = conn + val info = conn.initialize() + ClientMain.agentInfo = info + check(info.protocolVersion == 1) { "protocolVersion ${info.protocolVersion}" } + "protocolVersion=1 agentInfo=${info.implementation}" + }, + "init.agent-capabilities" to { + val caps = info().capabilities + val sc = caps.sessionCapabilities + check(caps.loadSession) { "loadSession is not true" } + check(sc.list != null && sc.resume != null && sc.close != null && sc.delete != null) { + "sessionCapabilities $sc lacks list, resume, close or delete" + } + "loadSession; sessionCapabilities list, resume, close, delete" + }, + "init.client-capabilities" to { + val caps = echoedCaps() + check(caps.at("fs", "readTextFile") == JsonPrimitive(true) && caps.at("fs", "writeTextFile") == JsonPrimitive(true) + && caps.at("terminal") == JsonPrimitive(true)) { "echoed capabilities ${compact(caps)}" } + "the agent echoed fs.readTextFile, fs.writeTextFile and terminal" + }, + "init.auth-methods" to { + val ids = info().authMethods.map { it.id.value } + check(Fixtures.AUTH_METHOD in ids) { "authMethods $ids" } + "authMethods $ids" + }, + "init.agent-info" to { + val name = info().implementation?.name + check(name?.startsWith("interop-") == true) { "agentInfo.name $name" } + "agentInfo.name $name" + }, + "init.config-boolean" to { + val caps = echoedCaps() + check(caps.at("session", "configOptions", "boolean") != null) { + "the echoed capabilities have no session.configOptions.boolean: the Kotlin ClientCapabilities cannot advertise it (${compact(caps)})" + } + "session.configOptions.boolean echoed" + }, + "auth.authenticate" to { + main().client.authenticate(AuthMethodId(Fixtures.AUTH_METHOD)) + "authenticate answered" + }, + "auth.logout" to { + main().client.logout() + "logout answered" + }, + "auth.logout-capability" to { + check(info().capabilities.auth.logout != null) { "agentCapabilities.auth.logout is absent" } + "agentCapabilities.auth.logout present" + }, + "auth.terminal" to { + val terminal = info().authMethods.filterIsInstance() + check(terminal.any { it.id.value == Fixtures.TERMINAL_AUTH_METHOD }) { + "no terminal auth method in ${info().authMethods.map { it.id.value }}: the Kotlin AuthCapabilities cannot advertise auth.terminal" + } + "terminal auth method listed" + }, + "session.new" to { + val s = main().newSession() + check(s.sessionId.value.isNotEmpty()) { "empty sessionId" } + "sessionId=${s.sessionId}" + }, + "session.load" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + conn.prompt(s, "hello load") + val loaded = conn.loadSession(sid) + val before = conn.chunks(sid).size + endTurn(conn.prompt(loaded, "after load"), "after load: stopReason") + await(failure = { "no agent_message_chunk after the load" }) { conn.chunks(sid).size > before } + "loaded $sid; prompt after load end_turn" + }, + "session.load-replay" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + conn.prompt(s, "replay me") + val from = conn.updates(sid).size + conn.loadSession(sid) + delay(ClientMain.REPLAY_GRACE_MS) + val replayed = conn.updates(sid).drop(from) + val user = replayed.filterIsInstance().any { textOf(it.content) == "replay me" } + val agent = replayed.filterIsInstance().any { textOf(it.content)?.contains("replay me") == true } + check(user && agent) { "replayed ${replayed.map(::kindOf)}: user_message_chunk=$user agent_message_chunk=$agent" } + "replay: ${replayed.size} updates" + }, + "session.resume" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + conn.prompt(s, "before resume") + val from = conn.updates(sid).size + val resumed = conn.resumeSession(sid) + delay(ClientMain.UPDATE_GRACE_MS) + val extra = conn.updates(sid).drop(from) + check(extra.isEmpty()) { "resume replayed ${extra.map(::kindOf)}" } + endTurn(conn.prompt(resumed, "after resume"), "after resume: stopReason") + "resumed $sid without replay" + }, + "session.list" to { + val conn = main() + val cwd = ClientMain.dir.resolve("list").also { Files.createDirectories(it) }.absolutePathString() + val s = conn.newSession(cwd) + val listed = conn.client.listSessions(cwd = cwd).toList() + check(listed.any { it.sessionId == s.sessionId && it.cwd == cwd }) { "listed ${listed.map { "${it.sessionId}@${it.cwd}" }}" } + "listed ${listed.size} session(s) for $cwd" + }, + "session.close" to { + val conn = main() + val s = conn.newSession() + coroutineScope { + val run = startSlow(s) + run.firstTick.await() + s.close() + val closedAt = System.nanoTime() + val outcome = withTimeoutOrNull(5_000) { + try { + "stopReason ${run.job.await().stopReason}" + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "error ${ClientMain.describe(e)}" + } + } + check(outcome != null) { "the #slow prompt did not end within 5 s of the close" } + val after = try { + conn.prompt(s, "after close") + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e + } + check(after != null) { "the prompt after close succeeded" } + "close answered; #slow ended ${ms(closedAt)} ms after it ($outcome); prompt after close: ${ClientMain.describe(after!!)}" + } + }, + "session.delete" to { + val conn = main() + val s = conn.newSession() + conn.client.deleteSession(s.sessionId) + conn.client.deleteSession(SessionId("no-such-session")) + "deleted ${s.sessionId} and no-such-session" + }, + "session.multi" to { + val conn = main() + val a = conn.newSession() + val b = conn.newSession() + coroutineScope { + val ra = async { conn.prompt(a, "multi A") } + val rb = async { conn.prompt(b, "multi B") } + endTurn(ra.await(), "multi A: stopReason") + endTurn(rb.await(), "multi B: stopReason") + } + await(failure = { "chunks A ${conn.chunks(a.sessionId.value)} B ${conn.chunks(b.sessionId.value)}" }) { + conn.chunks(a.sessionId.value).joinToString("") == "echo: multi A" && + conn.chunks(b.sessionId.value).joinToString("") == "echo: multi B" + } + "both end_turn, each session got its own chunks" + }, + "session.fork" to { + val conn = main() + val s = conn.newSession() + conn.prompt(s, "before fork") + val forked = conn.forkSession(s.sessionId.value) + check(forked.sessionId != s.sessionId) { "the fork returned the original sessionId" } + endTurn(conn.prompt(forked, "in fork"), "in fork: stopReason") + "forked ${s.sessionId} into ${forked.sessionId}" + }, + "update.agent_message_chunk" to { + val (sid, r) = promptNew("hello") + endTurn(r) + await(failure = { "chunks ${main().chunks(sid)} do not spell \"echo: hello\"" }) { main().chunks(sid).joinToString("") == "echo: hello" } + "end_turn; chunks spell \"echo: hello\"" + }, + "update.user_message_chunk" to emitStep("user_message_chunk") { sid, r -> + endTurn(r) + await(failure = { "no user_message_chunk \"user-chunk\"" }) { + main().updates(sid).filterIsInstance().any { textOf(it.content) == "user-chunk" } + } + "user_message_chunk \"user-chunk\"" + }, + "update.agent_thought_chunk" to emitStep("agent_thought_chunk") { sid, r -> + endTurn(r) + await(failure = { "no agent_thought_chunk \"thinking\"" }) { + main().updates(sid).filterIsInstance().any { textOf(it.content) == "thinking" } + } + "agent_thought_chunk \"thinking\"" + }, + "update.tool_call" to emitStep("tool_call") { sid, _ -> + await(failure = { "no tool_call call-1 in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { + it.toolCallId.value == "call-1" && it.title == "interop tool" && it.kind == ToolKind.READ && it.status == ToolCallStatus.PENDING + } + } + "tool_call call-1 \"interop tool\" read pending" + }, + "update.tool_call_update" to emitStep("tool_call_update") { sid, _ -> + await(failure = { "no tool_call then tool_call_update call-1 completed \"tool output\" in ${main().updates(sid)}" }) { + val us = main().updates(sid) + val call = us.indexOfFirst { it is SessionUpdate.ToolCall && it.toolCallId.value == "call-1" } + val upd = us.indexOfFirst { + it is SessionUpdate.ToolCallUpdate && it.toolCallId.value == "call-1" && it.status == ToolCallStatus.COMPLETED && + it.content.orEmpty().any { c -> c is ToolCallContent.Content && textOf(c.content) == "tool output" } + } + call >= 0 && upd > call + } + "tool_call then tool_call_update completed \"tool output\"" + }, + "update.tool_call-name" to emitStep("tool_call name=read_file") { sid, _ -> + await(failure = { "no tool_call" }) { first(sid) != null } + throw ClientMain.StepFailure("tool_call received, but the Kotlin v1 SessionUpdate.ToolCall has no name field to read") + }, + "update.plan" to emitStep("plan") { sid, _ -> + val want = listOf( + Triple("step one", PlanEntryPriority.HIGH, PlanEntryStatus.PENDING), + Triple("step two", PlanEntryPriority.LOW, PlanEntryStatus.COMPLETED), + ) + await(failure = { "no plan with the fixture entries in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { p -> + p.entries.map { Triple(it.content, it.priority, it.status) } == want + } + } + "plan with the two fixture entries" + }, + "update.available_commands_update" to emitStep("available_commands_update") { sid, _ -> + await(failure = { "no available_commands_update with command interop (hint args) in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { u -> + u.availableCommands.size == 1 && u.availableCommands[0].name == "interop" && + (u.availableCommands[0].input as? AvailableCommandInput.Unstructured)?.hint == "args" + } + } + "available_commands_update: interop (hint args)" + }, + "update.current_mode_update" to emitStep("current_mode_update") { sid, _ -> + await(failure = { "no current_mode_update interop-mode-b" }) { + main().updates(sid).filterIsInstance().any { it.currentModeId.value == Fixtures.MODE_B } + } + "current_mode_update interop-mode-b" + }, + "update.config_option_update" to emitStep("config_option_update") { sid, _ -> + await(failure = { "no config_option_update with model at model-b in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { u -> + u.configOptions.any { it.id.value == "model" && (it as? SessionConfigOption.Select)?.currentValue?.value == "model-b" } + } + } + "config_option_update: model at model-b" + }, + "update.session_info_update" to emitStep("session_info_update") { sid, _ -> + await(failure = { "no session_info_update \"interop title\" in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { it.title == "interop title" } + } + "session_info_update \"interop title\"" + }, + "update.usage_update" to emitStep("usage_update") { sid, _ -> + await(failure = { "no usage_update 100/1000 0.01 USD in ${main().updates(sid)}" }) { + main().updates(sid).filterIsInstance().any { + it.used == 100L && it.size == 1000L && it.cost?.amount == 0.01 && it.cost?.currency == "USD" + } + } + "usage_update used 100 size 1000 cost 0.01 USD" + }, + "update.unknown" to emitStep("unknown") { sid, r -> + endTurn(r) + expectChunk(sid, "after-unknown") + val kept = main().updates(sid).filterIsInstance().map { it.sessionUpdateType } + "\"after-unknown\" arrived and end_turn; unknown update surfaced as $kept" + }, + "stop.max_tokens" to stop("max_tokens", StopReason.MAX_TOKENS), + "stop.refusal" to stop("refusal", StopReason.REFUSAL), + "stop.max_turn_requests" to stop("max_turn_requests", StopReason.MAX_TURN_REQUESTS), + // Unknown enum values. Every v1 enum is a closed Kotlin enum without a fallback, so a tool_call + // or plan carrying one fails to decode and is dropped, and so does a PromptResponse. + "enum.tool_call" to { + val (sid, r) = promptNew("#enum tool_call") + endTurn(r) + expectChunk(sid, "after-enum") + val call = main().updates(sid).filterIsInstance().firstOrNull { it.toolCallId.value == "call-enum" } + ?: throw ClientMain.StepFailure("no tool_call call-enum: the update was dropped (after-enum and end_turn arrived); " + + "ToolCallStatus and ToolKind are closed enums") + throw ClientMain.StepFailure("tool_call call-enum decoded with status ${call.status} kind ${call.kind}") + }, + "enum.plan" to { + val (sid, r) = promptNew("#enum plan") + endTurn(r) + expectChunk(sid, "after-enum") + val plan = main().updates(sid).filterIsInstance().firstOrNull() + ?: throw ClientMain.StepFailure("no plan: the update was dropped (after-enum and end_turn arrived); " + + "PlanEntryPriority and PlanEntryStatus are closed enums") + throw ClientMain.StepFailure("plan decoded as ${plan.entries}") + }, + "enum.stop" to { + val conn = main() + val s = conn.newSession() + val first = try { + "answered ${conn.prompt(s, "#enum stop").stopReason}" + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + "failed: ${ClientMain.describe(e)}" + } + val after = try { + conn.prompt(s, "after enum").stopReason.toString() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ClientMain.describe(e) + } + throw ClientMain.StepFailure("the #enum stop prompt $first (StopReason is a closed enum); \"after enum\": $after") + }, + "enum.audience" to { + // A Kotlin Role cannot hold interop_future_role, so the prompt goes out untyped; its + // updates still reach the session's operations. + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + val params = buildJsonObject { + put("sessionId", sid) + putJsonArray("prompt") { + addJsonObject { + put("type", "text") + put("text", "#enum audience") + putJsonObject("annotations") { + putJsonArray("audience") { Fixtures.unknownAudience.forEach { add(it) } } + } + } + } + } + val r = conn.protocol.sendRequestRaw(MethodName("session/prompt"), params) + val stop = ((r as? JsonObject)?.get("stopReason") as? JsonPrimitive)?.contentOrNull + check(stop == "end_turn") { "stopReason $stop" } + expectChunk(sid, "audience: user,interop_future_role") + "audience: user,interop_future_role; end_turn (prompt sent untyped)" + }, + "mode.set" to { + val conn = main() + val s = conn.newSession() + check(s.availableModes.any { it.id.value == Fixtures.MODE_B }) { "session/new returned modes ${s.availableModes.map { it.id }}" } + s.setMode(SessionModeId(Fixtures.MODE_B)) + "set_mode interop-mode-b answered" + }, + "config.on-new" to { + val s = main().newSession() + check(s.configOptionsSupported) { "session/new returned no configOptions" } + val model = s.configOptions.value.firstOrNull { it.id.value == "model" } as? SessionConfigOption.Select + check(model?.currentValue?.value == "model-a") { "configOptions ${s.configOptions.value}" } + "select option model at model-a" + }, + "config.select" to { + val s = main().newSession() + val r = s.setConfigOption(SessionConfigId("model"), SessionConfigOptionValue.StringValue("model-b")) + val model = r.configOptions.firstOrNull { it.id.value == "model" } as? SessionConfigOption.Select + check(model?.currentValue?.value == "model-b") { "response configOptions ${r.configOptions}" } + "response lists ${r.configOptions.size} option(s), model at model-b" + }, + "config.boolean" to { + val s = main().newSession() + val r = s.setConfigOption(SessionConfigId("verbose"), SessionConfigOptionValue.BoolValue(true)) + val verbose = r.configOptions.firstOrNull { it.id.value == "verbose" } as? SessionConfigOption.BooleanOption + check(verbose?.currentValue == true) { "response configOptions ${r.configOptions}" } + "verbose at true" + }, + "config.grouped" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + endTurn(conn.prompt(s, "#config grouped")) + await(failure = { "no config_option_update in ${main().updates(sid)}" }) { + main().updates(sid).any { it is SessionUpdate.ConfigOptionUpdate } + } + val effort = main().updates(sid).filterIsInstance().first() + .configOptions.firstOrNull { it.id.value == "effort" } as? SessionConfigOption.Select + ?: throw ClientMain.StepFailure("no select effort in the config_option_update") + val grouped = effort.options as? SessionConfigSelectOptions.Grouped + ?: throw ClientMain.StepFailure("effort options are not grouped: ${effort.options}") + check(grouped.groups.map { it.group.value } == listOf("fast", "deep")) { "groups ${grouped.groups}" } + val values = grouped.groups.flatMap { g -> g.options.map { it.value.value } } + check(values == Fixtures.effortValues) { "effort values $values" } + val r = s.setConfigOption(SessionConfigId("effort"), SessionConfigOptionValue.StringValue("effort-high")) + val set = r.configOptions.firstOrNull { it.id.value == "effort" } as? SessionConfigOption.Select + val model = r.configOptions.firstOrNull { it.id.value == "model" } as? SessionConfigOption.Select + check(set?.currentValue?.value == "effort-high" && model?.currentValue?.value == "model-a") { + "response configOptions ${r.configOptions}" + } + "effort grouped fast/deep, values $values; set to effort-high" + }, + "perm.selected" to { + val (sid, r) = promptNew("#permission allow") + endTurn(r) + expectChunk(sid, "permission: selected allow") + val asked = main().permissions[sid]?.get() ?: 0 + check(asked == 1) { "$asked permission requests, expected 1" } + "one permission request; selected allow; end_turn" + }, + "perm.cancelled" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId.value + conn.holdCancel += sid + val r = conn.prompt(s, "#permission hold") + val cancelledAt = conn.cancelledAt[sid] ?: throw ClientMain.StepFailure("no permission request arrived") + val after = ms(cancelledAt) + check(r.stopReason == StopReason.CANCELLED) { "stopReason ${r.stopReason}" } + check(after <= 5_000) { "cancelled $after ms after the cancel" } + "stopReason cancelled $after ms after session/cancel" + }, + "fs.write" to { + val file = ClientMain.dir.resolve("fs-write.txt") + val (sid, r) = promptNew("#fs write ${file.absolutePathString()} interop write") + endTurn(r) + expectChunk(sid, "fs write ok") + check(Files.exists(file)) { "$file was not written" } + val content = file.readText() + check(content == "interop write") { "file content \"$content\"" } + "written through the client: \"interop write\"" + }, + "fs.read" to { + val file = ClientMain.dir.resolve("fs-read.txt").also { it.writeText(Fixtures.FS_READ_CONTENT) } + val (sid, r) = promptNew("#fs read ${file.absolutePathString()}") + endTurn(r) + expectChunk(sid, Fixtures.FS_READ_CONTENT) + "the agent read the fixture content" + }, + "fs.read-range" to { + val file = ClientMain.dir.resolve("fs-read.txt").also { it.writeText(Fixtures.FS_READ_CONTENT) } + val (sid, r) = promptNew("#fs read-range ${file.absolutePathString()} line=2 limit=1") + endTurn(r) + await(failure = { "no chunk \"line2\" in ${main().chunks(sid)}" }) { main().chunks(sid).any { it.trim() == "line2" } } + "line=2 limit=1 read \"line2\"" + }, + "fs.read-missing" to { + val (sid, r) = promptNew("#fs read-missing ${ClientMain.dir.resolve("no-such-file.txt").absolutePathString()}") + endTurn(r) + await(failure = { "no chunk starting \"fs read error\" in ${main().chunks(sid)}" }) { + main().chunks(sid).any { it.startsWith("fs read error") } + } + "the agent got an error: ${main().chunks(sid).first { it.startsWith("fs read error") }}" + }, + "term.run" to { + val (sid, r) = promptNew("#terminal run echo hi") + endTurn(r) + expectChunk(sid, "terminal: hi exit=0") + "terminal: hi exit=0" + }, + "term.kill" to { + val t0 = System.nanoTime() + val (sid, r) = promptNew("#terminal kill sleep 30") + endTurn(r) + expectChunk(sid, "terminal killed") + check(ms(t0) <= 5_000) { "terminal killed after ${ms(t0)} ms" } + "terminal killed within ${ms(t0)} ms" + }, + "elicit.form" to { + val (sid, r) = promptNew("#elicit form") + endTurn(r) + await(failure = { "no matching elicit chunk in ${main().chunks(sid)}" }) { + main().chunks(sid).any { ch -> + ch.startsWith("elicit: accept ") && runCatching { + Json.parseToJsonElement(ch.removePrefix("elicit: accept ")) == buildJsonObject { put("name", "interop") } + }.getOrDefault(false) + } + } + "elicit: accept {\"name\":\"interop\"}" + }, + "elicit.complete" to { + val conn = main() + val s = conn.newSession() + conn.elicitationsCompleted.remove("elic-1") + endTurn(conn.prompt(s, "#elicit url")) + await(failure = { "no elicitation/complete for elic-1" }) { conn.elicitationsCompleted.containsKey("elic-1") } + "elicitation/complete elic-1 arrived" + }, + "cancel.prompt" to { + val conn = main() + val s = conn.newSession() + coroutineScope { + val run = startSlow(s) + run.firstTick.await() + s.cancel() + val at = System.nanoTime() + val r = withTimeoutOrNull(5_000) { run.job.await() } + ?: throw ClientMain.StepFailure("no response within 5 s of session/cancel") + check(r.stopReason == StopReason.CANCELLED) { "stopReason ${r.stopReason}" } + "stopReason cancelled ${ms(at)} ms after session/cancel" + } + }, + "cancel.prompt-while-cancelling" to { + val conn = main() + val s = conn.newSession() + val sid = s.sessionId + coroutineScope { + val run = startSlow(s, "#slow grace=1000") + run.firstTick.await() + s.cancel() + // ClientSession allows one prompt at a time, so the overlapping prompt goes out raw. + val during = try { + AcpMethod.AgentMethods.V1.SessionPrompt(conn.protocol, PromptRequest(sid, listOf(ContentBlock.Text("during cancel")))) + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + e + } + val first = run.job.await() + check(first.stopReason == StopReason.CANCELLED) { "first prompt stopReason ${first.stopReason}" } + check(during != null && errorCode(during) == -32600) { + "\"during cancel\" ${if (during == null) "succeeded" else "failed with ${ClientMain.describe(during)}"}" + } + endTurn(conn.prompt(s, "after cancel"), "after cancel: stopReason") + "cancelled; \"during cancel\" -32600; \"after cancel\" end_turn" + } + }, + "cancel.grace" to { + val conn = main() + val s = conn.newSession() + coroutineScope { + val run = async { conn.prompt(s, "#hang") } + delay(200) + s.cancel() + val at = System.nanoTime() + val r = withTimeoutOrNull(6_000) { run.await() } + ?: throw ClientMain.StepFailure("no response within 6 s of session/cancel") + check(r.stopReason == StopReason.CANCELLED) { "stopReason ${r.stopReason}" } + "stopReason cancelled ${ms(at)} ms after session/cancel" + } + }, + "cancel.max-duration" to { + val conn = main() + val s = conn.newSession() + val t0 = System.nanoTime() + val outcome = withTimeoutOrNull(11_000) { + try { + Result.success(conn.prompt(s, "#hang")) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } ?: throw ClientMain.StepFailure("TIMEOUT: no answer within 11 s") + val elapsed = ms(t0) + val e = outcome.exceptionOrNull() + ?: throw ClientMain.StepFailure("the prompt answered ${outcome.getOrNull()?.stopReason}, expected error -32800") + check(errorCode(e) == -32800) { "the prompt failed with ${ClientMain.describe(e)}, expected -32800" } + check(elapsed >= 7_000) { "-32800 after $elapsed ms, before the 8 s maxPromptDuration" } + endTurn(conn.prompt(s, "after max-duration"), "\"after max-duration\" answered") + "-32800 after $elapsed ms; \"after max-duration\" end_turn" + }, + "cancel-request.client" to { + val conn = main() + val s = conn.newSession() + coroutineScope { + val run = startSlow(s) + run.firstTick.await() + val id = conn.tap.lastOutgoingId("session/prompt") ?: throw ClientMain.StepFailure("no session/prompt id seen") + // Cancelling the call is how the Kotlin SDK sends $/cancel_request for it. + val at = System.nanoTime() + run.job.cancel() + val resp = withTimeoutOrNull(5_000) { conn.tap.response(id).await() } + val sent = conn.tap.sentNotifications.any { + it.method.name == "\$/cancel_request" && (it.params as? JsonObject)?.get("requestId")?.toString() == id.toString() + } + check(sent) { "the SDK sent no \$/cancel_request for request $id" } + check(resp != null) { "no response to the prompt within 5 s of \$/cancel_request" } + val outcome = when (resp) { + is JsonRpcErrorResponse -> "error ${resp.error.code}" + is JsonRpcSuccessResponse -> "stopReason ${(resp.result as? JsonObject)?.get("stopReason")?.jsonPrimitive?.contentOrNull}" + else -> "?" + } + check(outcome == "error -32800" || outcome == "stopReason cancelled") { "the prompt ended with $outcome" } + "\$/cancel_request for $id; the prompt ended ${ms(at)} ms later with $outcome" + } + }, + "cancel-request.agent" to { + val t0 = System.nanoTime() + val (sid, r) = promptNew("#fs read-slow ${ClientMain.dir.resolve("slow.txt").absolutePathString()}") + endTurn(r) + expectChunk(sid, "cancel-request sent") + check(ms(t0) <= 5_000) { "end_turn after ${ms(t0)} ms" } + "\"cancel-request sent\" and end_turn in ${ms(t0)} ms" + }, + "cancel-request.unknown" to { + val conn = main() + val done = conn.tap.completedOutgoing.lastOrNull() ?: throw ClientMain.StepFailure("no completed request to name") + val ids = listOf(RequestId.create(999999), done) + for (id in ids) { + AcpMethod.MetaMethods.CancelRequest(conn.protocol, CancelRequestNotification(id)) + } + val s = conn.newSession() + endTurn(conn.prompt(s, "after cancel-request")) + val errors = conn.tap.errorsFor(ids + RequestId.Null) + check(errors.isEmpty()) { "error responses: ${errors.map { "${it.id}: ${it.error.code}" }}" } + "\$/cancel_request for 999999 and completed request $done: no error; prompt end_turn" + }, + "ext.agent-request" to { + val (sid, r) = promptNew("#ext request ${Fixtures.EXT_METHOD}") + endTurn(r) + await(failure = { "no ext chunk in ${main().chunks(sid)}" }) { + main().chunks(sid).any { it.startsWith("ext: ") && runCatching { Json.parseToJsonElement(it.removePrefix("ext: ")) == Fixtures.extResult }.getOrDefault(false) } + } + "ext: {\"pong\":1}" + }, + "ext.agent-notification" to { + val conn = main() + conn.extNotifications.clear() + val (_, r) = promptNew("#ext notify ${Fixtures.EXT_NOTIFICATION}") + endTurn(r) + await(failure = { "no ${Fixtures.EXT_NOTIFICATION} notification (got ${conn.extNotifications})" }) { + conn.extNotifications.any { it == Fixtures.extParams } + } + "${Fixtures.EXT_NOTIFICATION} arrived with {\"n\":1}" + }, + "ext.client-request" to { + val result = main().protocol.sendRequestRaw(MethodName(Fixtures.EXT_METHOD), Fixtures.extParams) + check(result == Fixtures.extResult) { "result ${compact(result)}" } + "result ${compact(result)}" + }, + "ext.client-notification" to { + main().protocol.sendNotificationRaw(Fixtures.extNotificationMethod, Fixtures.extParams) + val (sid, r) = promptNew("#ext last-notification") + endTurn(r) + expectChunk(sid, "ext last: ${Fixtures.EXT_NOTIFICATION}") + "ext last: ${Fixtures.EXT_NOTIFICATION}" + }, + "meta.prompt" to { + val (sid, r) = promptNew("#meta", Fixtures.meta) + await(failure = { "no \"meta\" chunk" }) { main().chunks(sid).contains("meta") } + val chunk = main().updates(sid).filterIsInstance().first { textOf(it.content) == "meta" } + check(Fixtures.hasMeta(chunk._meta)) { "the \"meta\" chunk's _meta is ${chunk._meta}" } + check(Fixtures.hasMeta(r._meta)) { "the PromptResponse _meta is ${r._meta}" } + "update and PromptResponse _meta interop == m1" + }, + "meta.permission" to { + val (sid, r) = promptNew("#permission allow meta") + endTurn(r) + val meta = main().permissionMeta[sid] + check(Fixtures.hasMeta(meta)) { "the permission request carried _meta $meta" } + "the permission request carried _meta interop == m1" + }, + "error.method-not-found" to { + val conn = main() + val code = try { + conn.protocol.sendRequestRaw(MethodName("interop/no_such_method"), JsonObject(emptyMap())) + null + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + errorCode(e) ?: throw ClientMain.StepFailure("failed without a JSON-RPC code: $e") + } + check(code == -32601) { "interop/no_such_method answered ${code ?: "with a result"}" } + conn.newSession() + "-32601, then session/new succeeded" + }, + "big.prompt-1m" to bigPrompt(1_048_576), + "big.update-1m" to bigUpdate(1_048_576), + "big.prompt-8m" to bigPrompt(8_388_608), + "big.update-8m" to bigUpdate(8_388_608), + "http.reconnect" to { + throw ClientMain.StepFailure("http.reconnect does not apply to ${ClientMain.transport}") + }, + "stdio.eof-exit" to { + check(ClientMain.transport == "stdio") { "stdio.eof-exit does not apply to ${ClientMain.transport}" } + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val (process, transport) = Conn.spawn(scope) + try { + val protocol = Protocol(scope, transport) + val client = Client(protocol) + protocol.start() + client.initialize(ClientInfo(protocolVersion = 1)) + process.outputStream.close() + val at = System.nanoTime() + val exited = runInterruptible(Dispatchers.IO) { process.waitFor(5, TimeUnit.SECONDS) } + check(exited) { "no exit on EOF" } + "the second agent exited ${ms(at)} ms after EOF (exit ${process.exitValue()})" + } finally { + Conn.destroy(process) + scope.cancel() + } + }, + "conn.close" to { + main().close() + "closed" + }, + ) +} diff --git a/integration-testing/programs/kotlin/src/main/kotlin/interop/Common.kt b/integration-testing/programs/kotlin/src/main/kotlin/interop/Common.kt new file mode 100644 index 00000000..5c29d510 --- /dev/null +++ b/integration-testing/programs/kotlin/src/main/kotlin/interop/Common.kt @@ -0,0 +1,172 @@ +package interop + +import com.agentclientprotocol.model.* +import com.agentclientprotocol.rpc.* +import com.agentclientprotocol.transport.Transport +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.serialization.json.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +/** steps.json fixtures, hard-coded as the contract asks. */ +object Fixtures { + const val AUTH_METHOD = "interop-auth" + const val TERMINAL_AUTH_METHOD = "interop-terminal-auth" + const val MODE_A = "interop-mode-a" + const val MODE_B = "interop-mode-b" + const val FS_READ_CONTENT = "line1\nline2\nline3\n" + const val EXT_METHOD = "_interop/ping" + const val EXT_NOTIFICATION = "_interop/note" + val extParams: JsonObject = buildJsonObject { put("n", 1) } + val extResult: JsonObject = buildJsonObject { put("pong", 1) } + val meta: JsonObject = buildJsonObject { put("interop", "m1") } + + val permissionOptions = listOf( + PermissionOption(PermissionOptionId("allow"), "Allow", PermissionOptionKind.ALLOW_ONCE), + PermissionOption(PermissionOptionId("reject"), "Reject", PermissionOptionKind.REJECT_ONCE), + ) + val permissionToolCall = SessionUpdate.ToolCallUpdate( + toolCallId = ToolCallId("perm-1"), + title = "interop permission", + kind = ToolKind.EDIT, + status = ToolCallStatus.PENDING, + ) + + val modes = listOf(SessionMode(SessionModeId(MODE_A), "Mode A"), SessionMode(SessionModeId(MODE_B), "Mode B")) + + fun modelOption(current: String): SessionConfigOption = SessionConfigOption.select( + id = "model", + name = "Model", + currentValue = current, + options = SessionConfigSelectOptions.Flat( + listOf( + SessionConfigSelectOption(SessionConfigValueId("model-a"), "Model A"), + SessionConfigSelectOption(SessionConfigValueId("model-b"), "Model B"), + ) + ), + ) + + /** fixtures.groupedConfigOption: the select effort, its options in the groups fast and deep. */ + fun effortOption(current: String): SessionConfigOption = SessionConfigOption.select( + id = "effort", + name = "Effort", + currentValue = current, + options = SessionConfigSelectOptions.Grouped( + listOf( + SessionConfigSelectGroup(SessionConfigGroupId("fast"), "Fast", + listOf(SessionConfigSelectOption(SessionConfigValueId("effort-low"), "Low"))), + SessionConfigSelectGroup(SessionConfigGroupId("deep"), "Deep", + listOf( + SessionConfigSelectOption(SessionConfigValueId("effort-medium"), "Medium"), + SessionConfigSelectOption(SessionConfigValueId("effort-high"), "High"), + )), + ) + ), + ) + + val effortValues = listOf("effort-low", "effort-medium", "effort-high") + + /** fixtures.unknownEnums: values the v1 schema does not define. */ + const val FUTURE_STATUS = "interop_future_status" + const val FUTURE_STOP = "interop_future_stop" + val unknownAudience = listOf("user", "interop_future_role") + + fun verboseOption(current: Boolean): SessionConfigOption = + SessionConfigOption.boolean(id = "verbose", name = "Verbose", currentValue = current) + + /** The fixture `_meta` value, `interop == m1`, on any JSON element. */ + fun hasMeta(meta: JsonElement?): Boolean = + (meta as? JsonObject)?.get("interop")?.let { (it as? JsonPrimitive)?.contentOrNull } == "m1" + + /** Extension method objects: only their names matter to the raw send/handler API. */ + val extRequestMethod = AcpMethod.AcpRequestResponseMethod( + EXT_METHOD, AuthenticateRequest.serializer(), AuthenticateResponse.serializer() + ) + val extNotificationMethod = AcpMethod.AcpNotificationMethod(EXT_NOTIFICATION, CancelRequestNotification.serializer()) + + fun notificationMethod(name: String) = AcpMethod.AcpNotificationMethod(name, CancelRequestNotification.serializer()) +} + +/** One STEP line: `STEP PASS|FAIL ( ms) -> `, on one line. */ +fun stepLine(id: String, ok: Boolean, ms: Long, detail: String): String = + "STEP $id ${if (ok) "PASS" else "FAIL"} ($ms ms) -> ${detail.replace('\n', ' ').replace('\r', ' ')}" + +fun abbreviate(s: String, max: Int = 300): String = if (s.length > max) s.substring(0, max) + "... (${s.length} chars)" else s + +fun compact(e: JsonElement): String = Json.encodeToString(JsonElement.serializer(), e) + +/** + * A transport that records what crosses it, delegating everything to the real transport. + * + * It lets a program check what the SDK put on the wire where the typed API hides it: the request + * id of an in-flight prompt, the `$/cancel_request` the SDK sent when a call was cancelled, and the + * response (`-32800`, `stopReason`) the counterpart sent for it after the local call gave up. + */ +class TapTransport(private val delegate: Transport) : Transport by delegate { + private val lastOutgoing = ConcurrentHashMap() + private val responses = ConcurrentHashMap>() + val sentNotifications = CopyOnWriteArrayList() + val completedOutgoing = CopyOnWriteArrayList() + private val outgoingIds = ConcurrentHashMap.newKeySet() + + init { + delegate.onFrame { observe(it, incoming = true) } + } + + override fun send(frame: TransportFrame) { + observe(frame, incoming = false) + delegate.send(frame) + } + + private fun observe(frame: TransportFrame, incoming: Boolean) { + val entries = when (frame) { + is TransportFrame.Batch -> frame.entries + is TransportFrame.Entry -> listOf(frame) + } + for (entry in entries) { + val message = (entry as? TransportFrame.Single)?.message ?: continue + when { + !incoming && message is JsonRpcRequest -> { + lastOutgoing[message.method.name] = message.id + outgoingIds += message.id + } + !incoming && message is JsonRpcNotification -> sentNotifications += message + incoming && message is JsonRpcResponse -> { + if (message.id in outgoingIds) completedOutgoing += message.id + responses.computeIfAbsent(message.id) { CompletableDeferred() }.complete(message) + } + } + } + } + + /** The id of the last request sent with this method. */ + fun lastOutgoingId(method: String): RequestId? = lastOutgoing[method] + + /** The counterpart's response to request [id], whenever it arrives (or arrived). */ + fun response(id: RequestId): Deferred = responses.computeIfAbsent(id) { CompletableDeferred() } + + fun responseIfAny(id: RequestId): JsonRpcResponse? = responses[id]?.takeIf { it.isCompleted }?.getCompleted() + + /** Error responses received for any of [ids] (including null). */ + fun errorsFor(ids: Collection): List = + ids.mapNotNull { responseIfAny(it) as? JsonRpcErrorResponse } +} + +/** `upd_` names: the wire `sessionUpdate` value, `other` for unknown kinds. */ +fun kindOf(u: SessionUpdate): String = when (u) { + is SessionUpdate.UserMessageChunk -> "user_message_chunk" + is SessionUpdate.AgentMessageChunk -> "agent_message_chunk" + is SessionUpdate.AgentThoughtChunk -> "agent_thought_chunk" + is SessionUpdate.ToolCall -> "tool_call" + is SessionUpdate.ToolCallUpdate -> "tool_call_update" + is SessionUpdate.PlanUpdate -> "plan" + is SessionUpdate.AvailableCommandsUpdate -> "available_commands_update" + is SessionUpdate.CurrentModeUpdate -> "current_mode_update" + is SessionUpdate.ConfigOptionUpdate -> "config_option_update" + is SessionUpdate.SessionInfoUpdate -> "session_info_update" + is SessionUpdate.UsageUpdate -> "usage_update" + else -> "other" +} + +fun textOf(c: ContentBlock): String? = (c as? ContentBlock.Text)?.text diff --git a/integration-testing/programs/kotlin/src/main/kotlin/interop/Main.kt b/integration-testing/programs/kotlin/src/main/kotlin/interop/Main.kt new file mode 100644 index 00000000..e3ed4d3a --- /dev/null +++ b/integration-testing/programs/kotlin/src/main/kotlin/interop/Main.kt @@ -0,0 +1,40 @@ +package interop + +import kotlin.system.exitProcess + +/** + * The Kotlin interop programs of the cross-SDK suite: `kt-peer agent ...` and `kt-peer client ...` + * (launch/agent.sh and launch/client.sh). Contract: integration-testing/README.md, "Contracts"; + * step catalogue: integration-testing/steps.json. + */ +fun main(args: Array) { + when (args.firstOrNull()) { + "agent" -> AgentMain.run(args.drop(1)) + "client" -> ClientMain.run(args.drop(1)) + else -> usage("kt-peer", "first argument must be agent or client", "kt-peer agent|client ") + } +} + +fun usage(who: String, problem: String, usage: String): Nothing { + System.err.println("$who: $problem") + System.err.println("usage: $usage") + exitProcess(2) +} + +/** Parses `--transport ` plus `--port ` or `--url `; anything else is a usage error. */ +class Args(args: List, who: String, usage: String, allowed: Set) { + val values = HashMap() + + init { + var i = 0 + while (i < args.size) { + val key = args[i] + if (!key.startsWith("--") || key.removePrefix("--") !in allowed) usage(who, "unknown argument $key", usage) + if (i + 1 >= args.size) usage(who, "$key needs a value", usage) + values[key.removePrefix("--")] = args[i + 1] + i += 2 + } + } + + operator fun get(name: String): String? = values[name] +} diff --git a/integration-testing/programs/kotlin/src/main/resources/simplelogger.properties b/integration-testing/programs/kotlin/src/main/resources/simplelogger.properties new file mode 100644 index 00000000..3e7c301d --- /dev/null +++ b/integration-testing/programs/kotlin/src/main/resources/simplelogger.properties @@ -0,0 +1,5 @@ +# Logs go to stderr: on stdio, stdout is the protocol stream. +org.slf4j.simpleLogger.logFile=System.err +org.slf4j.simpleLogger.defaultLogLevel=warn +org.slf4j.simpleLogger.showDateTime=true +org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss.SSS diff --git a/integration-testing/programs/micronaut/launch/agent.sh b/integration-testing/programs/micronaut/launch/agent.sh new file mode 100755 index 00000000..fd295e4b --- /dev/null +++ b/integration-testing/programs/micronaut/launch/agent.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Micronaut smoke agent: --transport stdio | --transport http|ws --port . +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself: on stdio, +# stdout is the protocol stream (the application logs to stderr). JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.micronaut.MicronautAgentApp "$@" diff --git a/integration-testing/programs/micronaut/launch/build.sh b/integration-testing/programs/micronaut/launch/build.sh new file mode 100755 index 00000000..12f701e9 --- /dev/null +++ b/integration-testing/programs/micronaut/launch/build.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Builds the Micronaut smoke programs against the SDK installed from this checkout, with the root +# pom's Micronaut core and logback versions. Launcher contract: integration-testing/README.md, +# "Contracts". Env: MVNW, ACP_VERSION, IT_ROOT. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +root_pom="${IT_ROOT:?IT_ROOT is not set}/../pom.xml" +prop() { sed -n "s:.*<$1>\(.*\).*:\1:p" "$root_pom" | head -1; } +core="$(prop micronaut.core.version)" logback="$(prop logback.version)" +[ -n "$core" ] && [ -n "$logback" ] || { echo "build.sh: no micronaut.core.version or logback.version in the root pom" >&2; exit 1; } +exec "${MVNW:?MVNW is not set}" -q -B -Dacp.version="${ACP_VERSION:?ACP_VERSION is not set}" \ + -Dmicronaut.core.version="$core" -Dlogback.version="$logback" \ + compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt diff --git a/integration-testing/programs/micronaut/launch/client.sh b/integration-testing/programs/micronaut/launch/client.sh new file mode 100755 index 00000000..1c26f1b9 --- /dev/null +++ b/integration-testing/programs/micronaut/launch/client.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Micronaut smoke client: --transport http|ws --url (env STEPS). The ACP client +# is the bean acp-micronaut builds from acp.client.*. Launcher contract: +# integration-testing/README.md, "Contracts". JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.micronaut.MicronautClientApp "$@" diff --git a/integration-testing/programs/micronaut/pom.xml b/integration-testing/programs/micronaut/pom.xml new file mode 100644 index 00000000..6a6ec440 --- /dev/null +++ b/integration-testing/programs/micronaut/pom.xml @@ -0,0 +1,92 @@ + + + + 4.0.0 + + com.agentclientprotocol.integration + acp-integration-micronaut + 1 + + + + set-by-build.sh + set-by-build.sh + set-by-build.sh + 17 + UTF-8 + + + + + com.agentclientprotocol + acp-micronaut + ${acp.version} + + + + com.agentclientprotocol + acp-streamable-http-jetty + ${acp.version} + + + ch.qos.logback + logback-classic + ${logback.version} + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + io.micronaut + micronaut-inject-java + ${micronaut.core.version} + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + framework-sources + generate-sources + + add-source + + + + ${project.basedir}/../framework/src/main/java + + + + + + + + diff --git a/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautAgentApp.java b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautAgentApp.java new file mode 100644 index 00000000..163bbb74 --- /dev/null +++ b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautAgentApp.java @@ -0,0 +1,66 @@ +package interop.micronaut; + +import java.util.HashMap; +import java.util.Map; + +import com.agentclientprotocol.sdk.micronaut.agent.AcpAgentRuntime; +import io.micronaut.context.ApplicationContext; +import io.micronaut.runtime.Micronaut; + +/** + * The launcher contract (integration-testing/README.md, "Contracts") over a Micronaut application + * whose {@code @AcpAgent} bean ({@link MicronautSmokeAgent}) acp-micronaut serves: + * {@code --transport stdio}, or {@code --transport http|ws --port }, which prints + * {@code READY } once the SDK listener accepts connections. This class only maps the flags + * onto {@code acp.agent.*} settings. Over + * stdio the process exits when its input ends; over HTTP it runs until SIGTERM. + */ +public final class MicronautAgentApp { + + private MicronautAgentApp() { + } + + public static void main(String[] args) { + String transport = null; + String port = "0"; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--transport" -> transport = value(args, ++i); + case "--port" -> port = value(args, ++i); + default -> usage("unknown flag " + args[i]); + } + } + if (transport == null) { + usage("--transport is required"); + } + boolean stdio = "stdio".equals(transport); + if (!stdio && !"http".equals(transport) && !"ws".equals(transport)) { + usage("unknown transport " + transport); + } + Map properties = new HashMap<>(); + // One listener serves Streamable HTTP and WebSocket upgrades on /acp. + properties.put("acp.agent.transport.type", stdio ? "stdio" : "http"); + properties.put("acp.agent.transport.http.listener.port", port); + ApplicationContext context = Micronaut.build(new String[0]).mainClass(MicronautAgentApp.class).banner(false) + .properties(properties).start(); + if (!stdio) { + int bound = context.getBean(AcpAgentRuntime.class).port().orElseThrow(); + System.out.println("READY " + bound); + System.out.flush(); + } + } + + private static String value(String[] args, int i) { + if (i >= args.length) { + usage(args[i - 1] + " needs a value"); + } + return args[i]; + } + + private static void usage(String problem) { + System.err.println(problem); + System.err.println("usage: agent.sh --transport stdio | --transport http|ws --port "); + System.exit(2); + } + +} diff --git a/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautClientApp.java b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautClientApp.java new file mode 100644 index 00000000..70f1e88c --- /dev/null +++ b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautClientApp.java @@ -0,0 +1,50 @@ +package interop.micronaut; + +import java.util.HashMap; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import interop.framework.SmokeClient; +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Factory; +import io.micronaut.runtime.Micronaut; +import jakarta.inject.Singleton; + +/** + * The client launcher contract over a Micronaut application: acp-micronaut builds the ACP client + * bean from {@code acp.client.*} (the transport URI and the capabilities it advertises), the + * {@link SmokeClient} customizer bean registers its handlers, and the steps in {@code STEPS} run on + * that bean. The program's {@code @AcpAgent} bean is not served here ({@code acp.agent.enabled=false}). + */ +@Factory +public class MicronautClientApp { + + @Singleton + SmokeClient smokeClient() { + return new SmokeClient(); + } + + @Singleton + AcpClientCustomizer smokeClientHandlers(SmokeClient smokeClient) { + return smokeClient::customize; + } + + public static void main(String[] args) throws Exception { + String[] parsed = SmokeClient.args(args); + Map properties = new HashMap<>(); + properties.put("acp.agent.enabled", "false"); + properties.put("ws".equals(parsed[0]) ? "acp.client.transport.websocket.uri" : "acp.client.transport.http.uri", + parsed[1]); + properties.put("acp.client.capabilities.read-text-file", "true"); + properties.put("acp.client.capabilities.elicitation-form", "true"); + try (ApplicationContext context = Micronaut.build(new String[0]).mainClass(MicronautClientApp.class) + .banner(false) + .properties(properties) + .start()) { + context.getBean(SmokeClient.class).run(context.getBean(AcpAsyncClient.class), SmokeClient.steps()); + } + System.exit(0); + } + +} diff --git a/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautSmokeAgent.java b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautSmokeAgent.java new file mode 100644 index 00000000..f634a810 --- /dev/null +++ b/integration-testing/programs/micronaut/src/main/java/interop/micronaut/MicronautSmokeAgent.java @@ -0,0 +1,12 @@ +package interop.micronaut; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import interop.framework.SmokeAgent; +import jakarta.inject.Singleton; + +/** The shared smoke agent as a Micronaut singleton: acp-micronaut finds it by its annotation. */ +@Singleton +@AcpAgent(name = "interop-micronaut-agent", version = "1") +public class MicronautSmokeAgent extends SmokeAgent { + +} diff --git a/integration-testing/programs/micronaut/src/main/resources/logback.xml b/integration-testing/programs/micronaut/src/main/resources/logback.xml new file mode 100644 index 00000000..f0e5fff4 --- /dev/null +++ b/integration-testing/programs/micronaut/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/integration-testing/programs/node/interop/agent.mjs b/integration-testing/programs/node/interop/agent.mjs new file mode 100644 index 00000000..c389ff4d --- /dev/null +++ b/integration-testing/programs/node/interop/agent.mjs @@ -0,0 +1,664 @@ +// The TypeScript interop agent of the step catalogue (integration-testing/steps.json), on the +// TypeScript SDK's app API (acp.agent()). +// +// agent.mjs --transport stdio newline-delimited JSON-RPC on stdin/stdout +// agent.mjs --transport http|ws --port

    AcpServer on 127.0.0.1:

    /acp; prints "READY " +// +// Over HTTP and WebSocket one server serves both transports (Streamable HTTP POST/GET/DELETE and +// GET + Upgrade on /acp), whichever flag was given. The agent keeps no script: it is driven by the +// prompt text (the catalogue's "directives"). Everything except the protocol and the READY line +// goes to stderr: diagnostics, one [http] line per request, and the agent-side assertions, +// "STEP agent. PASS|FAIL ( ms) -> ". +import { createServer } from "node:http"; +import { randomUUID } from "node:crypto"; +import { Readable, Writable } from "node:stream"; +import { acp, server as acpServerModule, nodeAdapter, ws, fixtures, anyParams, sleep, abbreviate, describeError } from "./sdk.mjs"; + +const RequestError = acp.RequestError; +const M = acp.methods; + +// ---------------------------------------------------------------- arguments + +function usage(problem) { + console.error(`agent: ${problem}`); + console.error("usage: agent --transport stdio | --transport http|ws --port "); + process.exit(2); +} + +let transport; +let port; +{ + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const value = () => (i + 1 < args.length ? args[++i] : usage(`${a} needs a value`)); + if (a === "--transport") transport = value(); + else if (a === "--port") port = value(); + else usage(`unknown argument ${a}`); + } + if (!["stdio", "http", "ws"].includes(transport)) usage("--transport stdio|http|ws is required"); + if (transport !== "stdio" && (port === undefined || !/^\d+$/.test(port))) usage(`--port is required for ${transport}`); +} + +const log = (...parts) => console.error("[agent]", ...parts); + +/** An agent-side assertion, on stderr (stdout is the protocol on stdio). */ +function agentStep(id, ok, detail, t0 = Date.now()) { + console.error(`STEP agent.${id} ${ok ? "PASS" : "FAIL"} (${Date.now() - t0} ms) -> ${abbreviate(detail, 400)}`); +} + +// ---------------------------------------------------------------- sessions (shared by all connections) + +/** + * sessionId -> { cwd, history: [{ prompt, chunks }], closed, turn, mode, model, verbose }. + * Shared across connections so a session can be loaded on a new connection (http.reconnect). + */ +const sessions = new Map(); + +function newSessionState(cwd) { + return { cwd, history: [], closed: false, turn: null, mode: fixtures.modes.currentModeId, model: "model-a", verbose: false, grouped: false, effort: "effort-low" }; +} + +function knownSession(sessionId) { + const s = sessions.get(sessionId); + if (!s) throw new RequestError(-32602, `unknown session ${sessionId}`); + if (s.closed) throw new RequestError(-32602, `session ${sessionId} is closed`); + return s; +} + +/** A running prompt turn: cancellable by session/cancel and session/close. */ +class Turn { + constructor(kind) { + this.kind = kind; + this.cancelled = false; + this.cancelledBy = undefined; + this.listeners = []; + } + + cancel(by) { + if (this.cancelled) return; + this.cancelled = true; + this.cancelledBy = by; + for (const l of this.listeners.splice(0)) l(by); + } + + /** Resolves when the turn is cancelled. */ + whenCancelled() { + return new Promise((resolve) => (this.cancelled ? resolve(this.cancelledBy) : this.listeners.push(resolve))); + } +} + +// ---------------------------------------------------------------- the agent app, one per connection + +function createAgentApp() { + /** What this connection's client sent in initialize (after the SDK's schema parsing). */ + let clientCaps; + let lastExtNotification; + + const sessionState = (s) => ({ + modes: { ...fixtures.modes, currentModeId: s.mode }, + configOptions: [ + ...fixtures.configOptions(s.model, s.verbose, booleanConfig()), + ...(s.grouped ? [fixtures.groupedConfigOption(s.effort)] : []), + ], + }); + const booleanConfig = () => clientCaps?.session?.configOptions?.boolean != null; + + return acp + .agent({ name: "interop-typescript-agent" }) + .onRequest(M.agent.initialize, (ctx) => { + clientCaps = ctx.params.clientCapabilities; + log("initialize", abbreviate(ctx.params)); + const authMethods = [...fixtures.agent.authMethods]; + if (clientCaps?.auth?.terminal === true) authMethods.push(fixtures.agent.terminalAuthMethod); + return { + protocolVersion: fixtures.agent.protocolVersion, + agentCapabilities: fixtures.agent.agentCapabilities, + authMethods, + agentInfo: fixtures.agent.agentInfo, + }; + }) + .onRequest(M.agent.authenticate, (ctx) => { + agentStep("auth.authenticate", ctx.params.methodId === "interop-auth", `methodId ${ctx.params.methodId}`); + return {}; + }) + .onRequest(M.agent.logout, () => ({})) + .onRequest(M.agent.session.new, (ctx) => { + const sessionId = randomUUID(); + const s = newSessionState(ctx.params.cwd); + sessions.set(sessionId, s); + log("session/new", sessionId, ctx.params.cwd); + return { sessionId, ...sessionState(s) }; + }) + .onRequest(M.agent.session.load, async (ctx) => { + const sid = ctx.params.sessionId; + const s = knownSession(sid); + log("session/load", sid, `${s.history.length} turn(s) to replay`); + for (const turn of s.history) { + await update(ctx.client, sid, { sessionUpdate: "user_message_chunk", content: { type: "text", text: turn.prompt } }); + for (const text of turn.chunks) { + await update(ctx.client, sid, { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); + } + } + return sessionState(s); + }) + .onRequest(M.agent.session.resume, (ctx) => { + const s = knownSession(ctx.params.sessionId); + log("session/resume", ctx.params.sessionId); + return sessionState(s); + }) + .onRequest(M.agent.session.fork, (ctx) => { + const s = knownSession(ctx.params.sessionId); + const sessionId = randomUUID(); + const fork = newSessionState(ctx.params.cwd); + fork.history = s.history.map((t) => ({ prompt: t.prompt, chunks: [...t.chunks] })); + fork.mode = s.mode; + fork.model = s.model; + fork.verbose = s.verbose; + sessions.set(sessionId, fork); + log("session/fork", ctx.params.sessionId, "->", sessionId); + return { sessionId, ...sessionState(fork) }; + }) + .onRequest(M.agent.session.list, (ctx) => { + const cwd = ctx.params.cwd; + const list = [...sessions.entries()] + .filter(([, s]) => !s.closed && (cwd == null || s.cwd === cwd)) + .map(([sessionId, s]) => ({ sessionId, cwd: s.cwd })); + return { sessions: list }; + }) + .onRequest(M.agent.session.close, (ctx) => { + const t0 = Date.now(); + const sid = ctx.params.sessionId; + const s = sessions.get(sid); + if (!s || s.closed) { + agentStep("session.close", false, `close of an unknown session ${sid}`, t0); + throw new RequestError(-32602, `unknown session ${sid}`); + } + s.closed = true; + const turn = s.turn; + if (turn) turn.cancel("close"); + agentStep("session.close", turn != null, turn ? `session ${sid} closed; its ${turn.kind} turn was cancelled` : `session ${sid} had no running turn`, t0); + return {}; + }) + .onRequest(M.agent.session.delete, (ctx) => { + const s = sessions.get(ctx.params.sessionId); + s?.turn?.cancel("delete"); + sessions.delete(ctx.params.sessionId); + return {}; + }) + .onRequest(M.agent.session.setMode, (ctx) => { + const s = knownSession(ctx.params.sessionId); + const known = fixtures.modes.availableModes.some((m) => m.id === ctx.params.modeId); + agentStep("mode.set", known && ctx.params.modeId === "interop-mode-b", `modeId ${ctx.params.modeId}`); + if (!known) throw new RequestError(-32602, `unknown mode ${ctx.params.modeId}`); + s.mode = ctx.params.modeId; + return {}; + }) + .onRequest(M.agent.session.setConfigOption, (ctx) => { + const s = knownSession(ctx.params.sessionId); + const { configId, value } = ctx.params; + if (configId === "model" && ["model-a", "model-b"].includes(value)) s.model = value; + else if (configId === "verbose" && typeof value === "boolean" && booleanConfig()) s.verbose = value; + else if (configId === "effort" && s.grouped && fixtures.groupedConfigOption().options.some((g) => g.options.some((o) => o.value === value))) { + s.effort = value; + agentStep("config.grouped", value === "effort-high", `effort set to ${value}`); + } + else throw new RequestError(-32602, `invalid config option ${configId}=${JSON.stringify(value)}`); + return { configOptions: sessionState(s).configOptions }; + }) + .onRequest(M.agent.session.prompt, (ctx) => prompt(ctx, clientCaps, () => lastExtNotification, sessionState)) + .onNotification(M.agent.session.cancel, (ctx) => { + const s = sessions.get(ctx.params.sessionId); + log("session/cancel", ctx.params.sessionId, s?.turn ? `(running ${s.turn.kind})` : "(no running turn)"); + const turn = s?.turn; + if (!turn) return; + if (turn.kind === "slow" && !turn.cancelled) agentStep("cancel.prompt", true, `session/cancel for the running session ${ctx.params.sessionId}`); + turn.cancel("session/cancel"); + }) + .onRequest(fixtures.ext.method, anyParams, (ctx) => { + log("extension request", fixtures.ext.method, abbreviate(ctx.params)); + return fixtures.ext.result; + }) + .onNotification(fixtures.ext.notification, anyParams, (ctx) => { + log("extension notification", fixtures.ext.notification, abbreviate(ctx.params)); + lastExtNotification = fixtures.ext.notification; + }); +} + +function update(client, sessionId, upd, meta) { + const params = { sessionId, update: upd }; + if (meta !== undefined) params._meta = meta; + return client.notify(M.client.session.update, params); +} + +// ---------------------------------------------------------------- prompt turns and directives + +async function prompt(ctx, clientCaps, lastExt, sessionState) { + const sid = ctx.params.sessionId; + const s = knownSession(sid); + const first = ctx.params.prompt.find((b) => b.type === "text"); + const text = first?.text ?? ""; + log("session/prompt", sid, abbreviate(text, 120)); + + const record = { prompt: text, chunks: [] }; + s.history.push(record); + const chunk = async (t, meta) => { + record.chunks.push(t); + const upd = { sessionUpdate: "agent_message_chunk", content: { type: "text", text: t } }; + if (meta !== undefined) upd._meta = meta; + await update(ctx.client, sid, upd); + }; + + if (!text.startsWith("#")) { + await chunk("echo: "); + await chunk(text); + return { stopReason: "end_turn" }; + } + + const space = text.indexOf(" "); + const name = (space < 0 ? text : text.slice(0, space)).slice(1); + const rest = space < 0 ? "" : text.slice(space + 1); + const args = rest === "" ? [] : rest.split(" "); + const kw = (key) => args.find((a) => a.startsWith(`${key}=`))?.slice(key.length + 1); + + const turn = new Turn(name); + s.turn = turn; + try { + switch (name) { + case "permission": + return await permission(ctx, sid, args.join(" "), turn, chunk); + case "fs": + return await fs(ctx, sid, clientCaps, args, rest, kw, chunk); + case "emit": + return await emit(ctx, sid, args[0], kw("name"), clientCaps, chunk); + case "stop": + await chunk("stop"); + return { stopReason: args[0] }; + case "slow": + return await slow(ctx, sid, Number(kw("grace") ?? 0), turn, chunk); + case "hang": + log("#hang: never answering"); + return await new Promise(() => {}); + case "terminal": + return await terminal(ctx, sid, clientCaps, args, chunk); + case "elicit": + return await elicit(ctx, sid, clientCaps, args[0], chunk); + case "ext": + return await ext(ctx, args, lastExt, chunk); + case "meta": { + const meta = ctx.params._meta; + await chunk("meta", meta ?? undefined); + return meta == null ? { stopReason: "end_turn" } : { stopReason: "end_turn", _meta: meta }; + } + case "echo-caps": { + const caps = clientCaps; + agentStep("init.client-capabilities", caps != null, `clientCapabilities ${abbreviate(caps)}`); + await chunk(JSON.stringify(caps ?? null)); + return { stopReason: "end_turn" }; + } + case "enum": + return await unknownEnum(ctx, sid, args[0], first, chunk); + case "config": + if (args[0] !== "grouped") throw new RequestError(-32602, `unknown directive: #config ${args[0]}`); + s.grouped = true; + await update(ctx.client, sid, { sessionUpdate: "config_option_update", configOptions: sessionState(s).configOptions }); + return { stopReason: "end_turn" }; + case "len": + await chunk(`len=${rest.length}`); + return { stopReason: "end_turn" }; + case "big": + await chunk("x".repeat(Number(args[0]))); + return { stopReason: "end_turn" }; + default: + throw new RequestError(-32602, `unknown directive: #${name}`); + } + } finally { + if (s.turn === turn) s.turn = null; + } +} + +// #permission allow: perm.selected; #permission allow meta: meta.permission (_meta on the request); +// #permission hold: perm.cancelled. +async function permission(ctx, sid, mode, turn, chunk) { + if (mode !== "allow" && mode !== "allow meta" && mode !== "hold") throw new RequestError(-32602, `unknown directive: #permission ${mode}`); + const t0 = Date.now(); + const request = { sessionId: sid, toolCall: fixtures.permission.toolCall, options: fixtures.permission.options }; + if (mode === "allow meta") request._meta = fixtures.meta; + const r = await ctx.client.request(M.client.session.requestPermission, request); + const outcome = r?.outcome; + log("permission outcome", abbreviate(r)); + if (mode === "allow") { + agentStep("perm.selected", outcome?.outcome === "selected" && outcome.optionId === "allow", `outcome ${abbreviate(outcome)}`, t0); + } else if (mode === "allow meta") { + agentStep("meta.permission", r?._meta?.interop === "m1", `response _meta ${abbreviate(r?._meta)}`, t0); + } else { + agentStep("perm.cancelled", outcome?.outcome === "cancelled", `outcome ${abbreviate(outcome)}`, t0); + } + await chunk(outcome?.outcome === "selected" ? `permission: selected ${outcome.optionId}` : "permission: cancelled"); + return { stopReason: turn.cancelled || outcome?.outcome === "cancelled" ? "cancelled" : "end_turn" }; +} + +async function fs(ctx, sid, clientCaps, args, rest, kw, chunk) { + const op = args[0]; + if (op === "write") { + const path = args[1]; + const content = rest.slice("write ".length + path.length + 1); + const t0 = Date.now(); + if (clientCaps?.fs?.writeTextFile !== true) { + agentStep("fs.write", false, "the client did not advertise fs.writeTextFile", t0); + await chunk("fs write error capability"); + return { stopReason: "end_turn" }; + } + try { + const r = await ctx.client.request(M.client.fs.writeTextFile, { sessionId: sid, path, content }); + agentStep("fs.write", true, `fs/write_text_file answered ${abbreviate(r)}`, t0); + await chunk("fs write ok"); + } catch (e) { + agentStep("fs.write", false, `fs/write_text_file failed: ${describeError(e)}`, t0); + await chunk(`fs write error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; + } + if (op === "read" || op === "read-range" || op === "read-missing") { + const path = args[1]; + const line = kw("line"); + const limit = kw("limit"); + const id = `fs.${op}`; + const t0 = Date.now(); + if (clientCaps?.fs?.readTextFile !== true) { + agentStep(id, false, "the client did not advertise fs.readTextFile", t0); + await chunk("fs read error capability"); + return { stopReason: "end_turn" }; + } + const params = { sessionId: sid, path }; + if (line !== undefined) params.line = Number(line); + if (limit !== undefined) params.limit = Number(limit); + try { + const r = await ctx.client.request(M.client.fs.readTextFile, params); + const content = r?.content; + if (id === "fs.read") agentStep(id, content === fixtures.fsReadContent, `content ${JSON.stringify(content)}`, t0); + else if (id === "fs.read-range") agentStep(id, typeof content === "string" && content.trim() === "line2", `content ${JSON.stringify(content)}`, t0); + else agentStep(id, false, `fs/read_text_file of a missing file answered ${abbreviate(r)}`, t0); + await chunk(typeof content === "string" ? content : `fs read error no content: ${abbreviate(r)}`); + } catch (e) { + agentStep(id, id === "fs.read-missing" && typeof e?.code === "number", `fs/read_text_file failed: ${describeError(e)}`, t0); + await chunk(`fs read error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; + } + if (op === "read-slow") { + const path = args[1]; + const t0 = Date.now(); + if (clientCaps?.fs?.readTextFile !== true) { + agentStep("cancel-request.agent", false, "the client did not advertise fs.readTextFile", t0); + await chunk("fs read error capability"); + return { stopReason: "end_turn" }; + } + const cancel = new AbortController(); + const pending = ctx.client.request(M.client.fs.readTextFile, { sessionId: sid, path }, { cancellationSignal: cancel.signal }); + pending.catch(() => {}); + await sleep(200); + cancel.abort(); + const tCancel = Date.now(); + log("$/cancel_request sent for the fs/read_text_file of", path); + await chunk("cancel-request sent"); + let outcome; + try { + const r = await Promise.race([pending, sleep(5000).then(() => ({ timeout: true }))]); + outcome = r?.timeout ? "TIMEOUT: no answer within 5 s of $/cancel_request" : `answered with a result ${abbreviate(r)}`; + agentStep("cancel-request.agent", false, outcome, t0); + } catch (e) { + const ms = Date.now() - tCancel; + agentStep("cancel-request.agent", e?.code === -32800 && ms <= 5000, `fs request ended ${describeError(e)} ${ms} ms after the cancel`, t0); + } + return { stopReason: "end_turn" }; + } + throw new RequestError(-32602, `unknown directive: #fs ${op}`); +} + +/** + * #enum tool_call|plan|stop|audience: values the v1 schema does not define. notify() and the + * prompt response are sent as given (no outgoing validation). + */ +async function unknownEnum(ctx, sid, what, first, chunk) { + switch (what) { + case "tool_call": + case "plan": + await update(ctx.client, sid, { ...fixtures.unknownEnums[what === "plan" ? "plan" : "toolCall"] }); + await chunk("after-enum"); + return { stopReason: "end_turn" }; + case "stop": + await chunk("stop"); + return { stopReason: fixtures.unknownEnums.stopReason }; + case "audience": { + const audience = first?.annotations?.audience; + const ok = JSON.stringify(audience) === JSON.stringify(fixtures.unknownEnums.audience); + agentStep("enum.audience", ok, `audience ${JSON.stringify(audience ?? null)}`); + await chunk(`audience: ${Array.isArray(audience) ? audience.join(",") : "none"}`); + return { stopReason: "end_turn" }; + } + default: + throw new RequestError(-32602, `unknown directive: #enum ${what}`); + } +} + +async function emit(ctx, sid, kind, toolName, clientCaps, chunk) { + const send = (u) => update(ctx.client, sid, u); + switch (kind) { + case "user_message_chunk": + case "agent_thought_chunk": + case "plan": + case "available_commands_update": + case "current_mode_update": + case "session_info_update": + case "usage_update": + await send({ sessionUpdate: kind, ...fixtures.emit[kind] }); + break; + case "tool_call": + await send({ sessionUpdate: "tool_call", ...fixtures.emit.tool_call, ...(toolName ? { name: toolName } : {}) }); + break; + case "tool_call_update": + await send({ sessionUpdate: "tool_call", ...fixtures.emit.tool_call }); + await send({ sessionUpdate: "tool_call_update", ...fixtures.emit.tool_call_update }); + break; + case "config_option_update": + await send({ + sessionUpdate: "config_option_update", + configOptions: fixtures.configOptions("model-b", false, clientCaps?.session?.configOptions?.boolean != null), + }); + break; + case "unknown": + // The typed API has no such variant; notify() sends the params as given (no outgoing validation). + await send({ ...fixtures.emit.unknown }); + await chunk("after-unknown"); + break; + default: + throw new RequestError(-32602, `unknown directive: #emit ${kind}`); + } + return { stopReason: "end_turn" }; +} + +async function slow(ctx, sid, grace, turn, chunk) { + const t0 = Date.now(); + let requestCancelled = false; + const onAbort = () => { + requestCancelled = true; + agentStep("cancel-request.client", true, `$/cancel_request observed for the prompt (request ${ctx.requestId})`, t0); + turn.cancel("$/cancel_request"); + }; + if (ctx.signal.aborted) onAbort(); + else ctx.signal.addEventListener("abort", onAbort, { once: true }); + try { + const deadline = Date.now() + 10_000; + let cancelAt; + while (Date.now() < deadline) { + if (turn.cancelled && cancelAt === undefined) cancelAt = Date.now(); + if (cancelAt !== undefined && (requestCancelled || turn.cancelledBy === "close" || Date.now() - cancelAt >= grace)) break; + try { + await chunk("tick"); + } catch (e) { + log("#slow: tick failed:", describeError(e)); + break; + } + await Promise.race([sleep(100), turn.cancelled ? sleep(100) : turn.whenCancelled()]); + } + if (turn.cancelled) { + log(`#slow: cancelled by ${turn.cancelledBy} after ${Date.now() - t0} ms`); + return { stopReason: "cancelled" }; + } + return { stopReason: "end_turn" }; + } finally { + ctx.signal.removeEventListener("abort", onAbort); + } +} + +async function terminal(ctx, sid, clientCaps, args, chunk) { + const [op, command, ...cmdArgs] = args; + const id = op === "kill" ? "term.kill" : "term.run"; + const t0 = Date.now(); + if (op !== "run" && op !== "kill") throw new RequestError(-32602, `unknown directive: #terminal ${op}`); + if (clientCaps?.terminal !== true) { + agentStep(id, false, "the client did not advertise terminal", t0); + await chunk("terminal error capability"); + return { stopReason: "end_turn" }; + } + const c = ctx.client; + try { + const { terminalId } = await c.request(M.client.terminal.create, { sessionId: sid, command, args: cmdArgs }); + if (op === "run") { + const exit = await c.request(M.client.terminal.waitForExit, { sessionId: sid, terminalId }); + const out = await c.request(M.client.terminal.output, { sessionId: sid, terminalId }); + await c.request(M.client.terminal.release, { sessionId: sid, terminalId }); + const output = (out?.output ?? "").trim(); + const code = exit?.exitCode ?? out?.exitStatus?.exitCode; + agentStep(id, output.includes("hi") && code === 0, `output ${JSON.stringify(output)} exitCode ${code}`, t0); + await chunk(`terminal: ${output} exit=${code}`); + } else { + await sleep(200); + await c.request(M.client.terminal.kill, { sessionId: sid, terminalId }); + const tKill = Date.now(); + const exit = await c.request(M.client.terminal.waitForExit, { sessionId: sid, terminalId }); + const ms = Date.now() - tKill; + await c.request(M.client.terminal.release, { sessionId: sid, terminalId }); + agentStep(id, ms <= 5000, `wait_for_exit returned ${abbreviate(exit)} ${ms} ms after the kill`, t0); + await chunk("terminal killed"); + } + } catch (e) { + agentStep(id, false, `terminal request failed: ${describeError(e)}`, t0); + await chunk(`terminal error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; +} + +async function elicit(ctx, sid, clientCaps, mode, chunk) { + const t0 = Date.now(); + if (mode === "form") { + if (clientCaps?.elicitation?.form == null) { + agentStep("elicit.form", false, "the client did not advertise elicitation.form", t0); + await chunk("elicit error capability"); + return { stopReason: "end_turn" }; + } + try { + const r = await ctx.client.request(M.client.elicitation.create, { sessionId: sid, mode: "form", ...fixtures.elicitation.form }); + agentStep("elicit.form", r?.action === "accept" && r?.content?.name === "interop", `response ${abbreviate(r)}`, t0); + await chunk(`elicit: ${r?.action} ${JSON.stringify(r?.content ?? null)}`); + } catch (e) { + agentStep("elicit.form", false, `elicitation/create failed: ${describeError(e)}`, t0); + await chunk(`elicit error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; + } + if (mode === "url") { + if (clientCaps?.elicitation?.url == null) { + await chunk("elicit error capability"); + return { stopReason: "end_turn" }; + } + try { + const r = await ctx.client.request(M.client.elicitation.create, { sessionId: sid, mode: "url", ...fixtures.elicitation.url }); + log("url elicitation answered", abbreviate(r)); + await ctx.client.notify(M.client.elicitation.complete, { elicitationId: fixtures.elicitation.url.elicitationId }); + await chunk("elicit url done"); + } catch (e) { + await chunk(`elicit error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; + } + throw new RequestError(-32602, `unknown directive: #elicit ${mode}`); +} + +async function ext(ctx, args, lastExt, chunk) { + const [op, method] = args; + const t0 = Date.now(); + if (op === "request") { + try { + const r = await ctx.client.request(method, fixtures.ext.params); + agentStep("ext.agent-request", r?.pong === 1, `result ${abbreviate(r)}`, t0); + await chunk(`ext: ${JSON.stringify(r)}`); + } catch (e) { + agentStep("ext.agent-request", false, `${method} failed: ${describeError(e)}`, t0); + await chunk(`ext error ${e?.code ?? describeError(e)}`); + } + return { stopReason: "end_turn" }; + } + if (op === "notify") { + await ctx.client.notify(method, fixtures.ext.params); + await chunk("ext notified"); + return { stopReason: "end_turn" }; + } + if (op === "last-notification") { + await chunk(`ext last: ${lastExt() ?? "none"}`); + return { stopReason: "end_turn" }; + } + throw new RequestError(-32602, `unknown directive: #ext ${op}`); +} + +// ---------------------------------------------------------------- transports + +if (transport === "stdio") { + const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)); + const connection = createAgentApp().connect(stream); + log("serving on stdio"); + const exit = (why) => { + log(`exiting: ${why}`); + process.exit(0); + }; + process.stdin.on("end", () => exit("stdin reached EOF")); + process.stdin.on("close", () => exit("stdin closed")); + void connection.closed.then(() => exit("connection closed")); +} else { + const acpServer = new acpServerModule.AcpServer({ createAgent: () => createAgentApp() }); + const handler = nodeAdapter.createNodeHttpHandler(acpServer); + const wss = new ws.WebSocketServer({ noServer: true }); + const upgrade = nodeAdapter.createNodeWebSocketUpgradeHandler(acpServer, wss); + const httpServer = createServer((req, res) => { + const t0 = Date.now(); + const h = req.headers; + const line = `[http] ${req.method} ${req.url} HTTP/${req.httpVersion}`; + const extra = `ct=${h["content-type"]} accept=${h["accept"]} conn=${h["acp-connection-id"]} sess=${h["acp-session-id"]}`; + res.on("finish", () => console.error(`${line} -> ${res.statusCode} ${extra} ${Date.now() - t0}ms`)); + if (new URL(req.url, "http://x").pathname !== "/acp") { + res.writeHead(404); + res.end(); + return; + } + handler(req, res); + }); + wss.on("headers", (headers, req) => { + console.error(`[http] ${req.method} ${req.url} HTTP/${req.httpVersion} -> 101 upgrade="${req.headers["upgrade"]}" ${headers.find((x) => /^acp-connection-id/i.test(x)) ?? ""}`); + }); + httpServer.on("upgrade", (req, socket, head) => { + if (new URL(req.url, "http://x").pathname !== "/acp") { + console.error(`[http] ${req.method} ${req.url} HTTP/${req.httpVersion} -> 404 upgrade="${req.headers["upgrade"]}"`); + socket.end("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + return; + } + upgrade(req, socket, head); + }); + httpServer.on("clientError", (err) => console.error("[clientError]", err.message)); + httpServer.listen(Number(port), "127.0.0.1", () => { + const bound = httpServer.address().port; + log(`listening on http://127.0.0.1:${bound}/acp (Streamable HTTP and WebSocket, --transport ${transport})`); + console.log(`READY ${bound}`); + }); + process.on("SIGTERM", () => process.exit(0)); +} diff --git a/integration-testing/programs/node/interop/client.mjs b/integration-testing/programs/node/interop/client.mjs new file mode 100644 index 00000000..692e6dc6 --- /dev/null +++ b/integration-testing/programs/node/interop/client.mjs @@ -0,0 +1,1128 @@ +// The TypeScript interop client of the step catalogue (integration-testing/steps.json), on the +// TypeScript SDK's app API (acp.client()). +// +// STEPS= client.mjs --transport stdio spawns bash -c "exec $AGENT_CMD" +// STEPS= client.mjs --transport http --url http://127.0.0.1:

    /acp +// STEPS= client.mjs --transport ws --url ws://127.0.0.1:

    /acp +// +// Runs the step ids in STEPS in order and prints one "STEP PASS|FAIL ( ms) -> " +// line per step, then "RESULT pass=.. fail=.. updates_total=.. upd_=..", and exits 0. On +// stdio the agent's stderr is relayed to stdout: lines starting with "STEP " verbatim, every other +// line prefixed "agent| ". Env STEP_TIMEOUT_MS (default 15000). +import { spawn } from "node:child_process"; +import { createInterface } from "node:readline"; +import { Readable, Writable } from "node:stream"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { acp, httpClient, wsClient, ws, fixtures, anyParams, sleep, abbreviate, describeError } from "./sdk.mjs"; + +const M = acp.methods; +const RequestError = acp.RequestError; + +// ---------------------------------------------------------------- arguments + +function usage(problem) { + console.error(`client: ${problem}`); + console.error("usage: STEPS= client --transport stdio (with AGENT_CMD) | --transport http|ws --url "); + process.exit(2); +} + +let transport; +let url; +{ + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + const value = () => (i + 1 < args.length ? args[++i] : usage(`${a} needs a value`)); + if (a === "--transport") transport = value(); + else if (a === "--url") url = value(); + else usage(`unknown argument ${a}`); + } + if (!["stdio", "http", "ws"].includes(transport)) usage("--transport stdio|http|ws is required"); + if (transport !== "stdio" && !url) usage(`--url is required for ${transport}`); + if (transport === "stdio" && !process.env.AGENT_CMD) usage("AGENT_CMD is required for stdio"); + if (!process.env.STEPS || !process.env.STEPS.trim()) usage("STEPS is required"); +} + +const STEP_TIMEOUT = Number(process.env.STEP_TIMEOUT_MS || 15000); +const UPDATE_GRACE = 1000; +const REPLAY_GRACE = 2000; + +let dir; +let pass = 0; +let fail = 0; +let updatesTotal = 0; +const updatesByKind = new Map(); +/** The main connection, opened by init.initialize. */ +let main; + +class StepFailure extends Error {} + +function check(ok, failure) { + if (!ok) throw new StepFailure(typeof failure === "function" ? failure() : failure); +} + +class Timeout extends Error {} + +function withTimeout(promise, ms, what) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Timeout(`TIMEOUT after ${ms} ms: ${what}`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +/** Wait up to `ms` for a condition on what was received (updates may trail a response). */ +async function waitFor(condition, failure, ms = UPDATE_GRACE) { + const deadline = Date.now() + ms; + for (;;) { + if (condition()) return; + if (Date.now() > deadline) throw new StepFailure(typeof failure === "function" ? failure() : failure); + await sleep(20); + } +} + +/** Relay one stderr line of a stdio agent: STEP lines verbatim, everything else prefixed. */ +function relay(line) { + console.log(line.startsWith("STEP ") ? line : `agent| ${line}`); +} + +// ---------------------------------------------------------------- connection + +/** + * Wraps the transport stream: records the requests this side sends (their ids, for + * cancel-request.unknown) and stray responses, and lets close() wait for the transport's own close + * (the HTTP DELETE, the WebSocket close). + */ +function wrapStream(inner) { + const sent = []; + const strays = []; + const writer = inner.writable.getWriter(); + const reader = inner.readable.getReader(); + let closing; + const closeInner = (reason) => { + if (!closing) closing = reader.cancel(reason).catch((e) => console.log(" transport close failed:", describeError(e))); + return closing; + }; + return { + sent, + strays, + closeInner, + stream: { + writable: new WritableStream({ + write(message) { + if (message && typeof message === "object" && "method" in message && "id" in message) sent.push({ id: message.id, method: message.method }); + return writer.write(message); + }, + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }), + readable: new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await reader.read(); + if (done) { + controller.close(); + return; + } + if (value && typeof value === "object" && !("method" in value) && "id" in value && "error" in value) { + strays.push(value); + } + controller.enqueue(value); + } catch (e) { + controller.error(e); + } + }, + cancel: (reason) => closeInner(reason), + }), + }, + }; +} + +/** Spawns the agent of a stdio cell: bash -c "exec $AGENT_CMD", stderr relayed line by line. */ +function spawnAgent() { + const child = spawn("bash", ["-c", `exec ${process.env.AGENT_CMD}`], { stdio: ["pipe", "pipe", "pipe"], env: process.env }); + createInterface({ input: child.stderr }).on("line", relay); + const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve({ code, signal }))); + child.stdin.on("error", () => {}); + return { child, exited, stream: acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)) }; +} + +/** One client connection, with what it received. */ +class Conn { + constructor() { + /** sessionId -> [update] in arrival order. */ + this.updates = new Map(); + /** sessionId -> [RequestPermissionRequest params]. */ + this.permissions = new Map(); + /** Sessions whose permission request is answered by cancelling the turn (perm.cancelled). */ + this.holdSessions = new Map(); + this.extNotifications = []; + this.elicitationCompletes = []; + this.terminals = new Map(); + } + + static open() { + const c = new Conn(); + let inner; + if (transport === "http") inner = httpClient.createHttpStream(url); + else if (transport === "ws") inner = wsClient.createWebSocketStream(url, { WebSocket: ws.WebSocket }); + else { + c.proc = spawnAgent(); + inner = c.proc.stream; + } + c.wrapped = wrapStream(inner); + c.connection = c.app().connect(c.wrapped.stream); + c.agent = c.connection.agent; + return c; + } + + app() { + return acp + .client({ name: "interop-typescript-client" }) + .onNotification(M.client.session.update, (ctx) => this.onUpdate(ctx.params)) + .onRequest(M.client.session.requestPermission, (ctx) => this.onPermission(ctx)) + .onRequest(M.client.fs.writeTextFile, async (ctx) => { + await fsp.writeFile(ctx.params.path, ctx.params.content, "utf8"); + console.log(` fs/write_text_file ${ctx.params.path}`); + return {}; + }) + .onRequest(M.client.fs.readTextFile, (ctx) => this.onRead(ctx)) + .onRequest(M.client.terminal.create, (ctx) => this.terminalCreate(ctx.params)) + .onRequest(M.client.terminal.output, (ctx) => { + const t = this.terminal(ctx.params.terminalId); + return { output: t.output, truncated: false, exitStatus: t.exit ?? undefined }; + }) + .onRequest(M.client.terminal.waitForExit, async (ctx) => this.terminal(ctx.params.terminalId).exited) + .onRequest(M.client.terminal.kill, (ctx) => { + this.terminal(ctx.params.terminalId).proc.kill("SIGKILL"); + return {}; + }) + .onRequest(M.client.terminal.release, (ctx) => { + const t = this.terminal(ctx.params.terminalId); + if (t.exit === undefined) t.proc.kill("SIGKILL"); + this.terminals.delete(ctx.params.terminalId); + return {}; + }) + .onRequest(M.client.elicitation.create, (ctx) => { + console.log(` elicitation/create ${abbreviate(ctx.params)}`); + if (ctx.params.mode === "form") return fixtures.elicitation.formAnswer; + return { action: "accept" }; + }) + .onNotification(M.client.elicitation.complete, (ctx) => { + console.log(` elicitation/complete ${abbreviate(ctx.params)}`); + this.elicitationCompletes.push({ ...ctx.params, at: Date.now() }); + }) + .onRequest(fixtures.ext.method, anyParams, (ctx) => { + console.log(` extension request ${fixtures.ext.method} ${abbreviate(ctx.params)}`); + return fixtures.ext.result; + }) + .onNotification(fixtures.ext.notification, anyParams, (ctx) => { + console.log(` extension notification ${fixtures.ext.notification} ${abbreviate(ctx.params)}`); + this.extNotifications.push({ method: fixtures.ext.notification, params: ctx.params }); + }); + } + + onUpdate(n) { + const list = this.updates.get(n.sessionId) ?? []; + list.push(n.update); + this.updates.set(n.sessionId, list); + updatesTotal++; + const kind = KINDS.has(n.update.sessionUpdate) ? n.update.sessionUpdate : "other"; + updatesByKind.set(kind, (updatesByKind.get(kind) ?? 0) + 1); + console.log(` update ${n.sessionId}: ${abbreviate(n.update, 200)}`); + } + + async onPermission(ctx) { + const p = ctx.params; + const list = this.permissions.get(p.sessionId) ?? []; + list.push(p); + this.permissions.set(p.sessionId, list); + console.log(` permission request ${p.sessionId}: ${abbreviate(p.options)}`); + const meta = p._meta != null ? { _meta: p._meta } : {}; + const hold = this.holdSessions.get(p.sessionId); + if (hold) { + // perm.cancelled: cancel the turn while the request is pending, then answer cancelled. + hold.cancelAt = Date.now(); + await this.agent.notify(M.agent.session.cancel, { sessionId: p.sessionId }); + return { outcome: { outcome: "cancelled" }, ...meta }; + } + const chosen = p.options.find((o) => o.kind === "allow_once") ?? p.options[0]; + return { outcome: { outcome: "selected", optionId: chosen.optionId }, ...meta }; + } + + async onRead(ctx) { + const { path: file, line, limit } = ctx.params; + console.log(` fs/read_text_file ${abbreviate(ctx.params)}`); + if (file.endsWith("slow.txt")) { + // cancel-request.agent: wait up to 10 s, or until the agent cancels this request. + const t0 = Date.now(); + await Promise.race([sleep(10_000), new Promise((resolve) => ctx.signal.addEventListener("abort", resolve, { once: true }))]); + if (ctx.signal.aborted) { + console.log(` fs/read_text_file ${file}: cancelled by $/cancel_request after ${Date.now() - t0} ms`); + throw ctx.signal.reason ?? RequestError.requestCancelled(); + } + return { content: "slow" }; + } + let content; + try { + content = await fsp.readFile(file, "utf8"); + } catch { + throw RequestError.resourceNotFound(file); + } + if (line != null || limit != null) { + const lines = content.split(/(?<=\n)/); + const start = Math.max(0, (line ?? 1) - 1); + content = lines.slice(start, limit != null ? start + limit : undefined).join(""); + } + return { content }; + } + + terminalCreate(p) { + const terminalId = `term-${this.terminals.size + 1}-${Date.now()}`; + const proc = spawn(p.command, p.args ?? [], { cwd: p.cwd ?? undefined, stdio: ["ignore", "pipe", "pipe"] }); + const t = { proc, output: "", exit: undefined }; + proc.stdout.on("data", (d) => (t.output += d)); + proc.stderr.on("data", (d) => (t.output += d)); + t.exited = new Promise((resolve) => { + const done = (code, signal) => { + t.exit = { exitCode: code ?? null, signal: signal ?? null }; + resolve(t.exit); + }; + proc.on("exit", done); + proc.on("error", (e) => { + t.output += String(e?.message ?? e); + done(127, null); + }); + }); + this.terminals.set(terminalId, t); + console.log(` terminal/create ${terminalId}: ${p.command} ${(p.args ?? []).join(" ")}`); + return { terminalId }; + } + + terminal(id) { + const t = this.terminals.get(id); + if (!t) throw new RequestError(-32602, `unknown terminal ${id}`); + return t; + } + + request(method, params, options) { + return withTimeout(this.agent.request(method, params, options), STEP_TIMEOUT, method); + } + + notify(method, params) { + return this.agent.notify(method, params); + } + + initialize() { + return this.request(M.agent.initialize, { + protocolVersion: fixtures.client.protocolVersion, + clientCapabilities: fixtures.client.clientCapabilities, + clientInfo: fixtures.client.clientInfo, + }); + } + + async newSession(cwd = dir) { + const r = await this.request(M.agent.session.new, { cwd, mcpServers: [] }); + check(typeof r?.sessionId === "string" && r.sessionId.length > 0, () => `session/new returned no sessionId: ${abbreviate(r)}`); + return r; + } + + prompt(sessionId, text, extra = {}, options) { + return this.request(M.agent.session.prompt, { sessionId, prompt: [{ type: "text", text }], ...extra }, options); + } + + of(sid) { + return this.updates.get(sid) ?? []; + } + + /** The text of every agent_message_chunk received for the session, in order. */ + chunks(sid) { + return this.of(sid) + .filter((u) => u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text") + .map((u) => u.content.text); + } + + async close() { + this.connection.close(); + await withTimeout(this.wrapped.closeInner(), STEP_TIMEOUT, "transport close"); + if (this.proc) { + this.proc.child.stdin.end(); + const exit = await Promise.race([this.proc.exited, sleep(5000).then(() => undefined)]); + if (!exit) { + this.proc.child.kill("SIGKILL"); + throw new StepFailure("the stdio agent did not exit within 5 s of the end of its stdin"); + } + return `agent exited ${exit.code ?? exit.signal}`; + } + return "closed"; + } +} + +const KINDS = new Set([ + "user_message_chunk", + "agent_message_chunk", + "agent_thought_chunk", + "tool_call", + "tool_call_update", + "plan", + "available_commands_update", + "current_mode_update", + "config_option_update", + "session_info_update", + "usage_update", +]); + +function mainConn() { + check(main != null, "no main connection: init.initialize did not run first"); + return main; +} + +const isEmptyResult = (r) => r != null && typeof r === "object" && Object.keys(r).filter((k) => k !== "_meta").length === 0; + +// ---------------------------------------------------------------- steps + +/** A prompt on a new session of the main connection that must answer end_turn. */ +async function promptNew(text, extra) { + const c = mainConn(); + const { sessionId } = await c.newSession(); + const r = await c.prompt(sessionId, text, extra); + return { c, sid: sessionId, r }; +} + +async function endTurn(text) { + const x = await promptNew(text); + check(x.r?.stopReason === "end_turn", `stopReason ${x.r?.stopReason}`); + return x; +} + +/** Waits for the first update of the session matching the predicate and returns it. */ +async function updateOf(c, sid, predicate, failure, ms = UPDATE_GRACE) { + let found; + await waitFor(() => (found = c.of(sid).find(predicate)) !== undefined, () => `${failure}; updates: ${abbreviate(c.of(sid), 400)}`, ms); + return found; +} + +async function emitStep(kind, predicate, what, prompt = `#emit ${kind}`) { + const { c, sid } = await endTurn(prompt); + const u = await updateOf(c, sid, (u) => u.sessionUpdate === kind && predicate(u), `no ${kind} with ${what}`); + return `${kind}: ${abbreviate(u, 200)}`; +} + +async function echoCaps() { + const { c, sid } = await endTurn("#echo-caps"); + let caps; + await waitFor( + () => + c.chunks(sid).some((t) => { + try { + caps = JSON.parse(t); + return caps !== null && typeof caps === "object"; + } catch { + return false; + } + }), + () => `no JSON chunk in ${abbreviate(c.chunks(sid))}`, + ); + return caps; +} + +/** Starts a "#slow" prompt and waits for its first tick. */ +async function startSlow(c, sid, text = "#slow", options) { + const t0 = Date.now(); + const pending = c.prompt(sid, text, {}, options); + let early; + pending.then( + (value) => (early = { ok: true, value }), + (error) => (early = { ok: false, error }), + ); + await waitFor( + () => c.chunks(sid).includes("tick") || early !== undefined, + "no tick within 5 s", + 5000, + ); + check(c.chunks(sid).includes("tick"), () => `the ${text} prompt ended before its first tick: ${early.ok ? abbreviate(early.value) : describeError(early.error)}`); + return { pending, t0 }; +} + +/** Settles a promise: { ok, value } or { ok: false, error }, or undefined after `ms`. */ +function settle(promise, ms) { + return Promise.race([ + promise.then( + (value) => ({ ok: true, value }), + (error) => ({ ok: false, error }), + ), + sleep(ms).then(() => undefined), + ]); +} + +const steps = { + "init.initialize": async () => { + main = Conn.open(); + const r = await main.initialize(); + main.init = r; + check(r?.protocolVersion === 1, `protocolVersion ${r?.protocolVersion}`); + return `protocolVersion=1 agentInfo=${abbreviate(r.agentInfo)}`; + }, + "init.agent-capabilities": async () => { + const caps = mainConn().init?.agentCapabilities; + const sc = caps?.sessionCapabilities ?? {}; + check(caps?.loadSession === true, `loadSession ${caps?.loadSession}`); + const missing = ["list", "resume", "close", "delete"].filter((k) => sc[k] == null); + check(missing.length === 0, `sessionCapabilities lacks ${missing.join(", ")}: ${abbreviate(sc)}`); + return `agentCapabilities ${abbreviate(caps, 200)}`; + }, + "init.client-capabilities": async () => { + const caps = await echoCaps(); + check(caps.fs?.readTextFile === true && caps.fs?.writeTextFile === true && caps.terminal === true, `echoed ${abbreviate(caps)}`); + return `echoed ${abbreviate(caps, 200)}`; + }, + "init.auth-methods": async () => { + const m = mainConn().init?.authMethods ?? []; + check(m.some((x) => x.id === "interop-auth"), `authMethods ${abbreviate(m)}`); + return `authMethods ${m.map((x) => x.id).join(",")}`; + }, + "init.agent-info": async () => { + const info = mainConn().init?.agentInfo; + check(typeof info?.name === "string" && info.name.startsWith("interop-"), `agentInfo ${abbreviate(info)}`); + return `agentInfo ${info.name}`; + }, + "init.config-boolean": async () => { + const caps = await echoCaps(); + check(caps.session?.configOptions?.boolean != null, `echoed ${abbreviate(caps)}`); + return "echoed session.configOptions.boolean"; + }, + "auth.authenticate": async () => { + const r = await mainConn().request(M.agent.authenticate, { methodId: "interop-auth" }); + return `authenticate answered ${abbreviate(r)}`; + }, + "auth.logout": async () => { + const r = await mainConn().request(M.agent.logout, {}); + return `logout answered ${abbreviate(r)}`; + }, + "auth.logout-capability": async () => { + const caps = mainConn().init?.agentCapabilities; + check(caps?.auth?.logout != null, `agentCapabilities.auth ${abbreviate(caps?.auth)}`); + return "agentCapabilities.auth.logout present"; + }, + "auth.terminal": async () => { + const m = mainConn().init?.authMethods ?? []; + check(m.some((x) => x.type === "terminal" && x.id === "interop-terminal-auth"), `authMethods ${abbreviate(m)}`); + return "terminal auth method listed"; + }, + "session.new": async () => { + const r = await mainConn().newSession(); + return `sessionId=${r.sessionId}`; + }, + "session.load": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + await c.prompt(sid, "hello load"); + await c.request(M.agent.session.load, { sessionId: sid, cwd: dir, mcpServers: [] }); + await waitFor(() => c.chunks(sid).includes("hello load"), "the first prompt's chunks did not arrive"); + const before = c.chunks(sid).length; + const r = await c.prompt(sid, "after load"); + check(r?.stopReason === "end_turn", `stopReason ${r?.stopReason}`); + await waitFor(() => c.chunks(sid).length > before, "no agent_message_chunk after the load"); + return `loaded ${sid}; prompt after load end_turn`; + }, + "session.load-replay": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + await c.prompt(sid, "replay me"); + await waitFor(() => c.chunks(sid).join("") === "echo: replay me", () => `the prompt's chunks ${abbreviate(c.chunks(sid))}`); + const from = c.of(sid).length; + await c.request(M.agent.session.load, { sessionId: sid, cwd: dir, mcpServers: [] }); + const replay = () => c.of(sid).slice(from); + await waitFor( + () => + replay().some((u) => u.sessionUpdate === "user_message_chunk" && u.content?.text === "replay me") && + replay().some((u) => u.sessionUpdate === "agent_message_chunk" && u.content?.text?.includes("replay me")), + () => `replay ${abbreviate(replay())}`, + REPLAY_GRACE, + ); + return `replayed ${replay().length} update(s)`; + }, + "session.resume": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + await c.prompt(sid, "before resume"); + await waitFor(() => c.chunks(sid).join("") === "echo: before resume", () => `the prompt's chunks ${abbreviate(c.chunks(sid))}`); + const from = c.of(sid).length; + await c.request(M.agent.session.resume, { sessionId: sid, cwd: dir }); + await sleep(UPDATE_GRACE); + const during = c.of(sid).slice(from); + check(during.length === 0, () => `${during.length} update(s) after the resume: ${abbreviate(during)}`); + const r = await c.prompt(sid, "after resume"); + check(r?.stopReason === "end_turn", `after resume: stopReason ${r?.stopReason}`); + return "resumed without replay; prompt after resume end_turn"; + }, + "session.list": async () => { + const c = mainConn(); + const cwd = path.join(dir, "list"); + await fsp.mkdir(cwd, { recursive: true }); + const { sessionId: sid } = await c.newSession(cwd); + let cursor; + const seen = []; + for (let page = 0; page < 10; page++) { + const r = await c.request(M.agent.session.list, cursor ? { cwd, cursor } : { cwd }); + seen.push(...(r?.sessions ?? [])); + cursor = r?.nextCursor; + if (!cursor) break; + } + const found = seen.find((s) => s.sessionId === sid); + check(found != null, () => `${sid} not listed: ${abbreviate(seen)}`); + check(found.cwd === cwd, `listed with cwd ${found.cwd}`); + return `listed among ${seen.length} session(s)`; + }, + "session.close": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const { pending } = await startSlow(c, sid); + const r = await c.request(M.agent.session.close, { sessionId: sid }); + const tClose = Date.now(); + check(isEmptyResult(r), `close answered ${abbreviate(r)}`); + const ended = await settle(pending, 5000); + check(ended !== undefined, "the #slow prompt did not end within 5 s of the close"); + check(!ended.ok || ended.value?.stopReason === "cancelled", `the #slow prompt answered ${abbreviate(ended.value)}`); + const how = ended.ok ? `stopReason ${ended.value.stopReason}` : describeError(ended.error); + const after = await settle(c.prompt(sid, "after close"), STEP_TIMEOUT); + check(after !== undefined && !after.ok, () => `the prompt after close ${after ? `answered ${abbreviate(after.value)}` : "TIMEOUT"}`); + return `closed; #slow ended (${how}) ${Date.now() - tClose} ms later; prompt after close failed: ${describeError(after.error)}`; + }, + "session.delete": async () => { + // Over HTTP the TypeScript client posts session/delete session-scoped (Acp-Session-Id: + // no-such-session), and any non-2xx POST errors its whole stream (http-stream.ts + // postConnectedMessage -> errorReadable). The step runs on its own connection there, so a + // server that answers 404 fails this step only, not every step after it. + const own = transport === "http"; + const c = own ? Conn.open() : mainConn(); + try { + if (own) await c.initialize(); + const { sessionId: sid } = await c.newSession(); + await c.request(M.agent.session.delete, { sessionId: sid }); + await c.request(M.agent.session.delete, { sessionId: "no-such-session" }); + return `both deletes answered${own ? " (own connection)" : ""}`; + } finally { + if (own) await c.close().catch(() => {}); + } + }, + "session.multi": async () => { + const c = mainConn(); + const a = (await c.newSession()).sessionId; + const b = (await c.newSession()).sessionId; + const [ra, rb] = await Promise.all([c.prompt(a, "multi A"), c.prompt(b, "multi B")]); + check(ra?.stopReason === "end_turn" && rb?.stopReason === "end_turn", `stopReasons ${ra?.stopReason} ${rb?.stopReason}`); + await waitFor( + () => c.chunks(a).join("") === "echo: multi A" && c.chunks(b).join("") === "echo: multi B", + () => `chunks A ${abbreviate(c.chunks(a))} B ${abbreviate(c.chunks(b))}`, + ); + return "both end_turn; chunks routed to their sessions"; + }, + "session.fork": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + await c.prompt(sid, "before fork"); + const f = await c.request(M.agent.session.fork, { sessionId: sid, cwd: dir, mcpServers: [] }); + check(typeof f?.sessionId === "string" && f.sessionId !== sid, `fork returned ${abbreviate(f)}`); + const r = await c.prompt(f.sessionId, "in fork"); + check(r?.stopReason === "end_turn", `in fork: stopReason ${r?.stopReason}`); + return `forked ${sid} -> ${f.sessionId}`; + }, + "update.agent_message_chunk": async () => { + const { c, sid } = await endTurn("hello"); + await waitFor(() => c.chunks(sid).join("") === "echo: hello", () => `chunks ${abbreviate(c.chunks(sid))} do not spell "echo: hello"`); + return 'end_turn; chunks spell "echo: hello"'; + }, + "update.user_message_chunk": () => emitStep("user_message_chunk", (u) => u.content?.text === "user-chunk", 'text "user-chunk"'), + "update.agent_thought_chunk": () => emitStep("agent_thought_chunk", (u) => u.content?.text === "thinking", 'text "thinking"'), + "update.tool_call": () => + emitStep( + "tool_call", + (u) => u.toolCallId === "call-1" && u.title === "interop tool" && u.kind === "read" && u.status === "pending", + "toolCallId call-1, title interop tool, kind read, status pending", + ), + "update.tool_call_update": async () => { + const { c, sid } = await endTurn("#emit tool_call_update"); + const ok = () => { + const us = c.of(sid); + const i = us.findIndex((u) => u.sessionUpdate === "tool_call" && u.toolCallId === "call-1"); + const j = us.findIndex( + (u) => + u.sessionUpdate === "tool_call_update" && + u.toolCallId === "call-1" && + u.status === "completed" && + (u.content ?? []).some((x) => x.type === "content" && x.content?.text === "tool output"), + ); + return i >= 0 && j > i; + }; + await waitFor(ok, () => `updates ${abbreviate(c.of(sid), 400)}`); + return "tool_call then tool_call_update completed with \"tool output\""; + }, + "update.tool_call-name": () => emitStep("tool_call", (u) => u.name === "read_file", 'name "read_file"', "#emit tool_call name=read_file"), + "update.plan": () => + emitStep( + "plan", + (u) => + Array.isArray(u.entries) && + u.entries.length === 2 && + u.entries.every((e, i) => { + const f = fixtures.emit.plan.entries[i]; + return e.content === f.content && e.priority === f.priority && e.status === f.status; + }), + "the two fixture entries", + ), + "update.available_commands_update": () => + emitStep( + "available_commands_update", + (u) => u.availableCommands?.length === 1 && u.availableCommands[0].name === "interop" && u.availableCommands[0].input?.hint === "args", + 'one command "interop" with input hint "args"', + ), + "update.current_mode_update": () => emitStep("current_mode_update", (u) => u.currentModeId === "interop-mode-b", "currentModeId interop-mode-b"), + "update.config_option_update": () => + emitStep("config_option_update", (u) => (u.configOptions ?? []).some((o) => o.id === "model" && o.currentValue === "model-b"), "model at model-b"), + "update.session_info_update": () => emitStep("session_info_update", (u) => u.title === "interop title", 'title "interop title"'), + "update.usage_update": () => + emitStep( + "usage_update", + (u) => u.used === 100 && u.size === 1000 && u.cost?.amount === 0.01 && u.cost?.currency === "USD", + "used 100, size 1000, cost 0.01 USD", + ), + "update.unknown": async () => { + const { c, sid } = await endTurn("#emit unknown"); + await waitFor(() => c.chunks(sid).includes("after-unknown"), () => `no "after-unknown" chunk in ${abbreviate(c.chunks(sid))}`); + const surfaced = c.of(sid).some((u) => u.sessionUpdate === "interop_future_update"); + return `"after-unknown" arrived; the unknown update was ${surfaced ? "surfaced" : "dropped"}`; + }, + "stop.max_tokens": () => stopStep("max_tokens"), + "stop.refusal": () => stopStep("refusal"), + "stop.max_turn_requests": () => stopStep("max_turn_requests"), + "enum.tool_call": async () => { + const { c, sid } = await endTurn("#enum tool_call"); + await waitFor(() => c.chunks(sid).includes("after-enum"), () => `no "after-enum" chunk in ${abbreviate(c.chunks(sid))}`); + const u = await updateOf(c, sid, (u) => u.sessionUpdate === "tool_call" && u.toolCallId === "call-enum", "no tool_call call-enum"); + check(u.status === "interop_future_status", `status ${u.status}`); + check(u.kind === "interop_future_kind" || u.kind === "other", `kind ${u.kind}`); + return `tool_call status ${u.status}, kind ${u.kind}; after-enum; end_turn`; + }, + "enum.plan": async () => { + const { c, sid } = await endTurn("#enum plan"); + await waitFor(() => c.chunks(sid).includes("after-enum"), () => `no "after-enum" chunk in ${abbreviate(c.chunks(sid))}`); + const u = await updateOf(c, sid, (u) => u.sessionUpdate === "plan", "no plan"); + const e = u.entries?.[0]; + check(u.entries?.length === 1 && e.content === "future entry", `entries ${abbreviate(u.entries)}`); + check(e.priority === "interop_future_priority" && e.status === "interop_future_status", `entry ${abbreviate(e)}`); + return `plan entry priority ${e.priority}, status ${e.status}; after-enum; end_turn`; + }, + "enum.stop": async () => { + const { c, sid, r } = await promptNew("#enum stop"); + check(r?.stopReason === "interop_future_stop", `stopReason ${r?.stopReason}`); + const after = await c.prompt(sid, "after enum"); + check(after?.stopReason === "end_turn", `"after enum": stopReason ${after?.stopReason}`); + return 'stopReason interop_future_stop; "after enum" end_turn'; + }, + "enum.audience": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + // Role is a closed string union in the TS types; request() sends the params as given. + const r = await c.request(M.agent.session.prompt, { + sessionId: sid, + prompt: [{ type: "text", text: "#enum audience", annotations: { audience: [...fixtures.unknownEnums.audience] } }], + }); + check(r?.stopReason === "end_turn", `stopReason ${r?.stopReason}`); + await waitFor(() => c.chunks(sid).includes("audience: user,interop_future_role"), () => `chunks ${abbreviate(c.chunks(sid))}`); + return "audience: user,interop_future_role; end_turn"; + }, + "mode.set": async () => { + const c = mainConn(); + const s = await c.newSession(); + check((s.modes?.availableModes ?? []).some((m) => m.id === "interop-mode-b"), `session/new modes ${abbreviate(s.modes)}`); + await c.request(M.agent.session.setMode, { sessionId: s.sessionId, modeId: "interop-mode-b" }); + return "set_mode interop-mode-b answered"; + }, + "config.on-new": async () => { + const s = await mainConn().newSession(); + const model = (s.configOptions ?? []).find((o) => o.id === "model"); + check(model?.type === "select" && model.currentValue === "model-a", `configOptions ${abbreviate(s.configOptions)}`); + return "model select at model-a"; + }, + "config.select": async () => { + const c = mainConn(); + const s = await c.newSession(); + const r = await c.request(M.agent.session.setConfigOption, { sessionId: s.sessionId, configId: "model", value: "model-b" }); + const opts = r?.configOptions ?? []; + check(opts.some((o) => o.id === "model" && o.currentValue === "model-b"), `configOptions ${abbreviate(opts)}`); + if (Array.isArray(s.configOptions)) { + const want = s.configOptions.map((o) => o.id).sort().join(","); + const got = opts.map((o) => o.id).sort().join(","); + check(want === got, `the response lists ${got}, session/new listed ${want}`); + } + return `complete list (${opts.map((o) => o.id).join(",")}) with model at model-b`; + }, + "config.boolean": async () => { + const c = mainConn(); + const s = await c.newSession(); + const r = await c.request(M.agent.session.setConfigOption, { sessionId: s.sessionId, configId: "verbose", type: "boolean", value: true }); + check((r?.configOptions ?? []).some((o) => o.id === "verbose" && o.currentValue === true), `configOptions ${abbreviate(r?.configOptions)}`); + return "verbose at true"; + }, + "config.grouped": async () => { + const { c, sid } = await endTurn("#config grouped"); + const u = await updateOf(c, sid, (u) => u.sessionUpdate === "config_option_update", "no config_option_update"); + const effort = (u.configOptions ?? []).find((o) => o.id === "effort"); + check(effort?.type === "select", `configOptions ${abbreviate(u.configOptions)}`); + const groups = (effort.options ?? []).map((g) => g.group); + check(groups.join(",") === "fast,deep", `effort groups ${abbreviate(effort.options)}`); + const values = effort.options.flatMap((g) => g.options ?? []).map((o) => o.value); + check(values.join(",") === "effort-low,effort-medium,effort-high", `effort values ${values}`); + const r = await c.request(M.agent.session.setConfigOption, { sessionId: sid, configId: "effort", value: "effort-high" }); + const opts = r?.configOptions ?? []; + check(opts.some((o) => o.id === "effort" && o.currentValue === "effort-high") && opts.some((o) => o.id === "model"), + `configOptions ${abbreviate(opts)}`); + return `effort grouped fast/deep, values ${values}; set to effort-high`; + }, + "perm.selected": async () => { + const { c, sid } = await endTurn("#permission allow"); + await waitFor(() => c.chunks(sid).includes("permission: selected allow"), () => `no chunk "permission: selected allow" in ${abbreviate(c.chunks(sid))}`); + const asked = c.permissions.get(sid) ?? []; + check(asked.length === 1 && asked[0].options?.length > 0, `${asked.length} permission requests, expected 1 with options`); + return "one permission request; selected allow; end_turn"; + }, + "perm.cancelled": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const hold = {}; + c.holdSessions.set(sid, hold); + const r = await c.prompt(sid, "#permission hold"); + const done = Date.now(); + check(hold.cancelAt !== undefined, "no permission request arrived"); + check(r?.stopReason === "cancelled", `stopReason ${r?.stopReason}`); + check(done - hold.cancelAt <= 5000, `answered ${done - hold.cancelAt} ms after the cancel`); + return `cancelled ${done - hold.cancelAt} ms after the cancel`; + }, + "fs.write": async () => { + const file = path.join(dir, "fs-write.txt"); + const { c, sid } = await endTurn(`#fs write ${file} interop write`); + await waitFor(() => c.chunks(sid).includes("fs write ok"), () => `no chunk "fs write ok" in ${abbreviate(c.chunks(sid))}`); + const content = await fsp.readFile(file, "utf8").catch(() => undefined); + check(content === "interop write", `file content ${JSON.stringify(content)}`); + return 'written through the client: "interop write"'; + }, + "fs.read": async () => { + const file = path.join(dir, "fs-read.txt"); + await fsp.writeFile(file, fixtures.fsReadContent, "utf8"); + const { c, sid } = await promptNew(`#fs read ${file}`); + await waitFor(() => c.chunks(sid).includes(fixtures.fsReadContent), () => `chunks ${abbreviate(c.chunks(sid))}`); + return "the agent read the fixture content"; + }, + "fs.read-range": async () => { + const file = path.join(dir, "fs-read.txt"); + await fsp.writeFile(file, fixtures.fsReadContent, "utf8"); + const { c, sid } = await promptNew(`#fs read-range ${file} line=2 limit=1`); + await waitFor(() => c.chunks(sid).some((t) => t.trim() === "line2"), () => `chunks ${abbreviate(c.chunks(sid))}`); + return 'the agent read "line2"'; + }, + "fs.read-missing": async () => { + const { c, sid, r } = await promptNew(`#fs read-missing ${path.join(dir, "no-such-file.txt")}`); + check(r?.stopReason === "end_turn", `stopReason ${r?.stopReason}`); + await waitFor(() => c.chunks(sid).some((t) => t.startsWith("fs read error")), () => `chunks ${abbreviate(c.chunks(sid))}`); + return `agent saw ${c.chunks(sid).find((t) => t.startsWith("fs read error"))}`; + }, + "term.run": async () => { + const { c, sid } = await promptNew("#terminal run echo hi"); + await waitFor(() => c.chunks(sid).includes("terminal: hi exit=0"), () => `chunks ${abbreviate(c.chunks(sid))}`); + return '"terminal: hi exit=0"'; + }, + "term.kill": async () => { + const t0 = Date.now(); + const { c, sid } = await promptNew("#terminal kill sleep 30"); + await waitFor(() => c.chunks(sid).includes("terminal killed"), () => `chunks ${abbreviate(c.chunks(sid))}`); + const ms = Date.now() - t0; + check(ms <= 5000, `"terminal killed" after ${ms} ms`); + return `"terminal killed" after ${ms} ms`; + }, + "elicit.form": async () => { + const { c, sid } = await promptNew("#elicit form"); + let content; + await waitFor( + () => + c.chunks(sid).some((t) => { + const m = /^elicit: accept (.*)$/.exec(t); + if (!m) return false; + try { + content = JSON.parse(m[1]); + } catch { + return false; + } + return content?.name === "interop" && Object.keys(content).length === 1; + }), + () => `chunks ${abbreviate(c.chunks(sid))}`, + ); + return `elicit: accept ${JSON.stringify(content)}`; + }, + "elicit.complete": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const from = c.elicitationCompletes.length; + await c.prompt(sid, "#elicit url"); + await waitFor( + () => c.elicitationCompletes.slice(from).some((n) => n.elicitationId === "elic-1"), + () => `no elicitation/complete for elic-1; chunks ${abbreviate(c.chunks(sid))}`, + ); + return "elicitation/complete elic-1 arrived"; + }, + "cancel.prompt": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const { pending } = await startSlow(c, sid); + await c.notify(M.agent.session.cancel, { sessionId: sid }); + const tCancel = Date.now(); + const ended = await settle(pending, 5000); + check(ended !== undefined, "TIMEOUT: no answer within 5 s of the cancel"); + check(ended.ok, () => `the prompt failed: ${describeError(ended.error)}`); + check(ended.value?.stopReason === "cancelled", `stopReason ${ended.value?.stopReason}`); + return `cancelled ${Date.now() - tCancel} ms after the cancel`; + }, + "cancel.prompt-while-cancelling": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const { pending } = await startSlow(c, sid, "#slow grace=1000"); + await c.notify(M.agent.session.cancel, { sessionId: sid }); + const during = await settle(c.prompt(sid, "during cancel"), STEP_TIMEOUT); + const first = await settle(pending, 5000); + check(first !== undefined && first.ok && first.value?.stopReason === "cancelled", () => `the first prompt: ${first ? (first.ok ? abbreviate(first.value) : describeError(first.error)) : "TIMEOUT"}`); + check(during !== undefined && !during.ok && during.error?.code === -32600, () => `"during cancel": ${during ? (during.ok ? `answered ${abbreviate(during.value)}` : describeError(during.error)) : "TIMEOUT"}`); + const after = await c.prompt(sid, "after cancel"); + check(after?.stopReason === "end_turn", `"after cancel": stopReason ${after?.stopReason}`); + return 'cancelled; "during cancel" -32600; "after cancel" end_turn'; + }, + "cancel.grace": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const pending = c.prompt(sid, "#hang"); + pending.catch(() => {}); + await sleep(200); + await c.notify(M.agent.session.cancel, { sessionId: sid }); + const tCancel = Date.now(); + const ended = await settle(pending, 6000); + check(ended !== undefined, "TIMEOUT: no answer within 6 s of the cancel"); + check(ended.ok && ended.value?.stopReason === "cancelled", () => (ended.ok ? `stopReason ${ended.value?.stopReason}` : describeError(ended.error))); + return `the SDK answered cancelled ${Date.now() - tCancel} ms after the cancel`; + }, + "cancel.max-duration": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const t0 = Date.now(); + const pending = c.prompt(sid, "#hang"); + pending.catch(() => {}); + const ended = await settle(pending, 11000); + const ms = Date.now() - t0; + check(ended !== undefined, "TIMEOUT: no answer within 11 s"); + check(!ended.ok && ended.error?.code === -32800, () => (ended.ok ? `answered ${abbreviate(ended.value)}` : `failed with ${describeError(ended.error)}, expected -32800`)); + check(ms >= 7000, `-32800 after ${ms} ms, before the 8 s maxPromptDuration`); + const after = await c.prompt(sid, "after max-duration"); + check(after?.stopReason === "end_turn", `"after max-duration": stopReason ${after?.stopReason}`); + return `-32800 after ${ms} ms; "after max-duration" end_turn`; + }, + "cancel-request.client": async () => { + const c = mainConn(); + const { sessionId: sid } = await c.newSession(); + const abort = new AbortController(); + const { pending } = await startSlow(c, sid, "#slow", { cancellationSignal: abort.signal }); + abort.abort(); + const tCancel = Date.now(); + const ended = await settle(pending, 5000); + check(ended !== undefined, "TIMEOUT: the prompt did not end within 5 s of $/cancel_request"); + if (ended.ok) { + check(ended.value?.stopReason === "cancelled", `stopReason ${ended.value?.stopReason}`); + return `stopReason cancelled ${Date.now() - tCancel} ms after $/cancel_request`; + } + check(ended.error?.code === -32800, () => `the prompt failed: ${describeError(ended.error)}`); + return `-32800 ${Date.now() - tCancel} ms after $/cancel_request`; + }, + "cancel-request.agent": async () => { + const t0 = Date.now(); + const { c, sid, r } = await promptNew(`#fs read-slow ${path.join(dir, "slow.txt")}`); + const ms = Date.now() - t0; + check(r?.stopReason === "end_turn", `stopReason ${r?.stopReason}`); + await waitFor(() => c.chunks(sid).includes("cancel-request sent"), () => `chunks ${abbreviate(c.chunks(sid))}`); + check(ms <= 5000, `end_turn after ${ms} ms`); + return `"cancel-request sent" and end_turn after ${ms} ms`; + }, + "cancel-request.unknown": async () => { + const c = mainConn(); + const strays = c.wrapped.strays.length; + await c.newSession(); + const done = [...c.wrapped.sent].reverse().find((s) => s.method === M.agent.session.new); + check(done !== undefined, "no recorded session/new request id"); + await c.notify(M.protocol.cancelRequest, { requestId: 999999 }); + await c.notify(M.protocol.cancelRequest, { requestId: done.id }); + const { sessionId: sid } = await c.newSession(); + const r = await c.prompt(sid, "after cancel-request"); + check(r?.stopReason === "end_turn", `stopReason ${r?.stopReason}`); + await sleep(200); + const errors = c.wrapped.strays.slice(strays); + check(errors.length === 0, () => `error responses came back: ${abbreviate(errors)}`); + return `cancelled 999999 and the completed request ${done.id}; no error; the next prompt end_turn`; + }, + "ext.agent-request": async () => { + const { c, sid } = await promptNew("#ext request _interop/ping"); + await waitFor(() => c.chunks(sid).includes('ext: {"pong":1}'), () => `chunks ${abbreviate(c.chunks(sid))}`); + return 'ext: {"pong":1}'; + }, + "ext.agent-notification": async () => { + const c = mainConn(); + const from = c.extNotifications.length; + await promptNew("#ext notify _interop/note"); + await waitFor( + () => c.extNotifications.slice(from).some((n) => n.params?.n === 1), + () => `notifications ${abbreviate(c.extNotifications.slice(from))}`, + ); + return "_interop/note arrived with {n: 1}"; + }, + "ext.client-request": async () => { + const r = await mainConn().request(fixtures.ext.method, fixtures.ext.params); + check(r?.pong === 1, `result ${abbreviate(r)}`); + return `result ${JSON.stringify(r)}`; + }, + "ext.client-notification": async () => { + const c = mainConn(); + await c.notify(fixtures.ext.notification, fixtures.ext.params); + const { sid } = await promptNew("#ext last-notification"); + await waitFor(() => c.chunks(sid).includes("ext last: _interop/note"), () => `chunks ${abbreviate(c.chunks(sid))}`); + return "ext last: _interop/note"; + }, + "meta.prompt": async () => { + const { c, sid, r } = await promptNew("#meta", { _meta: fixtures.meta }); + const u = await updateOf(c, sid, (u) => u.sessionUpdate === "agent_message_chunk" && u.content?.text === "meta", 'no "meta" chunk'); + check(u._meta?.interop === "m1", `the "meta" chunk's _meta ${abbreviate(u._meta)}`); + check(r?._meta?.interop === "m1", `the PromptResponse _meta ${abbreviate(r?._meta)}`); + return "_meta round trip on the update and the response"; + }, + "meta.permission": async () => { + const { c, sid } = await promptNew("#permission allow meta"); + const asked = c.permissions.get(sid) ?? []; + check(asked.length === 1, `${asked.length} permission requests`); + check(asked[0]._meta?.interop === "m1", `the request _meta ${abbreviate(asked[0]._meta)}`); + return "the permission request carried _meta; echoed in the response"; + }, + "error.method-not-found": async () => { + const c = mainConn(); + const r = await settle(c.request("interop/no_such_method", {}), STEP_TIMEOUT); + check(r !== undefined && !r.ok, () => `interop/no_such_method ${r ? `answered ${abbreviate(r.value)}` : "TIMEOUT"}`); + check(r.error?.code === -32601, `interop/no_such_method failed with ${describeError(r.error)}`); + await c.newSession(); + return "-32601; session/new after it succeeded"; + }, + "big.prompt-1m": () => bigPrompt(1048576), + "big.update-1m": () => bigUpdate(1048576), + "big.prompt-8m": () => bigPrompt(8388608), + "big.update-8m": () => bigUpdate(8388608), + "http.reconnect": async () => { + check(transport === "http", `http.reconnect does not apply to ${transport}`); + const first = Conn.open(); + let sid; + try { + await first.initialize(); + sid = (await first.newSession()).sessionId; + const r = await first.prompt(sid, "before reconnect"); + check(r?.stopReason === "end_turn", `before reconnect: stopReason ${r?.stopReason}`); + } finally { + await first.close().catch(() => {}); + } + const second = Conn.open(); + try { + await second.initialize(); + await second.request(M.agent.session.load, { sessionId: sid, cwd: dir, mcpServers: [] }); + const r = await second.prompt(sid, "after reconnect"); + check(r?.stopReason === "end_turn", `after reconnect: stopReason ${r?.stopReason}`); + } finally { + await second.close().catch(() => {}); + } + return `session ${sid} loaded and prompted on a new connection`; + }, + "stdio.eof-exit": async () => { + check(transport === "stdio", `stdio.eof-exit does not apply to ${transport}`); + const p = spawnAgent(); + const connection = acp.client({ name: "interop-typescript-client-eof" }).connect(p.stream); + try { + const r = await withTimeout( + connection.agent.request(M.agent.initialize, { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: fixtures.client.clientInfo, + }), + STEP_TIMEOUT, + "initialize", + ); + check(r?.protocolVersion === 1, `protocolVersion ${r?.protocolVersion}`); + p.child.stdin.end(); + const tEof = Date.now(); + const exit = await Promise.race([p.exited, sleep(5000).then(() => undefined)]); + if (!exit) { + p.child.kill("SIGKILL"); + throw new StepFailure("no exit on EOF"); + } + return `exited (${exit.code ?? exit.signal}) ${Date.now() - tEof} ms after EOF`; + } finally { + connection.close(); + } + }, + "conn.close": async () => mainConn().close(), +}; + +async function stopStep(reason) { + const { r } = await promptNew(`#stop ${reason}`); + check(r?.stopReason === reason, `stopReason ${r?.stopReason}`); + return `stopReason ${reason}`; +} + +async function bigPrompt(n) { + const { c, sid } = await promptNew(`#len ${"x".repeat(n)}`); + await waitFor(() => c.chunks(sid).includes(`len=${n}`), () => `chunks ${abbreviate(c.chunks(sid))}`); + return `len=${n}`; +} + +async function bigUpdate(n) { + const { c, sid } = await promptNew(`#big ${n}`); + await waitFor(() => c.chunks(sid).some((t) => t.length === n && /^x+$/.test(t)), () => `chunk lengths ${c.chunks(sid).map((t) => t.length).join(",")}`); + return `one chunk of ${n} characters`; +} + +// ---------------------------------------------------------------- run + +async function runStep(id) { + const body = steps[id]; + if (!body) { + fail++; + console.log(`STEP ${id} FAIL (0 ms) -> unknown-step`); + return; + } + const t0 = Date.now(); + let outcome; + let detail; + try { + detail = await withTimeout(Promise.resolve().then(body), STEP_TIMEOUT + 5000, id); + outcome = "PASS"; + pass++; + } catch (e) { + outcome = "FAIL"; + fail++; + detail = e instanceof StepFailure || e instanceof Timeout ? e.message : describeError(e); + } + console.log(`STEP ${id} ${outcome} (${Date.now() - t0} ms) -> ${abbreviate(String(detail), 600)}`); +} + +process.on("unhandledRejection", (e) => console.log(" unhandled rejection:", describeError(e))); + +dir = await fsp.mkdtemp(path.join(os.tmpdir(), "acp-interop-")); +for (const id of process.env.STEPS.split(",").map((s) => s.trim()).filter(Boolean)) { + await runStep(id); +} +const kinds = [...updatesByKind.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => ` upd_${k}=${v}`).join(""); +console.log(`RESULT pass=${pass} fail=${fail} updates_total=${updatesTotal}${kinds}`); +if (main?.proc) main.proc.child.kill("SIGKILL"); +process.exit(0); diff --git a/integration-testing/programs/node/interop/sdk.mjs b/integration-testing/programs/node/interop/sdk.mjs new file mode 100644 index 00000000..ccb74bb6 --- /dev/null +++ b/integration-testing/programs/node/interop/sdk.mjs @@ -0,0 +1,171 @@ +// Shared by the TypeScript interop agent and client: loads the TypeScript SDK from the checkout in +// TS_SDK (built with `npm run build`), and holds the fixtures of the step catalogue +// (integration-testing/steps.json, "fixtures"), hard-coded as the contract asks. +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; + +const sdk = process.env.TS_SDK; +if (!sdk) { + console.error("TS_SDK is not set (the built typescript-sdk checkout)"); + process.exit(2); +} +const load = (f) => import(pathToFileURL(path.join(sdk, "dist", f)).href); + +export const acp = await load("acp.js"); +export const httpClient = await load("http-stream.js"); +export const wsClient = await load("ws-stream.js"); +export const server = await load("server.js"); +export const nodeAdapter = await load("node-adapter.js"); +/** The `ws` package from the SDK's own node_modules (a dev dependency, installed by npm ci). */ +export const ws = createRequire(path.join(sdk, "package.json"))("ws"); + +export const fixtures = { + client: { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + elicitation: { form: {}, url: {} }, + auth: { terminal: true }, + session: { configOptions: { boolean: {} } }, + }, + clientInfo: { name: "interop-typescript-client", version: "1" }, + }, + agent: { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + mcpCapabilities: { http: false, sse: false }, + sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} }, + auth: { logout: {} }, + }, + authMethods: [{ id: "interop-auth", name: "Interop auth", description: "Accepts any authenticate call" }], + terminalAuthMethod: { + type: "terminal", + id: "interop-terminal-auth", + name: "Interop terminal auth", + args: ["--login"], + }, + agentInfo: { name: "interop-typescript-agent", version: "1" }, + }, + modes: { + currentModeId: "interop-mode-a", + availableModes: [ + { id: "interop-mode-a", name: "Mode A" }, + { id: "interop-mode-b", name: "Mode B" }, + ], + }, + /** fixtures.configOptions; `verbose` only for a client that advertised configOptions.boolean. */ + configOptions(model = "model-a", verbose = false, withBoolean = true) { + const out = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: model, + options: [ + { value: "model-a", name: "Model A" }, + { value: "model-b", name: "Model B" }, + ], + }, + ]; + if (withBoolean) { + out.push({ id: "verbose", name: "Verbose", type: "boolean", currentValue: verbose }); + } + return out; + }, + permission: { + toolCall: { toolCallId: "perm-1", title: "interop permission", kind: "edit", status: "pending" }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }, + fsReadContent: "line1\nline2\nline3\n", + emit: { + user_message_chunk: { content: { type: "text", text: "user-chunk" } }, + agent_thought_chunk: { content: { type: "text", text: "thinking" } }, + tool_call: { toolCallId: "call-1", title: "interop tool", kind: "read", status: "pending" }, + tool_call_update: { + toolCallId: "call-1", + status: "completed", + content: [{ type: "content", content: { type: "text", text: "tool output" } }], + }, + plan: { + entries: [ + { content: "step one", priority: "high", status: "pending" }, + { content: "step two", priority: "low", status: "completed" }, + ], + }, + available_commands_update: { + availableCommands: [{ name: "interop", description: "interop command", input: { hint: "args" } }], + }, + current_mode_update: { currentModeId: "interop-mode-b" }, + session_info_update: { title: "interop title" }, + usage_update: { used: 100, size: 1000, cost: { amount: 0.01, currency: "USD" } }, + unknown: { sessionUpdate: "interop_future_update", payload: { x: 1 } }, + }, + unknownEnums: { + toolCall: { sessionUpdate: "tool_call", toolCallId: "call-enum", title: "interop enum tool", kind: "interop_future_kind", status: "interop_future_status" }, + plan: { sessionUpdate: "plan", entries: [{ content: "future entry", priority: "interop_future_priority", status: "interop_future_status" }] }, + stopReason: "interop_future_stop", + audience: ["user", "interop_future_role"], + }, + /** fixtures.groupedConfigOption, at the given currentValue. */ + groupedConfigOption(effort = "effort-low") { + return { + id: "effort", + name: "Effort", + type: "select", + currentValue: effort, + options: [ + { group: "fast", name: "Fast", options: [{ value: "effort-low", name: "Low" }] }, + { group: "deep", name: "Deep", options: [{ value: "effort-medium", name: "Medium" }, { value: "effort-high", name: "High" }] }, + ], + }; + }, + ext: { + method: "_interop/ping", + notification: "_interop/note", + params: { n: 1 }, + result: { pong: 1 }, + }, + meta: { interop: "m1" }, + elicitation: { + form: { + message: "interop form", + requestedSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] }, + }, + formAnswer: { action: "accept", content: { name: "interop" } }, + url: { elicitationId: "elic-1", url: "https://example.invalid/interop", message: "interop url" }, + }, +}; + +/** A pass-through parser for extension methods (the SDK requires one for custom methods). */ +export const anyParams = (params) => params; + +export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** One line, at most `max` characters. */ +export function abbreviate(value, max = 300) { + const s = String(typeof value === "string" ? value : safeJson(value)).replace(/[\r\n]+/g, " "); + return s.length > max ? `${s.slice(0, max)}... (${s.length} chars)` : s; +} + +export function safeJson(value) { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** A thrown value as one line: JSON-RPC errors keep their code. */ +export function describeError(e) { + if (e && typeof e === "object" && "code" in e && typeof e.code === "number") { + return `error ${e.code} ${e.message ?? ""}${e.data !== undefined ? " " + abbreviate(e.data, 200) : ""}`; + } + return String(e?.message ?? e); +} diff --git a/integration-testing/programs/node/launch/agent.sh b/integration-testing/programs/node/launch/agent.sh new file mode 100755 index 00000000..85016253 --- /dev/null +++ b/integration-testing/programs/node/launch/agent.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the TypeScript interop agent: --transport stdio | --transport http|ws --port . +# Env TS_SDK (the built typescript-sdk checkout). Launcher contract: integration-testing/README.md, +# "Contracts". Prints nothing itself: on stdio, stdout is the protocol stream. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec node "$here/interop/agent.mjs" "$@" diff --git a/integration-testing/programs/node/launch/build.sh b/integration-testing/programs/node/launch/build.sh new file mode 100755 index 00000000..37afb8ff --- /dev/null +++ b/integration-testing/programs/node/launch/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# The TypeScript interop programs are plain ES modules: nothing to compile. Checks that TS_SDK is a +# built typescript-sdk checkout (peers.json builds it: npm ci && npm run build) with the `ws` +# package the programs load from its node_modules. Launcher contract: integration-testing/README.md, +# "Contracts". Env: TS_SDK. +set -euo pipefail +: "${TS_SDK:?TS_SDK is not set}" +for f in dist/acp.js dist/http-stream.js dist/ws-stream.js dist/server.js dist/node-adapter.js node_modules/ws/package.json; do + [ -e "$TS_SDK/$f" ] || { echo "build.sh: $TS_SDK/$f is missing: is TS_SDK a built typescript-sdk checkout?" >&2; exit 1; } +done +command -v node >/dev/null || { echo "build.sh: node is not on the PATH" >&2; exit 1; } diff --git a/integration-testing/programs/node/launch/client.sh b/integration-testing/programs/node/launch/client.sh new file mode 100755 index 00000000..9485aa01 --- /dev/null +++ b/integration-testing/programs/node/launch/client.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the TypeScript interop client: --transport stdio (env AGENT_CMD) | --transport http|ws --url . +# Env STEPS (required), STEP_TIMEOUT_MS (default 15000), TS_SDK (the built typescript-sdk checkout). +# Launcher contract: integration-testing/README.md, "Contracts". +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec node "$here/interop/client.mjs" "$@" diff --git a/integration-testing/programs/node/ts-client.mjs b/integration-testing/programs/node/ts-client.mjs deleted file mode 100644 index 47111565..00000000 --- a/integration-testing/programs/node/ts-client.mjs +++ /dev/null @@ -1,55 +0,0 @@ -// Interop client on the TypeScript SDK's Streamable HTTP client. Usage: node ts-client.mjs , -// env TS_SDK (built typescript-sdk checkout). Prints one "STEP PASS|FAIL" line per step and -// a RESULT line with pass/fail counts, updates per step and in total. Per-step counts are -// indicative only: nothing orders an update's dispatch against the response that follows it. The DELETE happens when connectWith -// returns (the SDK closes the stream itself); the server-side log is where scenarios assert on it. -import { pathToFileURL } from "node:url"; -import path from "node:path"; - -const sdk = process.env.TS_SDK; -if (!sdk) { console.error("TS_SDK is not set"); process.exit(2); } -const load = (f) => import(pathToFileURL(path.join(sdk, "dist", f)).href); -const acp = await load("acp.js"); -const { MemoryAcpCookieStore, createHttpStream } = await load("http-stream.js"); - -const url = process.argv[2]; -const stream = createHttpStream(url, { cookieStore: new MemoryAcpCookieStore() }); -let pass = 0, fail = 0, updates = 0, total = 0; -const perStep = {}; -const key = (n) => n.replace(/[^A-Za-z0-9]+/g, "_") + "_updates"; -const step = async (name, fn) => { - updates = 0; const t0 = Date.now(); - try { - const r = await fn(); pass++; - console.log(`STEP ${name} PASS (${Date.now() - t0} ms) -> ${JSON.stringify(r)}`); return r; - } catch (e) { - fail++; - console.log(`STEP ${name} FAIL (${Date.now() - t0} ms) -> ${e?.stack ?? JSON.stringify(e)}`); return undefined; - } finally { perStep[key(name)] = updates; } -}; -const withTimeout = (p, ms = 15000) => Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error("TIMEOUT " + ms)), ms))]); -let exitCode = 0; -try { - await withTimeout(acp.client({ name: "ts-interop-client" }) - .onRequest(acp.methods.client.session.requestPermission, (ctx) => { - console.log(" permission request", JSON.stringify(ctx.params.options)); - return { outcome: { outcome: "selected", optionId: ctx.params.options[0].optionId } }; - }) - .onNotification(acp.methods.client.session.update, (ctx) => { updates++; total++; console.log(" update:", JSON.stringify(ctx.params.update)); }) - .connectWith(stream, async (ctx) => { - await step("initialize", () => withTimeout(ctx.request(acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: {} }))); - const s = await step("session/new", () => withTimeout(ctx.request(acp.methods.agent.session.new, { cwd: process.cwd(), mcpServers: [] }))); - const sid = s?.sessionId ?? "missing"; - const p = (t) => withTimeout(ctx.request(acp.methods.agent.session.prompt, { sessionId: sid, prompt: [{ type: "text", text: t }] })); - await step("prompt1", () => p("hello one")); - await step("prompt2", () => p("hello two")); - await step("prompt3-permission", () => p("please ask permission")); - await step("session/load", () => withTimeout(ctx.request(acp.methods.agent.session.load, { sessionId: sid, cwd: process.cwd(), mcpServers: [] }))); - await step("prompt-after-load", () => p("after load")); - }), 60000); - console.log("connectWith returned"); -} catch (e) { - console.log("connectWith failed:", e?.stack ?? e); exitCode = 1; -} -console.log(`RESULT pass=${pass} fail=${fail} updates_total=${total} ` + Object.entries(perStep).map(([k, v]) => `${k}=${v}`).join(" ")); -process.exit(exitCode); diff --git a/integration-testing/programs/node/ts-server.mjs b/integration-testing/programs/node/ts-server.mjs deleted file mode 100644 index 88b227ad..00000000 --- a/integration-testing/programs/node/ts-server.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// Interop agent on the TypeScript SDK's Streamable HTTP server, wired the way the SDK's example -// ships it: the HTTP handler plus the WebSocket upgrade handler on the same port. (That upgrade -// listener is what turned the JDK's "Upgrade: h2c" into a 405 before the Java client pinned -// HTTP/1.1 after its probe.) Env: TS_SDK (built typescript-sdk checkout), PORT, NO_WS=1 to drop -// the upgrade listener. Prints "READY " on stdout once listening; one [http] line per request. -import { createServer } from "node:http"; -import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; -import path from "node:path"; - -const sdk = process.env.TS_SDK; -if (!sdk) { console.error("TS_SDK is not set"); process.exit(2); } -const load = (f) => import(pathToFileURL(path.join(sdk, "dist", f)).href); -const acp = await load("acp.js"); -const { createNodeHttpHandler, createNodeWebSocketUpgradeHandler } = await load("node-adapter.js"); -const { AcpServer } = await load("server.js"); -const { WebSocketServer } = createRequire(path.join(sdk, "package.json"))("ws"); - -const sessions = new Set(); -const chunk = (sessionId, text) => ({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } }); -const agent = acp.agent({ name: "ts-interop-agent" }) - .onRequest(acp.methods.agent.initialize, () => { - console.error("[agent] initialize"); - return { protocolVersion: acp.PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; - }) - .onRequest(acp.methods.agent.session.new, () => { - const id = crypto.randomUUID(); sessions.add(id); - console.error("[agent] session/new", id); - return { sessionId: id }; - }) - .onRequest(acp.methods.agent.session.load, async (ctx) => { - console.error("[agent] session/load", ctx.params.sessionId); - if (!sessions.has(ctx.params.sessionId)) throw new Error("not found"); - await ctx.client.notify(acp.methods.client.session.update, chunk(ctx.params.sessionId, "replayed history")); - return {}; - }) - .onRequest(acp.methods.agent.session.prompt, async (ctx) => { - const sid = ctx.params.sessionId; - const text = ctx.params.prompt.map((b) => b.text ?? "").join(" "); - console.error("[agent] session/prompt", text); - if (text.includes("permission")) { - const r = await ctx.client.request(acp.methods.client.session.requestPermission, { - sessionId: sid, - toolCall: { toolCallId: "tc-1", title: "Run thing", kind: "execute", status: "pending" }, - options: [{ optionId: "allow_once", name: "Allow once", kind: "allow_once" }, - { optionId: "reject_once", name: "Reject once", kind: "reject_once" }] }); - console.error("[agent] permission result", JSON.stringify(r)); - await ctx.client.notify(acp.methods.client.session.update, chunk(sid, "permission: " + JSON.stringify(r.outcome))); - } - await ctx.client.notify(acp.methods.client.session.update, chunk(sid, "echo: ")); - await ctx.client.notify(acp.methods.client.session.update, chunk(sid, text)); - return { stopReason: "end_turn" }; - }) - .onNotification(acp.methods.agent.session.cancel, () => {}); - -const acpServer = new AcpServer({ agent }); -const handler = createNodeHttpHandler(acpServer); -const wss = new WebSocketServer({ noServer: true }); -const wsUpgrade = createNodeWebSocketUpgradeHandler(acpServer, wss); -const port = Number(process.env.PORT ?? 0); -const server = createServer((req, res) => { - const t0 = Date.now(); - const h = req.headers; - const line = `[http] ${req.method} ${req.url} HTTP/${req.httpVersion} ct=${h["content-type"]} accept=${h["accept"]} conn=${h["acp-connection-id"]} sess=${h["acp-session-id"]} upgrade=${h["upgrade"]}`; - res.on("finish", () => console.error(`${line} -> ${res.statusCode} ct=${res.getHeader("content-type")} ${Date.now() - t0}ms`)); - if (new URL(req.url, "http://x").pathname !== "/acp") { res.writeHead(404); res.end(); return; } - handler(req, res); -}); -if (!process.env.NO_WS) server.on("upgrade", (req, socket, head) => { - console.error(`[upgrade] ${req.method} ${req.url} HTTP/${req.httpVersion} upgrade=${req.headers["upgrade"]}`); - wsUpgrade(req, socket, head); -}); -server.on("clientError", (err) => console.error("[clientError]", err.message)); -server.listen(port, "127.0.0.1", () => { - console.error(`listening http://127.0.0.1:${server.address().port}/acp`); - console.log(`READY ${server.address().port}`); -}); diff --git a/integration-testing/programs/python/interop/agent.py b/integration-testing/programs/python/interop/agent.py new file mode 100644 index 00000000..6c932f67 --- /dev/null +++ b/integration-testing/programs/python/interop/agent.py @@ -0,0 +1,677 @@ +"""The Python interop agent of the step catalogue (integration-testing/steps.json). + + agent.sh --transport stdio + agent.sh --transport http|ws --port + +Driven entirely by the prompt text (the catalogue's "directives"); it keeps no script. On stdio, +stdout is the protocol stream: every log line and every STEP agent. line goes to stderr, and +the agent exits when stdin reaches EOF. On http/ws it serves the Python SDK's ASGI app (Streamable +HTTP and WebSocket on the same /acp endpoint) with Hypercorn, prints READY on stdout once +listening and logs one [http] line per request. Contract: integration-testing/README.md. + +stdio runs with use_unstable_protocol=True, so session/resume, session/close and session/fork are +routed. The SDK's HTTP and WebSocket servers build their AgentSideConnection without that flag +(acp/http/server.py:270, acp/ws/server.py:55), so there those methods answer -32601 even though +this agent implements them (declared in expectations/python.json). +""" + +from __future__ import annotations + +import asyncio +import os +import socket +import sys +import time +import uuid +from typing import Any + +from acp import RequestError, run_agent +from acp.schema import ElicitationFormSessionMode, ElicitationUrlSessionMode +from fixtures import ( + AGENT_CAPABILITIES, + AGENT_INFO, + AUTH_METHOD, + ELICIT_FORM_MESSAGE, + ELICIT_FORM_SCHEMA, + ELICIT_URL, + EMIT, + EXT_PARAMS, + EXT_RESULT, + FS_READ_CONTENT, + GROUPED_VALUES, + META_KEY, + META_VALUE, + MODE_IDS, + MODES, + PERMISSION_OPTIONS, + PERMISSION_TOOL_CALL, + TERMINAL_AUTH_METHOD, + UNKNOWN_AUDIENCE, + UNKNOWN_ENUM_PLAN, + UNKNOWN_ENUM_TOOL_CALL, + UNKNOWN_STOP_REASON, + UNKNOWN_UPDATE, + compact, + config_options, + dump, + log, + step_line, +) + + +def agent_step(step_id: str, ok: bool, started: float, detail: str) -> None: + step_line(sys.stderr, "agent." + step_id, ok, started, detail) + + +class Turn: + """One running session/prompt: how it was asked to stop, if it was.""" + + def __init__(self) -> None: + self.stop = asyncio.Event() + self.reason: str | None = None + self.blocks: list = [] # the prompt's content blocks, as the SDK validated them + + def cancel(self, reason: str) -> None: + if self.reason is None: + self.reason = reason + self.stop.set() + + +class Session: + def __init__(self, session_id: str, cwd: str) -> None: + self.id = session_id + self.cwd = cwd + self.history: list[tuple[str, list[str]]] = [] + self.mode = MODES["currentModeId"] + self.model = "model-a" + self.verbose = False + self.effort: str | None = None # set by #config grouped + self.closed = False + self.turns: set[Turn] = set() + + +# Sessions are process-wide: a session created on one HTTP connection can be loaded on another. +SESSIONS: dict[str, Session] = {} + + +def _path(caps: dict, *keys: str) -> Any: + v: Any = caps + for k in keys: + if not isinstance(v, dict): + return None + v = v.get(k) + return v + + +class InteropAgent: + def __init__(self, conn: Any) -> None: + self.conn = conn + self.client_caps: dict | None = None + self.last_ext_notification: str | None = None + + # ------------------------------------------------------------ lifecycle + + def _with_boolean(self) -> bool: + return _path(self.client_caps or {}, "session", "configOptions", "boolean") is not None + + def _session(self, session_id: str) -> Session: + s = SESSIONS.get(session_id) + if s is None: + raise RequestError.invalid_params({"details": f"unknown session {session_id}"}) + if s.closed: + raise RequestError.invalid_params({"details": f"session {session_id} is closed"}) + return s + + def _session_state(self, s: Session) -> dict: + state = dict(MODES) + state["currentModeId"] = s.mode + return {"modes": state, "configOptions": self._options(s)} + + def _options(self, s: Session) -> list: + return config_options(s.model, s.verbose, self._with_boolean(), s.effort) + + async def initialize(self, protocol_version: int, client_capabilities=None, client_info=None, **kw): + if client_capabilities is not None: + self.client_caps = client_capabilities.model_dump(mode="json", by_alias=True, exclude_unset=True) + log(f"[agent] initialize protocolVersion={protocol_version} clientCapabilities={compact(self.client_caps)}") + methods = [AUTH_METHOD] + if _path(self.client_caps or {}, "auth", "terminal") is True: + methods.append(TERMINAL_AUTH_METHOD) + return { + "protocolVersion": 1, + "agentCapabilities": AGENT_CAPABILITIES, + "authMethods": methods, + "agentInfo": AGENT_INFO, + } + + async def authenticate(self, method_id: str, **kw): + agent_step("auth.authenticate", method_id == "interop-auth", time.monotonic(), f"methodId={method_id}") + return {} + + async def logout(self, **kw): + return {} + + async def new_session(self, cwd: str, additional_directories=None, mcp_servers=None, **kw): + s = Session("py-" + uuid.uuid4().hex[:12], cwd) + SESSIONS[s.id] = s + log(f"[agent] session/new {s.id} cwd={cwd}") + return {"sessionId": s.id, **self._session_state(s)} + + async def load_session(self, cwd: str, session_id: str, mcp_servers=None, additional_directories=None, **kw): + s = self._session(session_id) + log(f"[agent] session/load {session_id} replaying {len(s.history)} turn(s)") + for text, chunks in list(s.history): + await self._update(s.id, {"sessionUpdate": "user_message_chunk", "content": _text(text)}) + for c in chunks: + await self._chunk(s.id, c) + return self._session_state(s) + + async def resume_session(self, session_id: str, cwd: str, additional_directories=None, mcp_servers=None, **kw): + s = self._session(session_id) + log(f"[agent] session/resume {session_id}") + return self._session_state(s) + + async def fork_session(self, session_id: str, cwd: str, additional_directories=None, mcp_servers=None, **kw): + src = self._session(session_id) + s = Session("py-" + uuid.uuid4().hex[:12], cwd) + s.history = list(src.history) + SESSIONS[s.id] = s + log(f"[agent] session/fork {session_id} -> {s.id}") + return {"sessionId": s.id, **self._session_state(s)} + + async def list_sessions(self, cwd: str | None = None, cursor: str | None = None, **kw): + found = [s for s in SESSIONS.values() if cwd is None or s.cwd == cwd] + return {"sessions": [{"sessionId": s.id, "cwd": s.cwd} for s in found]} + + async def close_session(self, session_id: str, **kw): + started = time.monotonic() + s = SESSIONS.get(session_id) + if s is None: + agent_step("session.close", False, started, f"close of unknown session {session_id}") + raise RequestError.invalid_params({"details": f"unknown session {session_id}"}) + running = list(s.turns) + s.closed = True + for t in running: + t.cancel("close") + agent_step("session.close", bool(running), started, + f"closed {session_id}; cancelled {len(running)} running turn(s)") + return {} + + async def delete_session(self, session_id: str, **kw): + s = SESSIONS.pop(session_id, None) + if s is not None: + s.closed = True + for t in list(s.turns): + t.cancel("delete") + return {} + + async def set_session_mode(self, session_id: str, mode_id: str, **kw): + started = time.monotonic() + s = SESSIONS.get(session_id) + ok = s is not None and mode_id in MODE_IDS + agent_step("mode.set", ok and mode_id == "interop-mode-b", started, f"session={session_id} modeId={mode_id}") + if not ok: + raise RequestError.invalid_params({"details": f"unknown session or mode: {session_id} {mode_id}"}) + s.mode = mode_id + return {} + + async def set_config_option(self, config_id: str, session_id: str, value, **kw): + s = self._session(session_id) + if config_id == "model" and value in ("model-a", "model-b"): + s.model = value + elif config_id == "verbose" and isinstance(value, bool) and self._with_boolean(): + s.verbose = value + elif config_id == "effort" and s.effort is not None and value in GROUPED_VALUES: + s.effort = value + agent_step("config.grouped", value == "effort-high", time.monotonic(), f"effort set to {value}") + else: + raise RequestError.invalid_params({"details": f"unknown config option {config_id}={value!r}"}) + return {"configOptions": self._options(s)} + + async def cancel(self, session_id: str, **kw): + s = SESSIONS.get(session_id) + log(f"[agent] session/cancel {session_id} running={len(s.turns) if s else 0}") + if s is not None: + for t in list(s.turns): + t.cancel("cancel") + + async def ext_method(self, method: str, params: dict) -> dict: + if "_" + method == "_interop/ping": + return EXT_RESULT + raise RequestError.method_not_found("_" + method) + + async def ext_notification(self, method: str, params: dict) -> None: + self.last_ext_notification = "_" + method + log(f"[agent] extension notification _{method} {compact(params)}") + + # ------------------------------------------------------------ prompt turn + + async def _update(self, session_id: str, update: dict) -> None: + await self.conn.session_update(session_id=session_id, update=update) + + async def _chunk(self, session_id: str, text: str, meta: dict | None = None) -> None: + update = {"sessionUpdate": "agent_message_chunk", "content": _text(text)} + if meta: + update["_meta"] = meta + await self._update(session_id, update) + + async def prompt(self, session_id: str, prompt: list, **kw): + s = self._session(session_id) + text = next((b.text for b in prompt if getattr(b, "type", None) == "text"), "") + meta = dict(kw) + log(f"[agent] session/prompt {session_id} {text[:80]!r}{'...' if len(text) > 80 else ''}") + turn = Turn() + turn.blocks = prompt + s.turns.add(turn) + try: + if not text.startswith("#"): + chunks = ["echo: ", text] + for c in chunks: + await self._chunk(s.id, c) + s.history.append((text, chunks)) + return {"stopReason": "end_turn"} + name, _, rest = text[1:].partition(" ") + handler = getattr(self, "_d_" + name.replace("-", "_"), None) + if handler is None: + raise RequestError(-32602, f"unknown directive: #{name}") + resp = await handler(s, turn, rest, meta) + log(f"[agent] session/prompt {session_id} #{name} -> {resp.get('stopReason')}") + return resp + finally: + s.turns.discard(turn) + + # Directives: each returns the PromptResponse. + + async def _d_permission(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + # "allow": perm.selected; "allow meta": meta.permission (_meta on the request); "hold": + # perm.cancelled. + mode = rest.strip() + hold = mode == "hold" + if mode not in ("allow", "allow meta", "hold"): + raise RequestError(-32602, f"unknown directive: #permission {rest}") + extra = {META_KEY: META_VALUE} if mode == "allow meta" else {} + resp = await self.conn.request_permission( + session_id=s.id, tool_call=PERMISSION_TOOL_CALL, options=PERMISSION_OPTIONS, **extra + ) + outcome = dump(resp.outcome) or {} + selected = outcome.get("outcome") == "selected" + if selected: + await self._chunk(s.id, f"permission: selected {outcome.get('optionId')}") + else: + await self._chunk(s.id, "permission: cancelled") + if hold: + agent_step("perm.cancelled", outcome.get("outcome") == "cancelled", started, f"outcome {compact(outcome)}") + return {"stopReason": "cancelled" if not selected else "end_turn"} + if mode == "allow meta": + rmeta = resp.field_meta or {} + agent_step("meta.permission", rmeta.get(META_KEY) == META_VALUE, started, + f"RequestPermissionResponse _meta {compact(rmeta) if rmeta else 'absent'}") + else: + agent_step("perm.selected", selected and outcome.get("optionId") == "allow", started, + f"outcome {compact(outcome)}") + return {"stopReason": "end_turn"} + + async def _d_fs(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + op, _, args = rest.partition(" ") + fs = _path(self.client_caps or {}, "fs") or {} + if op == "write": + path, _, content = args.partition(" ") + if fs.get("writeTextFile") is not True: + agent_step("fs.write", False, started, "client did not advertise fs.writeTextFile") + await self._chunk(s.id, "fs write error capability") + return {"stopReason": "end_turn"} + try: + r = await self.conn.write_text_file(session_id=s.id, path=path, content=content) + except RequestError as e: + agent_step("fs.write", False, started, f"fs/write_text_file failed: {e.code} {e}") + await self._chunk(s.id, f"fs write error {e.code}") + return {"stopReason": "end_turn"} + agent_step("fs.write", True, started, f"fs/write_text_file answered {compact(dump(r))}") + await self._chunk(s.id, "fs write ok") + return {"stopReason": "end_turn"} + if op in ("read", "read-range", "read-missing", "read-slow"): + parts = args.split(" ") + path = parts[0] + opts = dict(p.split("=", 1) for p in parts[1:] if "=" in p) + line = int(opts["line"]) if "line" in opts else None + limit = int(opts["limit"]) if "limit" in opts else None + step_id = "cancel-request.agent" if op == "read-slow" else f"fs.{op}" + if fs.get("readTextFile") is not True: + agent_step(step_id, False, started, "client did not advertise fs.readTextFile") + await self._chunk(s.id, "fs read error capability") + return {"stopReason": "end_turn"} + if op == "read-slow": + return await self._read_slow(s, path, started) + try: + r = await self.conn.read_text_file(session_id=s.id, path=path, line=line, limit=limit) + except RequestError as e: + agent_step(step_id, step_id == "fs.read-missing", started, f"fs/read_text_file failed with {e.code} {e}") + await self._chunk(s.id, f"fs read error {e.code}") + return {"stopReason": "end_turn"} + content = r.content + if step_id == "fs.read-range": + agent_step(step_id, content.strip() == "line2", started, f"content {content!r}") + elif step_id == "fs.read-missing": + agent_step(step_id, False, started, f"reading a missing file succeeded: {content!r}") + else: + agent_step(step_id, content == FS_READ_CONTENT, started, f"content {content!r}") + await self._chunk(s.id, content) + return {"stopReason": "end_turn"} + raise RequestError(-32602, f"unknown directive: #fs {op}") + + async def _read_slow(self, s: Session, path: str, started: float): + # The Python SDK has no $/cancel_request: only the method-name constant exists + # (acp/meta.py:49); no send API, and the connection does not route it. So the agent can + # only abandon its own pending request locally, which puts nothing on the wire. + read = asyncio.ensure_future(self.conn.read_text_file(session_id=s.id, path=path)) + await asyncio.sleep(0.2) + read.cancel() + agent_step("cancel-request.agent", False, started, + "cannot send $/cancel_request: python-sdk has no API for it (acp/meta.py:49)") + await self._chunk(s.id, "cancel-request unsupported") + return {"stopReason": "end_turn"} + + async def _d_emit(self, s: Session, turn: Turn, rest: str, meta: dict): + kind, _, args = rest.partition(" ") + opts = dict(p.split("=", 1) for p in args.split(" ") if "=" in p) + if kind == "unknown": + # The typed session_update refuses an unknown variant: send the raw notification. + await self.conn._conn.send_notification("session/update", {"sessionId": s.id, "update": UNKNOWN_UPDATE}) + await self._chunk(s.id, "after-unknown") + return {"stopReason": "end_turn"} + if kind == "config_option_update": + body = {"configOptions": config_options("model-b", s.verbose, self._with_boolean())} + elif kind in EMIT: + body = dict(EMIT[kind]) + else: + raise RequestError(-32602, f"unknown directive: #emit {kind}") + if kind == "tool_call_update": + await self._update(s.id, {"sessionUpdate": "tool_call", **EMIT["tool_call"]}) + if kind == "tool_call" and "name" in opts: + body["name"] = opts["name"] + await self._update(s.id, {"sessionUpdate": kind, **body}) + return {"stopReason": "end_turn"} + + async def _d_enum(self, s: Session, turn: Turn, rest: str, meta: dict): + what = rest.strip() + if what in ("tool_call", "plan"): + # The typed session_update validates against Literal enums (acp/schema.py:5789-5793): + # send the raw notification. + update = UNKNOWN_ENUM_TOOL_CALL if what == "tool_call" else UNKNOWN_ENUM_PLAN + await self.conn._conn.send_notification("session/update", {"sessionId": s.id, "update": update}) + await self._chunk(s.id, "after-enum") + return {"stopReason": "end_turn"} + if what == "stop": + # The prompt handler's dict result is sent as is (acp/connection.py:201-208). + await self._chunk(s.id, "stop") + return {"stopReason": UNKNOWN_STOP_REASON} + if what == "audience": + started = time.monotonic() + blocks = turn.blocks + first = blocks[0] if blocks else None + ann = getattr(first, "annotations", None) + audience = list(ann.audience) if ann is not None and ann.audience is not None else None + agent_step("enum.audience", audience == UNKNOWN_AUDIENCE, started, f"audience {audience}") + await self._chunk(s.id, "audience: " + (",".join(audience) if audience is not None else "none")) + return {"stopReason": "end_turn"} + raise RequestError(-32602, f"unknown directive: #enum {what}") + + async def _d_config(self, s: Session, turn: Turn, rest: str, meta: dict): + if rest.strip() != "grouped": + raise RequestError(-32602, f"unknown directive: #config {rest.strip()}") + if s.effort is None: + s.effort = "effort-low" + await self._update(s.id, {"sessionUpdate": "config_option_update", "configOptions": self._options(s)}) + return {"stopReason": "end_turn"} + + async def _d_stop(self, s: Session, turn: Turn, rest: str, meta: dict): + await self._chunk(s.id, "stop") + return {"stopReason": rest.strip()} + + async def _d_slow(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + opts = dict(p.split("=", 1) for p in rest.split(" ") if "=" in p) + grace = int(opts.get("grace", "0")) / 1000.0 + deadline = started + 10.0 + while not turn.stop.is_set() and time.monotonic() < deadline: + await self._chunk(s.id, "tick") + try: + await asyncio.wait_for(turn.stop.wait(), 0.1) + except asyncio.TimeoutError: + pass + if not turn.stop.is_set(): + # Nothing cancelled the turn. A $/cancel_request (cancel-request.client) cannot reach + # this handler: the Python SDK routes no such method (acp/meta.py:49 is only the name). + log(f"[agent] #slow on {s.id} ran 10 s without a cancel") + return {"stopReason": "end_turn"} + end = time.monotonic() + grace + while time.monotonic() < end and turn.reason == "cancel": + await self._chunk(s.id, "tick") + await asyncio.sleep(0.1) + if turn.reason == "cancel": + agent_step("cancel.prompt", True, started, "session/cancel arrived for the running session") + return {"stopReason": "cancelled"} + + async def _d_hang(self, s: Session, turn: Turn, rest: str, meta: dict): + await asyncio.Event().wait() + + async def _d_terminal(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + op, _, cmdline = rest.partition(" ") + argv = cmdline.split(" ") + step_id = "term.kill" if op == "kill" else "term.run" + if op not in ("run", "kill"): + raise RequestError(-32602, f"unknown directive: #terminal {op}") + if (self.client_caps or {}).get("terminal") is not True: + agent_step(step_id, False, started, "client did not advertise terminal") + await self._chunk(s.id, "terminal error capability") + return {"stopReason": "end_turn"} + try: + created = await self.conn.create_terminal(session_id=s.id, command=argv[0], args=argv[1:]) + tid = created.terminal_id + if op == "run": + ex = await self.conn.wait_for_terminal_exit(session_id=s.id, terminal_id=tid) + out = await self.conn.terminal_output(session_id=s.id, terminal_id=tid) + await self.conn.release_terminal(session_id=s.id, terminal_id=tid) + output = out.output.strip() + agent_step(step_id, "hi" in output and ex.exit_code == 0, started, + f"output {output!r} exitCode={ex.exit_code}") + await self._chunk(s.id, f"terminal: {output} exit={ex.exit_code}") + return {"stopReason": "end_turn"} + await asyncio.sleep(0.2) + await self.conn.kill_terminal(session_id=s.id, terminal_id=tid) + killed = time.monotonic() + ex = await asyncio.wait_for(self.conn.wait_for_terminal_exit(session_id=s.id, terminal_id=tid), 5) + waited = time.monotonic() - killed + await self.conn.release_terminal(session_id=s.id, terminal_id=tid) + agent_step(step_id, waited <= 5, started, f"wait_for_exit {compact(dump(ex))} {int(waited * 1000)} ms after kill") + await self._chunk(s.id, "terminal killed") + return {"stopReason": "end_turn"} + except (RequestError, asyncio.TimeoutError) as e: + agent_step(step_id, False, started, f"terminal failed: {type(e).__name__} {e}") + await self._chunk(s.id, f"terminal error {getattr(e, 'code', 'timeout')}") + return {"stopReason": "end_turn"} + + async def _d_elicit(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + mode = rest.strip() + if mode == "form": + r = await self.conn.create_elicitation( + message=ELICIT_FORM_MESSAGE, + mode=ElicitationFormSessionMode(session_id=s.id, requested_schema=ELICIT_FORM_SCHEMA), + ) + d = dump(r) or {} + agent_step("elicit.form", d.get("action") == "accept" and (d.get("content") or {}).get("name") == "interop", + started, f"response {compact(d)}") + await self._chunk(s.id, f"elicit: {d.get('action')} {compact(d.get('content'))}") + return {"stopReason": "end_turn"} + if mode == "url": + await self.conn.create_elicitation( + message=ELICIT_URL["message"], + mode=ElicitationUrlSessionMode( + session_id=s.id, elicitation_id=ELICIT_URL["elicitationId"], url=ELICIT_URL["url"] + ), + ) + await self.conn.complete_elicitation(elicitation_id=ELICIT_URL["elicitationId"]) + await self._chunk(s.id, "elicit url done") + return {"stopReason": "end_turn"} + raise RequestError(-32602, f"unknown directive: #elicit {mode}") + + async def _d_ext(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + op, _, method = rest.partition(" ") + method = method.strip() + if op == "request": + try: + r = await self.conn.ext_method(method[1:], EXT_PARAMS) + except RequestError as e: + agent_step("ext.agent-request", False, started, f"{method} failed: {e.code} {e}") + await self._chunk(s.id, f"ext error {e.code}") + return {"stopReason": "end_turn"} + agent_step("ext.agent-request", r == EXT_RESULT, started, f"result {compact(r)}") + await self._chunk(s.id, f"ext: {compact(r)}") + return {"stopReason": "end_turn"} + if op == "notify": + await self.conn.ext_notification(method[1:], EXT_PARAMS) + await self._chunk(s.id, "ext notified") + return {"stopReason": "end_turn"} + if op == "last-notification": + await self._chunk(s.id, f"ext last: {self.last_ext_notification or 'none'}") + return {"stopReason": "end_turn"} + raise RequestError(-32602, f"unknown directive: #ext {op}") + + async def _d_meta(self, s: Session, turn: Turn, rest: str, meta: dict): + await self._chunk(s.id, "meta", meta or None) + resp: dict = {"stopReason": "end_turn"} + if meta: + resp["_meta"] = meta + return resp + + async def _d_echo_caps(self, s: Session, turn: Turn, rest: str, meta: dict): + started = time.monotonic() + ok = self.client_caps is not None + agent_step("init.client-capabilities", ok, started, + "clientCapabilities received" if ok else "initialize carried no clientCapabilities") + await self._chunk(s.id, compact(self.client_caps)) + return {"stopReason": "end_turn"} + + async def _d_len(self, s: Session, turn: Turn, rest: str, meta: dict): + await self._chunk(s.id, f"len={len(rest)}") + return {"stopReason": "end_turn"} + + async def _d_big(self, s: Session, turn: Turn, rest: str, meta: dict): + await self._chunk(s.id, "x" * int(rest.strip())) + return {"stopReason": "end_turn"} + + +def _text(text: str) -> dict: + return {"type": "text", "text": text} + + +# ---------------------------------------------------------------- transports + + +async def serve_stdio() -> None: + await run_agent(lambda conn: InteropAgent(conn), use_unstable_protocol=True) + log("[agent] stdin reached EOF, exiting") + + +def logging_app(inner): + async def app(scope, receive, send): + if scope["type"] not in ("http", "websocket"): + return await inner(scope, receive, send) + h = {k.decode().lower(): v.decode() for k, v in scope["headers"]} + method = scope.get("method", "GET") + line = (f"[http] {method} {scope['path']} HTTP/{scope.get('http_version', '1.1')}") + tail = (f"ct={h.get('content-type')} accept={h.get('accept')} conn={h.get('acp-connection-id')} " + f"sess={h.get('acp-session-id')} upgrade=\"{h.get('upgrade')}\"") + + async def logged_send(msg): + if msg["type"] == "http.response.start": + log(f"{line} -> {msg['status']} {tail}") + elif msg["type"] == "websocket.accept": + log(f"{line} -> 101 {tail}") + elif msg["type"] == "websocket.close": + log(f"[http] websocket close code={msg.get('code')}") + await send(msg) + + return await inner(scope, receive, logged_send) + + return app + + +async def serve_http(port: int) -> None: + import hypercorn.asyncio + from hypercorn.config import Config + + from acp.http.asgi import create_asgi_app + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("127.0.0.1", port)) + sock.listen(128) + sock.set_inheritable(True) + bound = sock.getsockname()[1] + config = Config() + config.bind = [f"fd://{sock.fileno()}"] + config.accesslog = None + config.graceful_timeout = 1.0 + config.websocket_max_message_size = 64 * 1024 * 1024 + app = logging_app(create_asgi_app(lambda conn: InteropAgent(conn))) + server = asyncio.ensure_future(hypercorn.asyncio.serve(app, config)) + + async def announce(): + for _ in range(400): + try: + _, w = await asyncio.open_connection("127.0.0.1", bound) + w.close() + print(f"READY {bound}", flush=True) + return + except OSError: + await asyncio.sleep(0.025) + + asyncio.ensure_future(announce()) + await server + + +def usage(problem: str) -> None: + print(f"agent: {problem}", file=sys.stderr) + print("usage: agent --transport stdio | --transport http|ws --port ", file=sys.stderr) + sys.exit(2) + + +def main(argv: list[str]) -> None: + transport = None + port = None + i = 0 + while i < len(argv): + a = argv[i] + if a in ("--transport", "--port") and i + 1 >= len(argv): + usage(f"{a} needs a value") + if a == "--transport": + transport = argv[i + 1] + i += 2 + elif a == "--port": + try: + port = int(argv[i + 1]) + except ValueError: + usage(f"bad port {argv[i + 1]}") + i += 2 + else: + usage(f"unknown argument {a}") + if transport not in ("stdio", "http", "ws"): + usage("--transport stdio|http|ws is required") + if transport == "stdio": + asyncio.run(serve_stdio()) + return + if port is None: + usage(f"--port is required for {transport}") + # One Python server serves both Streamable HTTP and WebSocket on /acp. + asyncio.run(serve_http(port)) + + +if __name__ == "__main__": + os.environ.setdefault("PYTHONUNBUFFERED", "1") + main(sys.argv[1:]) diff --git a/integration-testing/programs/python/interop/client.py b/integration-testing/programs/python/interop/client.py new file mode 100644 index 00000000..2e0f5737 --- /dev/null +++ b/integration-testing/programs/python/interop/client.py @@ -0,0 +1,1292 @@ +"""The Python interop client of the step catalogue (integration-testing/steps.json). + + STEPS=... client.sh --transport stdio spawns bash -c "exec $AGENT_CMD" + STEPS=... client.sh --transport http --url http://127.0.0.1:

    /acp + STEPS=... client.sh --transport ws --url ws://127.0.0.1:

    /acp + +Runs the step ids of STEPS in order, printing one STEP PASS|FAIL ( ms) -> line +each, then RESULT pass=.. fail=.. updates_total=.. upd_=..; exits 0 whatever the outcomes. +On stdio the agent's stderr is relayed to stdout: STEP lines verbatim, the rest prefixed "agent| ". +Contract: integration-testing/README.md, "Contracts". +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +import shutil +import sys +import tempfile +import time +from collections import defaultdict +from pathlib import Path +from typing import Any + +from acp import RequestError +from acp.client.connection import ClientSideConnection +from acp.connection import Connection +from acp.stdio import spawn_agent_process +from fixtures import ( + CLIENT_CAPABILITIES, + CLIENT_INFO, + ELICIT_FORM_ANSWER, + EXT_NOTIFICATION, + EXT_PARAMS, + EXT_RESULT, + FS_READ_CONTENT, + GROUPED_VALUES, + META_KEY, + META_VALUE, + UNKNOWN_AUDIENCE, + UNKNOWN_STOP_REASON, + compact, + dump, + step_line, +) + +STEP_TIMEOUT = int(os.environ.get("STEP_TIMEOUT_MS", "15000")) / 1000.0 +UPDATE_GRACE = 1.0 +REPLAY_GRACE = 2.0 +STDIO_LIMIT = 64 * 1024 * 1024 + +TRANSPORT: str = "" +URL: str | None = None +DIR: Path = Path(".") + +PASS = 0 +FAIL = 0 +UPDATES_TOTAL = 0 +UPDATES_BY_KIND: dict[str, int] = defaultdict(int) + +UNSUPPORTED_CANCEL_REQUEST = ( + "unsupported: python-sdk has no $/cancel_request API, only the method-name constant (acp/meta.py:49)" +) + + +class StepFailed(Exception): + pass + + +def check(ok: bool, message: str) -> None: + if not ok: + raise StepFailed(message) + + +# ---------------------------------------------------------------- the Client handler + + +class Recorder: + """Client-side handlers of one connection; records everything the steps assert on.""" + + def __init__(self) -> None: + self.conn: ClientSideConnection | None = None + self.updates: dict[str, list[tuple[dict, dict]]] = defaultdict(list) # sid -> [(update, notification _meta)] + self.permission_requests: dict[str, list[dict]] = defaultdict(list) + self.cancel_on_permission: set[str] = set() + self.cancel_sent_at: dict[str, float] = {} + self.ext_notifications: list[tuple[str, dict]] = [] + self.completed_elicitations: list[str] = [] + self.terminals: dict[str, Terminal] = {} + self.next_terminal = 0 + + def on_connect(self, conn: Any) -> None: + self.conn = conn + + def chunks(self, sid: str, kind: str = "agent_message_chunk") -> list[str]: + return [ + (u.get("content") or {}).get("text", "") for u, _ in self.updates.get(sid, []) if u.get("sessionUpdate") == kind + ] + + def of_kind(self, sid: str, kind: str) -> list[dict]: + return [u for u, _ in self.updates.get(sid, []) if u.get("sessionUpdate") == kind] + + async def session_update(self, session_id: str, update: Any, **kw: Any) -> None: + global UPDATES_TOTAL + u = dump(update) + kind = u.get("sessionUpdate", "other") + UPDATES_TOTAL += 1 + UPDATES_BY_KIND[kind] += 1 + self.updates[session_id].append((u, dict(kw))) + + async def request_permission(self, session_id: str, tool_call: Any, options: list, **kw: Any): + req = {"toolCall": dump(tool_call), "options": [dump(o) for o in options], "_meta": dict(kw)} + self.permission_requests[session_id].append(req) + meta = dict(kw) or None + if session_id in self.cancel_on_permission: + self.cancel_sent_at[session_id] = time.monotonic() + await self.conn.cancel(session_id=session_id) + return {"outcome": {"outcome": "cancelled"}, **({"_meta": meta} if meta else {})} + opts = req["options"] + chosen = next((o for o in opts if o.get("kind") == "allow_once"), opts[0] if opts else None) + if chosen is None: + return {"outcome": {"outcome": "cancelled"}} + return {"outcome": {"outcome": "selected", "optionId": chosen["optionId"]}, **({"_meta": meta} if meta else {})} + + async def write_text_file(self, session_id: str, path: str, content: str, **kw: Any): + Path(path).write_text(content) + # Returning None is how the Python SDK's examples answer: on the wire "result": null. + return None + + async def read_text_file(self, session_id: str, path: str, line: int | None = None, limit: int | None = None, **kw): + if path.endswith("slow.txt"): + # Waits up to 10 s; the Python SDK has no $/cancel_request to end it sooner. + await asyncio.sleep(10) + return {"content": ""} + p = Path(path) + if not p.is_file(): + raise RequestError.resource_not_found(path) + text = p.read_text() + if line is not None or limit is not None: + lines = text.splitlines(keepends=True) + start = max((line or 1) - 1, 0) + end = start + limit if limit is not None else len(lines) + text = "".join(lines[start:end]) + return {"content": text} + + async def create_terminal(self, session_id: str, command: str, args=None, env=None, cwd=None, + output_byte_limit=None, **kw): + self.next_terminal += 1 + tid = f"term-{self.next_terminal}" + t = Terminal() + await t.start(command, list(args or []), cwd) + self.terminals[tid] = t + return {"terminalId": tid} + + def _terminal(self, terminal_id: str) -> Terminal: + t = self.terminals.get(terminal_id) + if t is None: + raise RequestError.invalid_params({"details": f"unknown terminal {terminal_id}"}) + return t + + async def terminal_output(self, session_id: str, terminal_id: str, **kw): + t = self._terminal(terminal_id) + out: dict = {"output": t.output.decode(errors="replace"), "truncated": False} + if t.proc.returncode is not None: + out["exitStatus"] = t.exit_status() + return out + + async def wait_for_terminal_exit(self, session_id: str, terminal_id: str, **kw): + t = self._terminal(terminal_id) + await t.proc.wait() + await t.drained + return t.exit_status() + + async def kill_terminal(self, session_id: str, terminal_id: str, **kw): + t = self._terminal(terminal_id) + if t.proc.returncode is None: + t.proc.kill() + return {} + + async def release_terminal(self, session_id: str, terminal_id: str, **kw): + t = self.terminals.pop(terminal_id, None) + if t is not None and t.proc.returncode is None: + t.proc.kill() + await t.proc.wait() + return {} + + async def create_elicitation(self, message: str, mode: Any, **kw): + m = dump(mode) or {} + if "requestedSchema" in m: + return ELICIT_FORM_ANSWER + return {"action": "accept"} + + async def complete_elicitation(self, elicitation_id: str, **kw): + self.completed_elicitations.append(elicitation_id) + + async def ext_method(self, method: str, params: dict) -> dict: + if "_" + method == "_interop/ping": + return EXT_RESULT + raise RequestError.method_not_found("_" + method) + + async def ext_notification(self, method: str, params: dict) -> None: + self.ext_notifications.append(("_" + method, params)) + + +class Terminal: + def __init__(self) -> None: + self.proc: asyncio.subprocess.Process = None # type: ignore[assignment] + self.output = b"" + self.drained: asyncio.Future = None # type: ignore[assignment] + + async def start(self, command: str, args: list[str], cwd: str | None) -> None: + self.proc = await asyncio.create_subprocess_exec( + command, *args, cwd=cwd, stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, start_new_session=True, + ) + self.drained = asyncio.ensure_future(self._drain()) + + async def _drain(self) -> None: + while True: + data = await self.proc.stdout.read(65536) + if not data: + return + self.output += data + + def exit_status(self) -> dict: + rc = self.proc.returncode + if rc is not None and rc < 0: + import signal + + return {"exitCode": None, "signal": signal.Signals(-rc).name} + return {"exitCode": rc} + + +# ---------------------------------------------------------------- connections + + +class Conn: + """One ACP connection over the selected transport.""" + + def __init__(self) -> None: + self.rec = Recorder() + self.conn: ClientSideConnection = None # type: ignore[assignment] + self.init: dict = {} + self._stack = contextlib.AsyncExitStack() + self._relay: asyncio.Task | None = None + self.process: asyncio.subprocess.Process | None = None + + @classmethod + async def open(cls) -> Conn: + c = cls() + if TRANSPORT == "stdio": + conn, proc = await c._stack.enter_async_context( + spawn_agent_process( + c.rec, "bash", "-c", "exec " + os.environ["AGENT_CMD"], env=dict(os.environ), + transport_kwargs={"limit": STDIO_LIMIT}, use_unstable_protocol=True, + ) + ) + c.conn, c.process = conn, proc + c._relay = asyncio.ensure_future(relay_stderr(proc.stderr)) + elif TRANSPORT == "http": + from acp.http import create_http_stream + + c.conn = ClientSideConnection(c.rec, create_http_stream(URL), use_unstable_protocol=True) + else: + from acp.ws import create_websocket_stream + + c.conn = ClientSideConnection(c.rec, await create_websocket_stream(URL), use_unstable_protocol=True) + return c + + async def initialize(self) -> dict: + r = await self.conn.initialize( + protocol_version=1, client_capabilities=CLIENT_CAPABILITIES, client_info=CLIENT_INFO + ) + self.init = dump(r) + return self.init + + async def new_session(self, cwd: Path | None = None) -> tuple[str, dict]: + r = await self.conn.new_session(cwd=str(cwd or DIR), mcp_servers=[]) + d = dump(r) + check(bool(d.get("sessionId")), "empty sessionId") + return d["sessionId"], d + + async def prompt(self, sid: str, text: str, **meta: Any) -> dict: + r = await self.conn.prompt(session_id=sid, prompt=[{"type": "text", "text": text}], **meta) + return dump(r) + + async def close(self) -> None: + try: + await self.conn.close() + await self._stack.aclose() + finally: + if self._relay is not None: + with contextlib.suppress(Exception): + await asyncio.wait_for(self._relay, 2) + + +async def relay_stderr(stream: asyncio.StreamReader) -> None: + """Relays the agent's stderr: STEP lines verbatim, everything else prefixed "agent| ".""" + buf = b"" + while True: + data = await stream.read(65536) + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + emit_agent_line(line.decode(errors="replace")) + if buf: + emit_agent_line(buf.decode(errors="replace")) + + +def emit_agent_line(line: str) -> None: + if line.startswith("STEP "): + print(line, flush=True) + else: + if len(line) > 2000: + line = line[:2000] + f"... ({len(line)} chars)" + print("agent| " + line, flush=True) + + +MAIN: Conn | None = None + + +def main_conn() -> Conn: + check(MAIN is not None, "no main connection (init.initialize did not run or failed)") + return MAIN # type: ignore[return-value] + + +async def wait_for(predicate, message, grace: float = UPDATE_GRACE) -> None: + deadline = time.monotonic() + grace + while True: + if predicate(): + return + if time.monotonic() >= deadline: + raise StepFailed(message() if callable(message) else message) + await asyncio.sleep(0.02) + + +def end_turn(r: dict) -> None: + check(r.get("stopReason") == "end_turn", f"stopReason {r.get('stopReason')}") + + +async def first_tick(c: Conn, sid: str) -> None: + await wait_for(lambda: "tick" in c.rec.chunks(sid), "no \"tick\" chunk", grace=5) + + +async def abandon(c: Conn, sid: str, turn: asyncio.Future) -> None: + """After a failed step, cancel its still-running turn so it does not outlive the step.""" + if turn.done(): + return + with contextlib.suppress(Exception): + await c.conn.cancel(session_id=sid) + turn.cancel() + + +# ---------------------------------------------------------------- steps + + +async def s_init_initialize() -> str: + global MAIN + MAIN = await Conn.open() + r = await MAIN.initialize() + check(r.get("protocolVersion") == 1, f"protocolVersion {r.get('protocolVersion')}") + return f"protocolVersion=1 agentInfo={compact(r.get('agentInfo'))}" + + +async def s_init_agent_capabilities() -> str: + caps = main_conn().init.get("agentCapabilities") or {} + sc = caps.get("sessionCapabilities") or {} + check(caps.get("loadSession") is True, f"loadSession {caps.get('loadSession')}") + missing = [k for k in ("list", "resume", "close", "delete") if sc.get(k) is None] + check(not missing, f"sessionCapabilities lacks {missing}: {compact(sc)}") + return f"agentCapabilities {compact(caps)}" + + +async def echo_caps() -> dict: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "#echo-caps")) + await wait_for(lambda: c.rec.chunks(sid), "no chunk") + text = "".join(c.rec.chunks(sid)) + try: + return json.loads(text) + except ValueError: + raise StepFailed(f"chunk is not JSON: {text[:200]!r}") from None + + +async def s_init_client_capabilities() -> str: + caps = await echo_caps() + fs = caps.get("fs") or {} + check(fs.get("readTextFile") is True and fs.get("writeTextFile") is True and caps.get("terminal") is True, + f"echoed {compact(caps)}") + return f"echoed {compact(caps)}" + + +async def s_init_auth_methods() -> str: + methods = main_conn().init.get("authMethods") or [] + check(any(m.get("id") == "interop-auth" for m in methods), f"authMethods {compact(methods)}") + return f"authMethods {compact(methods)}" + + +async def s_init_agent_info() -> str: + info = main_conn().init.get("agentInfo") or {} + check(str(info.get("name", "")).startswith("interop-"), f"agentInfo {compact(info)}") + return f"agentInfo {compact(info)}" + + +async def s_init_config_boolean() -> str: + caps = await echo_caps() + check(((caps.get("session") or {}).get("configOptions") or {}).get("boolean") is not None, + f"echoed {compact(caps)} has no session.configOptions.boolean") + return "echoed session.configOptions.boolean" + + +async def s_auth_authenticate() -> str: + r = await main_conn().conn.authenticate(method_id="interop-auth") + return f"authenticate -> {compact(dump(r))}" + + +async def s_auth_logout() -> str: + r = await main_conn().conn.logout() + return f"logout -> {compact(dump(r))}" + + +async def s_auth_logout_capability() -> str: + caps = main_conn().init.get("agentCapabilities") or {} + check((caps.get("auth") or {}).get("logout") is not None, f"agentCapabilities.auth {compact(caps.get('auth'))}") + return "agentCapabilities.auth.logout present" + + +async def s_auth_terminal() -> str: + methods = main_conn().init.get("authMethods") or [] + check(any(m.get("type") == "terminal" and m.get("id") == "interop-terminal-auth" for m in methods), + f"no terminal auth method in {compact(methods)}") + return "terminal auth method listed" + + +async def s_session_new() -> str: + sid, _ = await main_conn().new_session() + return f"sessionId={sid}" + + +async def load(c: Conn, sid: str) -> dict: + return dump(await c.conn.load_session(cwd=str(DIR), session_id=sid, mcp_servers=[])) + + +async def s_session_load() -> str: + c = main_conn() + sid, _ = await c.new_session() + await c.prompt(sid, "hello load") + await load(c, sid) + before = len(c.rec.chunks(sid)) + end_turn(await c.prompt(sid, "after load")) + await wait_for(lambda: len(c.rec.chunks(sid)) > before, "no agent_message_chunk after the load") + return f"loaded {sid}; prompt after load end_turn" + + +async def s_session_load_replay() -> str: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "replay me")) + await asyncio.sleep(0.2) + mark = len(c.rec.updates[sid]) + await load(c, sid) + + def replayed(): + later = [u for u, _ in c.rec.updates[sid][mark:]] + user = any(u.get("sessionUpdate") == "user_message_chunk" and (u.get("content") or {}).get("text") == "replay me" + for u in later) + agent = any(u.get("sessionUpdate") == "agent_message_chunk" + and "replay me" in (u.get("content") or {}).get("text", "") for u in later) + return user and agent + + await wait_for(replayed, lambda: f"replay incomplete: {compact([u for u, _ in c.rec.updates[sid][mark:]])}", + grace=REPLAY_GRACE) + return f"replayed {len(c.rec.updates[sid]) - mark} update(s)" + + +async def s_session_resume() -> str: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "before resume")) + await asyncio.sleep(0.2) + mark = len(c.rec.updates[sid]) + await c.conn.resume_session(session_id=sid, cwd=str(DIR)) + await asyncio.sleep(UPDATE_GRACE) + extra = c.rec.updates[sid][mark:] + check(not extra, f"{len(extra)} update(s) after resume: {compact([u for u, _ in extra])[:200]}") + end_turn(await c.prompt(sid, "after resume")) + return "resumed without replay; prompt after resume end_turn" + + +async def s_session_list() -> str: + c = main_conn() + cwd = DIR / "list" + cwd.mkdir(exist_ok=True) + sid, _ = await c.new_session(cwd) + cursor = None + seen = [] + for _ in range(10): + r = dump(await c.conn.list_sessions(cwd=str(cwd), cursor=cursor)) + seen.extend(r.get("sessions") or []) + cursor = r.get("nextCursor") + if not cursor: + break + mine = [s for s in seen if s.get("sessionId") == sid] + check(bool(mine), f"{sid} not listed in {compact(seen)[:200]}") + check(mine[0].get("cwd") == str(cwd), f"listed cwd {mine[0].get('cwd')}") + return f"listed {sid} among {len(seen)}" + + +async def s_session_close() -> str: + c = main_conn() + sid, _ = await c.new_session() + slow = asyncio.ensure_future(c.prompt(sid, "#slow")) + try: + await first_tick(c, sid) + r = dump(await c.conn.close_session(session_id=sid)) + closed = time.monotonic() + check(r == {} or r is None, f"close answered {compact(r)}") + try: + pr = await asyncio.wait_for(asyncio.shield(slow), 5) + ended = f"stopReason {pr.get('stopReason')}" + check(pr.get("stopReason") == "cancelled", f"#slow ended with {ended}") + except RequestError as e: + ended = f"error {e.code}" + except asyncio.TimeoutError: + raise StepFailed("the #slow prompt did not end within 5 s of the close") from None + took = int((time.monotonic() - closed) * 1000) + try: + await c.prompt(sid, "after close") + except RequestError as e: + return f"close {{}}; #slow ended ({ended}) {took} ms after; prompt after close failed {e.code}" + raise StepFailed("the prompt after close succeeded") + finally: + await abandon(c, sid, slow) + + +async def s_session_delete() -> str: + c = main_conn() + sid, _ = await c.new_session() + await c.conn.delete_session(session_id=sid) + await c.conn.delete_session(session_id="no-such-session") + return "both deletes answered without error" + + +async def s_session_multi() -> str: + c = main_conn() + a, _ = await c.new_session() + b, _ = await c.new_session() + ra, rb = await asyncio.gather(c.prompt(a, "multi A"), c.prompt(b, "multi B")) + end_turn(ra) + end_turn(rb) + await wait_for(lambda: "".join(c.rec.chunks(a)) == "echo: multi A" and "".join(c.rec.chunks(b)) == "echo: multi B", + lambda: f"chunks A={c.rec.chunks(a)} B={c.rec.chunks(b)}") + return "both end_turn, chunks routed per session" + + +async def s_session_fork() -> str: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "before fork")) + r = dump(await c.conn.fork_session(session_id=sid, cwd=str(DIR))) + fid = r.get("sessionId") + check(bool(fid) and fid != sid, f"fork returned {compact(r)}") + end_turn(await c.prompt(fid, "in fork")) + return f"forked {sid} -> {fid}" + + +async def emit(directive: str) -> tuple[Conn, str, dict]: + c = main_conn() + sid, _ = await c.new_session() + r = await c.prompt(sid, directive) + return c, sid, r + + +async def s_update_agent_message_chunk() -> str: + c, sid, r = await emit("hello") + end_turn(r) + await wait_for(lambda: "".join(c.rec.chunks(sid)) == "echo: hello", + lambda: f"chunks {c.rec.chunks(sid)} do not spell \"echo: hello\"") + return "end_turn; chunks spell \"echo: hello\"" + + +def update_step(kind: str, predicate, describe: str, directive: str | None = None, need_end_turn: bool = True): + async def run() -> str: + c, sid, r = await emit(directive or f"#emit {kind}") + if need_end_turn: + end_turn(r) + await wait_for(lambda: any(predicate(u) for u in c.rec.of_kind(sid, kind)), + lambda: f"no matching {kind} in {compact(c.rec.of_kind(sid, kind))[:300]}") + return describe + + return run + + +def text_of(u: dict) -> str: + return (u.get("content") or {}).get("text", "") + + +async def s_update_tool_call_update() -> str: + c, sid, r = await emit("#emit tool_call_update") + end_turn(r) + + def ok(): + kinds = [(u.get("sessionUpdate"), u) for u, _ in c.rec.updates[sid]] + starts = [i for i, (k, u) in enumerate(kinds) if k == "tool_call" and u.get("toolCallId") == "call-1"] + ups = [i for i, (k, u) in enumerate(kinds) if k == "tool_call_update" and u.get("toolCallId") == "call-1" + and u.get("status") == "completed" + and any((x.get("content") or {}).get("text") == "tool output" for x in (u.get("content") or []))] + return bool(starts and ups and starts[0] < ups[-1]) + + await wait_for(ok, lambda: f"updates {compact([u for u, _ in c.rec.updates[sid]])[:300]}") + return "tool_call call-1 then tool_call_update completed \"tool output\"" + + +async def s_update_unknown() -> str: + c, sid, r = await emit("#emit unknown") + end_turn(r) + await wait_for(lambda: "after-unknown" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + return "unknown update dropped; \"after-unknown\" arrived; end_turn" + + +def stop_step(reason: str): + async def run() -> str: + _, _, r = await emit(f"#stop {reason}") + check(r.get("stopReason") == reason, f"stopReason {r.get('stopReason')}") + return f"stopReason {reason}" + + return run + + +async def enum_update(directive: str, kind: str, match_id) -> tuple[Conn, str, dict]: + """#enum tool_call|plan: end_turn, the chunk after-enum, and the update as the SDK surfaced it.""" + c, sid, r = await emit(directive) + end_turn(r) + await wait_for(lambda: "after-enum" in c.rec.chunks(sid), lambda: f"no chunk \"after-enum\" in {c.rec.chunks(sid)}") + found = [u for u in c.rec.of_kind(sid, kind) if match_id(u)] + check(bool(found), f"no {kind} surfaced (after-enum and end_turn arrived): updates " + f"{compact([u.get('sessionUpdate') for u, _ in c.rec.updates[sid]])}") + return c, sid, found[0] + + +async def s_enum_tool_call() -> str: + _, _, u = await enum_update("#enum tool_call", "tool_call", lambda u: u.get("toolCallId") == "call-enum") + check(u.get("status") == "interop_future_status", f"tool_call call-enum surfaced with status " + f"{u.get('status')!r}, not the raw interop_future_status: {compact(u)}") + check(u.get("kind") in ("interop_future_kind", "other"), f"kind {u.get('kind')!r}") + return f"tool_call status {u.get('status')} kind {u.get('kind')}; after-enum; end_turn" + + +async def s_enum_plan() -> str: + _, _, u = await enum_update("#enum plan", "plan", lambda u: True) + entries = [(e.get("content"), e.get("priority"), e.get("status")) for e in u.get("entries") or []] + check(entries == [("future entry", "interop_future_priority", "interop_future_status")], + f"plan surfaced with entries {entries}, not the raw values") + return "plan entry priority interop_future_priority, status interop_future_status; end_turn" + + +async def s_enum_stop() -> str: + c, sid, r = await emit("#enum stop") + check(r.get("stopReason") == UNKNOWN_STOP_REASON, f"stopReason {r.get('stopReason')}") + end_turn(await c.prompt(sid, "after enum")) + return f"stopReason {UNKNOWN_STOP_REASON}; \"after enum\" end_turn" + + +async def s_enum_audience() -> str: + c = main_conn() + sid, _ = await c.new_session() + # The typed prompt validates annotations.audience against Literal["assistant", "user"] + # (acp/schema.py:2622): send the raw request. + block = {"type": "text", "text": "#enum audience", "annotations": {"audience": UNKNOWN_AUDIENCE}} + r = await c.conn._conn.send_request("session/prompt", {"sessionId": sid, "prompt": [block]}) + end_turn(r or {}) + await wait_for(lambda: "audience: user,interop_future_role" in c.rec.chunks(sid), + lambda: f"chunks {c.rec.chunks(sid)}") + return "audience: user,interop_future_role; end_turn" + + +async def s_config_grouped() -> str: + c, sid, r = await emit("#config grouped") + end_turn(r) + await wait_for(lambda: c.rec.of_kind(sid, "config_option_update"), "no config_option_update") + effort = option(c.rec.of_kind(sid, "config_option_update")[0].get("configOptions"), "effort") + groups = effort.get("options") or [] + check([g.get("group") for g in groups] == ["fast", "deep"], f"effort options {compact(effort)}") + values = [o.get("value") for g in groups for o in g.get("options") or []] + check(values == GROUPED_VALUES, f"effort values {values}") + resp = dump(await c.conn.set_config_option(config_id="effort", session_id=sid, value="effort-high")) + opts = resp.get("configOptions") + check(option(opts, "effort").get("currentValue") == "effort-high" + and option(opts, "model").get("currentValue") == "model-a", f"response {compact(resp)}") + return f"effort grouped fast/deep, values {values}; set to effort-high" + + +async def s_mode_set() -> str: + c = main_conn() + sid, resp = await c.new_session() + modes = resp.get("modes") or {} + ids = [m.get("id") for m in modes.get("availableModes") or []] + check("interop-mode-b" in ids, f"session/new modes {compact(modes)}") + await c.conn.set_session_mode(session_id=sid, mode_id="interop-mode-b") + return f"availableModes {ids}; set_mode interop-mode-b" + + +def option(opts: list, oid: str) -> dict: + return next((o for o in opts or [] if o.get("id") == oid), {}) + + +async def s_config_on_new() -> str: + _, resp = await main_conn().new_session() + model = option(resp.get("configOptions"), "model") + check(model.get("type") == "select" and model.get("currentValue") == "model-a", + f"configOptions {compact(resp.get('configOptions'))}") + return "session/new carries model=model-a" + + +async def s_config_select() -> str: + c = main_conn() + sid, _ = await c.new_session() + r = dump(await c.conn.set_config_option(config_id="model", session_id=sid, value="model-b")) + check(option(r.get("configOptions"), "model").get("currentValue") == "model-b", f"response {compact(r)}") + return f"configOptions {compact(r.get('configOptions'))[:200]}" + + +async def s_config_boolean() -> str: + c = main_conn() + sid, _ = await c.new_session() + r = dump(await c.conn.set_config_option(config_id="verbose", session_id=sid, value=True)) + check(option(r.get("configOptions"), "verbose").get("currentValue") is True, f"response {compact(r)}") + return "verbose=true" + + +async def s_perm_selected() -> str: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "#permission allow")) + await wait_for(lambda: "permission: selected allow" in c.rec.chunks(sid), + lambda: f"no chunk \"permission: selected allow\" in {c.rec.chunks(sid)}") + asked = c.rec.permission_requests[sid] + check(len(asked) == 1, f"{len(asked)} permission requests, expected 1") + check(bool(asked[0]["options"]), "permission request without options") + return "one permission request; selected allow; end_turn" + + +async def s_perm_cancelled() -> str: + c = main_conn() + sid, _ = await c.new_session() + c.rec.cancel_on_permission.add(sid) + r = await c.prompt(sid, "#permission hold") + sent = c.rec.cancel_sent_at.get(sid) + check(sent is not None, "no permission request arrived") + took = time.monotonic() - sent + check(r.get("stopReason") == "cancelled", f"stopReason {r.get('stopReason')}") + check(took <= 5, f"cancelled {took:.1f} s after the cancel") + return f"stopReason cancelled {int(took * 1000)} ms after the cancel" + + +async def s_fs_write() -> str: + c = main_conn() + sid, _ = await c.new_session() + f = DIR / "fs-write.txt" + end_turn(await c.prompt(sid, f"#fs write {f} interop write")) + await wait_for(lambda: "fs write ok" in c.rec.chunks(sid), lambda: f"no chunk \"fs write ok\" in {c.rec.chunks(sid)}") + check(f.exists(), f"{f} was not written") + content = f.read_text() + check(content == "interop write", f"file content {content!r}") + return "written through the client (handler answered null): \"interop write\"" + + +async def fs_read(directive: str) -> tuple[Conn, str, dict]: + (DIR / "fs-read.txt").write_text(FS_READ_CONTENT) + c = main_conn() + sid, _ = await c.new_session() + r = await c.prompt(sid, directive) + return c, sid, r + + +async def s_fs_read() -> str: + c, sid, r = await fs_read(f"#fs read {DIR}/fs-read.txt") + await wait_for(lambda: FS_READ_CONTENT in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk equals fixtures.fsReadContent" + + +async def s_fs_read_range() -> str: + c, sid, r = await fs_read(f"#fs read-range {DIR}/fs-read.txt line=2 limit=1") + await wait_for(lambda: any(x.strip() == "line2" for x in c.rec.chunks(sid)), lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk \"line2\"" + + +async def s_fs_read_missing() -> str: + c, sid, r = await fs_read(f"#fs read-missing {DIR}/no-such-file.txt") + end_turn(r) + await wait_for(lambda: any(x.startswith("fs read error") for x in c.rec.chunks(sid)), + lambda: f"chunks {c.rec.chunks(sid)}") + return f"chunk {[x for x in c.rec.chunks(sid) if x.startswith('fs read error')][0]!r}" + + +async def s_term_run() -> str: + c, sid, r = await emit("#terminal run echo hi") + await wait_for(lambda: "terminal: hi exit=0" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk \"terminal: hi exit=0\"" + + +async def s_term_kill() -> str: + started = time.monotonic() + c, sid, r = await emit("#terminal kill sleep 30") + await wait_for(lambda: "terminal killed" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + took = time.monotonic() - started + check(took <= 5, f"\"terminal killed\" after {took:.1f} s") + return f"chunk \"terminal killed\" after {int(took * 1000)} ms" + + +async def s_elicit_form() -> str: + c, sid, r = await emit("#elicit form") + expected = {"name": "interop"} + + def ok(): + for x in c.rec.chunks(sid): + if x.startswith("elicit: accept "): + with contextlib.suppress(ValueError): + return json.loads(x[len("elicit: accept "):]) == expected + return False + + await wait_for(ok, lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk \"elicit: accept {\\\"name\\\":\\\"interop\\\"}\"" + + +async def s_elicit_complete() -> str: + c, sid, r = await emit("#elicit url") + await wait_for(lambda: "elic-1" in c.rec.completed_elicitations, + lambda: f"no elicitation/complete for elic-1 (got {c.rec.completed_elicitations})") + return "elicitation/complete elic-1 arrived" + + +async def s_cancel_prompt() -> str: + c = main_conn() + sid, _ = await c.new_session() + slow = asyncio.ensure_future(c.prompt(sid, "#slow")) + try: + await first_tick(c, sid) + sent = time.monotonic() + await c.conn.cancel(session_id=sid) + try: + r = await asyncio.wait_for(asyncio.shield(slow), 5) + except asyncio.TimeoutError: + raise StepFailed("no response within 5 s of the cancel") from None + check(r.get("stopReason") == "cancelled", f"stopReason {r.get('stopReason')}") + return f"stopReason cancelled {int((time.monotonic() - sent) * 1000)} ms after the cancel" + finally: + await abandon(c, sid, slow) + + +async def s_cancel_prompt_while_cancelling() -> str: + c = main_conn() + sid, _ = await c.new_session() + slow = asyncio.ensure_future(c.prompt(sid, "#slow grace=1000")) + try: + await first_tick(c, sid) + await c.conn.cancel(session_id=sid) + try: + await c.prompt(sid, "during cancel") + raise StepFailed("\"during cancel\" was accepted") + except RequestError as e: + check(e.code == -32600, f"\"during cancel\" failed with {e.code}, expected -32600") + r = await asyncio.wait_for(asyncio.shield(slow), 5) + check(r.get("stopReason") == "cancelled", f"first prompt stopReason {r.get('stopReason')}") + end_turn(await c.prompt(sid, "after cancel")) + return "cancelled; \"during cancel\" -32600; \"after cancel\" end_turn" + finally: + await abandon(c, sid, slow) + + +async def s_cancel_grace() -> str: + c = main_conn() + sid, _ = await c.new_session() + hang = asyncio.ensure_future(c.prompt(sid, "#hang")) + try: + await asyncio.sleep(0.2) + sent = time.monotonic() + await c.conn.cancel(session_id=sid) + try: + r = await asyncio.wait_for(asyncio.shield(hang), 6) + except asyncio.TimeoutError: + raise StepFailed("no response within 6 s of the cancel") from None + check(r.get("stopReason") == "cancelled", f"stopReason {r.get('stopReason')}") + return f"SDK answered cancelled {int((time.monotonic() - sent) * 1000)} ms after the cancel" + finally: + await abandon(c, sid, hang) + + +async def s_cancel_max_duration() -> str: + c = main_conn() + sid, _ = await c.new_session() + started = time.monotonic() + try: + r = await asyncio.wait_for(c.prompt(sid, "#hang"), 11) + except asyncio.TimeoutError: + raise StepFailed("TIMEOUT: no answer within 11 s") from None + except RequestError as e: + took = time.monotonic() - started + check(e.code == -32800, f"the prompt failed with {e.code} {e}, expected -32800") + check(took >= 7, f"-32800 after {int(took * 1000)} ms, before the 8 s maxPromptDuration") + end_turn(await c.prompt(sid, "after max-duration")) + return f"-32800 after {int(took * 1000)} ms; \"after max-duration\" end_turn" + raise StepFailed(f"the prompt answered {r.get('stopReason')}, expected error -32800") + + +async def s_cancel_request_unsupported() -> str: + raise StepFailed(UNSUPPORTED_CANCEL_REQUEST) + + +async def s_cancel_request_agent() -> str: + c = main_conn() + sid, _ = await c.new_session() + started = time.monotonic() + r = await c.prompt(sid, f"#fs read-slow {DIR}/slow.txt") + took = time.monotonic() - started + end_turn(r) + await wait_for(lambda: "cancel-request sent" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + check(took <= 5, f"end_turn after {took:.1f} s") + return f"\"cancel-request sent\" and end_turn after {int(took * 1000)} ms" + + +async def s_ext_agent_request() -> str: + c, sid, r = await emit("#ext request _interop/ping") + + def ok(): + for x in c.rec.chunks(sid): + if x.startswith("ext: "): + with contextlib.suppress(ValueError): + return json.loads(x[5:]) == EXT_RESULT + return False + + await wait_for(ok, lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk \"ext: {\\\"pong\\\":1}\"" + + +async def s_ext_agent_notification() -> str: + c, sid, r = await emit("#ext notify _interop/note") + await wait_for(lambda: (EXT_NOTIFICATION, EXT_PARAMS) in c.rec.ext_notifications, + lambda: f"notifications {c.rec.ext_notifications}") + return "_interop/note {\"n\":1} arrived" + + +async def s_ext_client_request() -> str: + r = await main_conn().conn.ext_method("interop/ping", EXT_PARAMS) + check(r == EXT_RESULT, f"result {compact(r)}") + return "result {\"pong\":1}" + + +async def s_ext_client_notification() -> str: + c = main_conn() + await c.conn.ext_notification("interop/note", EXT_PARAMS) + sid, _ = await c.new_session() + await c.prompt(sid, "#ext last-notification") + await wait_for(lambda: "ext last: _interop/note" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + return "chunk \"ext last: _interop/note\"" + + +async def s_meta_prompt() -> str: + c = main_conn() + sid, _ = await c.new_session() + r = await c.prompt(sid, "#meta", **{META_KEY: META_VALUE}) + + def chunk_meta(): + for u, nmeta in c.rec.updates[sid]: + if u.get("sessionUpdate") == "agent_message_chunk" and text_of(u) == "meta": + return (u.get("_meta") or {}).get(META_KEY) or nmeta.get(META_KEY) + return None + + await wait_for(lambda: chunk_meta() is not None, lambda: f"no \"meta\" chunk with _meta in {c.rec.updates[sid]}") + check(chunk_meta() == META_VALUE, f"chunk _meta {chunk_meta()}") + check((r.get("_meta") or {}).get(META_KEY) == META_VALUE, f"PromptResponse _meta {r.get('_meta')}") + return "update and PromptResponse _meta interop=m1" + + +async def s_meta_permission() -> str: + c = main_conn() + sid, _ = await c.new_session() + end_turn(await c.prompt(sid, "#permission allow meta")) + asked = c.rec.permission_requests[sid] + check(len(asked) == 1, f"{len(asked)} permission requests") + meta = asked[0]["_meta"] + check(meta.get(META_KEY) == META_VALUE, f"request _meta {compact(meta) if meta else 'absent'}") + return "request _meta interop=m1; echoed on the response" + + +async def s_error_method_not_found() -> str: + c = main_conn() + try: + r = await c.conn.list_providers() + raise StepFailed(f"providers/list answered {compact(dump(r))}") + except RequestError as e: + check(e.code == -32601, f"providers/list failed with {e.code} {e}") + sid, _ = await c.new_session() + return f"providers/list -32601; session/new after it: {sid}" + + +def big_prompt(n: int): + async def run() -> str: + c = main_conn() + sid, _ = await c.new_session() + await c.prompt(sid, "#len " + "x" * n) + await wait_for(lambda: f"len={n}" in c.rec.chunks(sid), lambda: f"chunks {c.rec.chunks(sid)}") + return f"chunk \"len={n}\"" + + return run + + +def big_update(n: int): + async def on(c: Conn) -> str: + sid, _ = await c.new_session() + try: + await c.prompt(sid, f"#big {n}") + except ConnectionError as e: + raise StepFailed(f"ConnectionError: {e}{ws_close_info(c)}") from None + await wait_for(lambda: any(len(x) == n for x in c.rec.chunks(sid)), + lambda: f"chunk lengths {[len(x) for x in c.rec.chunks(sid)]}") + return f"one agent_message_chunk of {n} characters" + + async def run() -> str: + if TRANSPORT != "ws": + return await on(main_conn()) + # The Python WebSocket client closes the whole connection on an over-size message + # (websockets' default max_size, 1 MiB), so this step runs on a connection of its own: + # its failure must not take the main connection, and the later steps, with it. + c = await Conn.open() + try: + await c.initialize() + return await on(c) + finally: + with contextlib.suppress(Exception): + await c.close() + + return run + + +def ws_close_info(c: Conn) -> str: + ws = getattr(getattr(getattr(c.conn, "_conn", None), "_transport", None), "_ws", None) + exc = getattr(getattr(ws, "protocol", None), "close_exc", None) + return "" if exc is None else f" (websocket: {exc})" + + +async def s_http_reconnect() -> str: + check(TRANSPORT == "http", f"http.reconnect does not apply to {TRANSPORT}") + first = await Conn.open() + try: + await first.initialize() + sid, _ = await first.new_session() + end_turn(await first.prompt(sid, "before reconnect")) + finally: + await first.close() + second = await Conn.open() + try: + await second.initialize() + await load(second, sid) + end_turn(await second.prompt(sid, "after reconnect")) + finally: + await second.close() + return f"reconnected and loaded {sid}; prompt after reconnect end_turn" + + +async def s_stdio_eof_exit() -> str: + check(TRANSPORT == "stdio", f"stdio.eof-exit does not apply to {TRANSPORT}") + proc = await asyncio.create_subprocess_exec( + "bash", "-c", "exec " + os.environ["AGENT_CMD"], env=dict(os.environ), limit=STDIO_LIMIT, + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + ) + relay = asyncio.ensure_future(relay_stderr(proc.stderr)) + conn = Connection(lambda *a: _no_handler(*a), proc.stdin, proc.stdout) + try: + r = await conn.send_request("initialize", {"protocolVersion": 1, "clientCapabilities": {}, + "clientInfo": CLIENT_INFO}) + check((r or {}).get("protocolVersion") == 1, f"initialize -> {compact(r)}") + proc.stdin.close() + eof = time.monotonic() + try: + await asyncio.wait_for(proc.wait(), 5) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise StepFailed("no exit on EOF within 5 s") from None + return f"agent exited (code {proc.returncode}) {int((time.monotonic() - eof) * 1000)} ms after EOF" + finally: + if proc.returncode is None: + proc.kill() + await proc.wait() + await conn.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(relay, 2) + + +async def _no_handler(method: str, params: Any, is_notification: bool) -> Any: + if is_notification: + return None + raise RequestError.method_not_found(method) + + +async def s_conn_close() -> str: + c = main_conn() + await c.close() + return f"closed the {TRANSPORT} connection" + + +STEPS = { + "init.initialize": s_init_initialize, + "init.agent-capabilities": s_init_agent_capabilities, + "init.client-capabilities": s_init_client_capabilities, + "init.auth-methods": s_init_auth_methods, + "init.agent-info": s_init_agent_info, + "init.config-boolean": s_init_config_boolean, + "auth.authenticate": s_auth_authenticate, + "auth.logout": s_auth_logout, + "auth.logout-capability": s_auth_logout_capability, + "auth.terminal": s_auth_terminal, + "session.new": s_session_new, + "session.load": s_session_load, + "session.load-replay": s_session_load_replay, + "session.resume": s_session_resume, + "session.list": s_session_list, + "session.close": s_session_close, + "session.delete": s_session_delete, + "session.multi": s_session_multi, + "session.fork": s_session_fork, + "update.agent_message_chunk": s_update_agent_message_chunk, + "update.user_message_chunk": update_step( + "user_message_chunk", lambda u: text_of(u) == "user-chunk", "user_message_chunk \"user-chunk\""), + "update.agent_thought_chunk": update_step( + "agent_thought_chunk", lambda u: text_of(u) == "thinking", "agent_thought_chunk \"thinking\""), + "update.tool_call": update_step( + "tool_call", + lambda u: u.get("toolCallId") == "call-1" and u.get("title") == "interop tool" and u.get("kind") == "read" + and u.get("status") == "pending", + "tool_call call-1 \"interop tool\" read pending", need_end_turn=False), + "update.tool_call_update": s_update_tool_call_update, + "update.tool_call-name": update_step( + "tool_call", lambda u: u.get("name") == "read_file", "tool_call name read_file", + directive="#emit tool_call name=read_file", need_end_turn=False), + "update.plan": update_step( + "plan", + lambda u: [(e.get("content"), e.get("priority"), e.get("status")) for e in u.get("entries") or []] + == [("step one", "high", "pending"), ("step two", "low", "completed")], + "plan with the two fixture entries", need_end_turn=False), + "update.available_commands_update": update_step( + "available_commands_update", + lambda u: any(cmd.get("name") == "interop" and (cmd.get("input") or {}).get("hint") == "args" + for cmd in u.get("availableCommands") or []), + "command interop with hint args", need_end_turn=False), + "update.current_mode_update": update_step( + "current_mode_update", lambda u: u.get("currentModeId") == "interop-mode-b", "currentModeId interop-mode-b", + need_end_turn=False), + "update.config_option_update": update_step( + "config_option_update", lambda u: option(u.get("configOptions"), "model").get("currentValue") == "model-b", + "model currentValue model-b", need_end_turn=False), + "update.session_info_update": update_step( + "session_info_update", lambda u: u.get("title") == "interop title", "title \"interop title\"", + need_end_turn=False), + "update.usage_update": update_step( + "usage_update", + lambda u: u.get("used") == 100 and u.get("size") == 1000 + and (u.get("cost") or {}).get("amount") == 0.01 and (u.get("cost") or {}).get("currency") == "USD", + "used 100 size 1000 cost 0.01 USD", need_end_turn=False), + "update.unknown": s_update_unknown, + "stop.max_tokens": stop_step("max_tokens"), + "stop.refusal": stop_step("refusal"), + "stop.max_turn_requests": stop_step("max_turn_requests"), + "enum.tool_call": s_enum_tool_call, + "enum.plan": s_enum_plan, + "enum.stop": s_enum_stop, + "enum.audience": s_enum_audience, + "mode.set": s_mode_set, + "config.on-new": s_config_on_new, + "config.select": s_config_select, + "config.boolean": s_config_boolean, + "config.grouped": s_config_grouped, + "perm.selected": s_perm_selected, + "perm.cancelled": s_perm_cancelled, + "fs.write": s_fs_write, + "fs.read": s_fs_read, + "fs.read-range": s_fs_read_range, + "fs.read-missing": s_fs_read_missing, + "term.run": s_term_run, + "term.kill": s_term_kill, + "elicit.form": s_elicit_form, + "elicit.complete": s_elicit_complete, + "cancel.prompt": s_cancel_prompt, + "cancel.prompt-while-cancelling": s_cancel_prompt_while_cancelling, + "cancel.grace": s_cancel_grace, + "cancel.max-duration": s_cancel_max_duration, + "cancel-request.client": s_cancel_request_unsupported, + "cancel-request.agent": s_cancel_request_agent, + "cancel-request.unknown": s_cancel_request_unsupported, + "ext.agent-request": s_ext_agent_request, + "ext.agent-notification": s_ext_agent_notification, + "ext.client-request": s_ext_client_request, + "ext.client-notification": s_ext_client_notification, + "meta.prompt": s_meta_prompt, + "meta.permission": s_meta_permission, + "error.method-not-found": s_error_method_not_found, + "big.prompt-1m": big_prompt(1048576), + "big.update-1m": big_update(1048576), + "big.prompt-8m": big_prompt(8388608), + "big.update-8m": big_update(8388608), + "http.reconnect": s_http_reconnect, + "stdio.eof-exit": s_stdio_eof_exit, + "conn.close": s_conn_close, +} + + +async def run_step(step_id: str) -> None: + global PASS, FAIL + fn = STEPS.get(step_id) + started = time.monotonic() + if fn is None: + FAIL += 1 + print(f"STEP {step_id} FAIL (0 ms) -> unknown-step", flush=True) + return + task = asyncio.ensure_future(fn()) + try: + detail = await asyncio.wait_for(task, STEP_TIMEOUT) + PASS += 1 + step_line(sys.stdout, step_id, True, started, detail) + except asyncio.TimeoutError: + FAIL += 1 + step_line(sys.stdout, step_id, False, started, f"TIMEOUT after {int(STEP_TIMEOUT * 1000)} ms") + except StepFailed as e: + FAIL += 1 + step_line(sys.stdout, step_id, False, started, str(e)) + except RequestError as e: + FAIL += 1 + step_line(sys.stdout, step_id, False, started, f"error {e.code} {e} {compact(e.data) if e.data else ''}") + except Exception as e: # noqa: BLE001 - every failure is a step result + FAIL += 1 + step_line(sys.stdout, step_id, False, started, f"{type(e).__name__}: {e}") + + +def usage(problem: str) -> None: + print(f"client: {problem}", file=sys.stderr) + print("usage: STEPS= client --transport stdio (with AGENT_CMD) | --transport http|ws --url ", + file=sys.stderr) + sys.exit(2) + + +async def amain(steps: list[str]) -> None: + for s in steps: + await run_step(s) + if MAIN is not None and "conn.close" not in steps: + with contextlib.suppress(Exception): + await asyncio.wait_for(MAIN.close(), 5) + kinds = " ".join(f"upd_{k}={v}" for k, v in sorted(UPDATES_BY_KIND.items())) + print(f"RESULT pass={PASS} fail={FAIL} updates_total={UPDATES_TOTAL} {kinds}".rstrip(), flush=True) + + +def main(argv: list[str]) -> None: + global TRANSPORT, URL, DIR + i = 0 + while i < len(argv): + a = argv[i] + if a in ("--transport", "--url"): + if i + 1 >= len(argv): + usage(f"{a} needs a value") + if a == "--transport": + TRANSPORT = argv[i + 1] + else: + URL = argv[i + 1] + i += 2 + else: + usage(f"unknown argument {a}") + if TRANSPORT not in ("stdio", "http", "ws"): + usage("--transport stdio|http|ws is required") + if TRANSPORT != "stdio" and not URL: + usage(f"--url is required for {TRANSPORT}") + if TRANSPORT == "stdio" and not os.environ.get("AGENT_CMD"): + usage("AGENT_CMD is required for stdio") + steps = [s.strip() for s in os.environ.get("STEPS", "").split(",") if s.strip()] + if not steps: + usage("STEPS is required") + DIR = Path(tempfile.mkdtemp(prefix="acp-interop-")) + try: + asyncio.run(amain(steps)) + finally: + shutil.rmtree(DIR, ignore_errors=True) + # Exit at once: an SDK task stuck on a dead peer must not keep the process alive. + sys.stdout.flush() + os._exit(0) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/integration-testing/programs/python/interop/fixtures.py b/integration-testing/programs/python/interop/fixtures.py new file mode 100644 index 00000000..e2c9a1f1 --- /dev/null +++ b/integration-testing/programs/python/interop/fixtures.py @@ -0,0 +1,155 @@ +"""Fixtures of the step catalogue (integration-testing/steps.json, "fixtures"), hard-coded as the +contract asks. Shared by the Python interop agent and client.""" + +import json +import sys +import time + +CLIENT_CAPABILITIES = { + "fs": {"readTextFile": True, "writeTextFile": True}, + "terminal": True, + "elicitation": {"form": {}, "url": {}}, + "auth": {"terminal": True}, + "session": {"configOptions": {"boolean": {}}}, +} +CLIENT_INFO = {"name": "interop-python-client", "version": "1"} + +AGENT_CAPABILITIES = { + "loadSession": True, + "promptCapabilities": {"image": False, "audio": False, "embeddedContext": False}, + "mcpCapabilities": {"http": False, "sse": False}, + "sessionCapabilities": {"list": {}, "resume": {}, "close": {}, "delete": {}}, + "auth": {"logout": {}}, +} +AUTH_METHOD = {"id": "interop-auth", "name": "Interop auth", "description": "Accepts any authenticate call"} +TERMINAL_AUTH_METHOD = { + "type": "terminal", + "id": "interop-terminal-auth", + "name": "Interop terminal auth", + "args": ["--login"], +} +AGENT_INFO = {"name": "interop-python-agent", "version": "1"} + +MODES = { + "currentModeId": "interop-mode-a", + "availableModes": [{"id": "interop-mode-a", "name": "Mode A"}, {"id": "interop-mode-b", "name": "Mode B"}], +} +MODE_IDS = {m["id"] for m in MODES["availableModes"]} + + +GROUPED_VALUES = ["effort-low", "effort-medium", "effort-high"] + + +def grouped_option(effort="effort-low"): + """fixtures.groupedConfigOption: the select effort, its options in the groups fast and deep.""" + return { + "id": "effort", + "name": "Effort", + "type": "select", + "currentValue": effort, + "options": [ + {"group": "fast", "name": "Fast", "options": [{"value": "effort-low", "name": "Low"}]}, + {"group": "deep", "name": "Deep", "options": [{"value": "effort-medium", "name": "Medium"}, + {"value": "effort-high", "name": "High"}]}, + ], + } + + +# fixtures.unknownEnums: values the v1 schema does not define. +UNKNOWN_ENUM_TOOL_CALL = {"sessionUpdate": "tool_call", "toolCallId": "call-enum", "title": "interop enum tool", + "kind": "interop_future_kind", "status": "interop_future_status"} +UNKNOWN_ENUM_PLAN = {"sessionUpdate": "plan", "entries": [ + {"content": "future entry", "priority": "interop_future_priority", "status": "interop_future_status"}]} +UNKNOWN_STOP_REASON = "interop_future_stop" +UNKNOWN_AUDIENCE = ["user", "interop_future_role"] + + +def config_options(model="model-a", verbose=False, with_boolean=False, effort=None): + """fixtures.configOptions with the given current values; verbose only when the client + advertised session.configOptions.boolean; effort (fixtures.groupedConfigOption) only after + #config grouped.""" + opts = [ + { + "id": "model", + "name": "Model", + "type": "select", + "currentValue": model, + "options": [{"value": "model-a", "name": "Model A"}, {"value": "model-b", "name": "Model B"}], + } + ] + if with_boolean: + opts.append({"id": "verbose", "name": "Verbose", "type": "boolean", "currentValue": verbose}) + if effort is not None: + opts.append(grouped_option(effort)) + return opts + + +PERMISSION_TOOL_CALL = {"toolCallId": "perm-1", "title": "interop permission", "kind": "edit", "status": "pending"} +PERMISSION_OPTIONS = [ + {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, + {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, +] + +FS_READ_CONTENT = "line1\nline2\nline3\n" + +EMIT = { + "user_message_chunk": {"content": {"type": "text", "text": "user-chunk"}}, + "agent_thought_chunk": {"content": {"type": "text", "text": "thinking"}}, + "tool_call": {"toolCallId": "call-1", "title": "interop tool", "kind": "read", "status": "pending"}, + "tool_call_update": { + "toolCallId": "call-1", + "status": "completed", + "content": [{"type": "content", "content": {"type": "text", "text": "tool output"}}], + }, + "plan": { + "entries": [ + {"content": "step one", "priority": "high", "status": "pending"}, + {"content": "step two", "priority": "low", "status": "completed"}, + ] + }, + "available_commands_update": { + "availableCommands": [{"name": "interop", "description": "interop command", "input": {"hint": "args"}}] + }, + "current_mode_update": {"currentModeId": "interop-mode-b"}, + "session_info_update": {"title": "interop title"}, + "usage_update": {"used": 100, "size": 1000, "cost": {"amount": 0.01, "currency": "USD"}}, +} +UNKNOWN_UPDATE = {"sessionUpdate": "interop_future_update", "payload": {"x": 1}} + +EXT_METHOD = "_interop/ping" +EXT_NOTIFICATION = "_interop/note" +EXT_PARAMS = {"n": 1} +EXT_RESULT = {"pong": 1} + +META_KEY = "interop" +META_VALUE = "m1" + +ELICIT_FORM_MESSAGE = "interop form" +ELICIT_FORM_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} +ELICIT_FORM_ANSWER = {"action": "accept", "content": {"name": "interop"}} +ELICIT_URL = {"elicitationId": "elic-1", "url": "https://example.invalid/interop", "message": "interop url"} + + +def compact(value): + return json.dumps(value, separators=(",", ":"), sort_keys=False) + + +def dump(model): + """A schema model (or plain value) as JSON-shaped data with wire names.""" + if model is None: + return None + if hasattr(model, "model_dump"): + return model.model_dump(mode="json", by_alias=True, exclude_none=True) + return model + + +def step_line(out, step_id, ok, started, detail): + ms = int((time.monotonic() - started) * 1000) + detail = " ".join(str(detail).split()) + if len(detail) > 400: + detail = detail[:400] + "..." + print(f"STEP {step_id} {'PASS' if ok else 'FAIL'} ({ms} ms) -> {detail}", file=out, flush=True) + + +def log(*parts): + print(*parts, file=sys.stderr, flush=True) diff --git a/integration-testing/programs/python/launch/agent.sh b/integration-testing/programs/python/launch/agent.sh new file mode 100755 index 00000000..8808fa18 --- /dev/null +++ b/integration-testing/programs/python/launch/agent.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Python interop agent: --transport stdio | --transport http|ws --port . +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself: on stdio, +# stdout is the protocol stream. Env: PY_SDK (the python-sdk checkout under test). +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +exec "${PY_SDK:?PY_SDK is not set}/.venv/bin/python" -B "$here/interop/agent.py" "$@" diff --git a/integration-testing/programs/python/launch/build.sh b/integration-testing/programs/python/launch/build.sh new file mode 100755 index 00000000..6df38a34 --- /dev/null +++ b/integration-testing/programs/python/launch/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Checks the Python interop programs' environment: the peer checkout's venv (built by peers.json, +# with the SDK's [http] extra and Hypercorn) imports every SDK module the programs use. +# Byte-compiles the SDK sources once (into the peer checkout, not here): the programs run with -B, +# and an uncompiled acp.schema adds about 2 s to every start. Idempotent and fast. +# Launcher contract: integration-testing/README.md, "Contracts". Env: PY_SDK (the python-sdk checkout under test). +set -euo pipefail +py="${PY_SDK:?PY_SDK is not set}/.venv/bin/python" +[[ -x "$py" ]] || { echo "build.sh: no venv python at $py (the python-sdk peer build did not run)" >&2; exit 1; } +"$py" -m compileall -q "$PY_SDK/src" >/dev/null +exec "$py" -B -c 'import acp, acp.http.asgi, acp.http.client, acp.ws.client, acp.ws.server, hypercorn.asyncio' diff --git a/integration-testing/programs/python/launch/client.sh b/integration-testing/programs/python/launch/client.sh new file mode 100755 index 00000000..1ccd314f --- /dev/null +++ b/integration-testing/programs/python/launch/client.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Python interop client: --transport stdio (env AGENT_CMD) | --transport http|ws --url . +# Env STEPS (required), STEP_TIMEOUT_MS (default 15000), PY_SDK (the python-sdk checkout under test). +# Launcher contract: integration-testing/README.md, "Contracts". +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +exec "${PY_SDK:?PY_SDK is not set}/.venv/bin/python" -B "$here/interop/client.py" "$@" diff --git a/integration-testing/programs/python/py-client.py b/integration-testing/programs/python/py-client.py deleted file mode 100644 index 4837a7c7..00000000 --- a/integration-testing/programs/python/py-client.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Interop client on the Python SDK's Streamable HTTP client. Usage: python py-client.py . - -Prints one "STEP PASS|FAIL" line per step and a RESULT line with pass/fail counts and the -number of session updates each step received and in total. Per-step counts are indicative only: -update handlers run as tasks, so an update can land after the response that follows it. -""" -import asyncio -import re -import sys -import time - -from acp import connect_to_agent -from acp.http import create_http_stream -from acp.interfaces import Client -from acp.schema import TextContentBlock - -UPDATES = 0 -TOTAL = 0 -PER_STEP = {} -COUNTS = {"pass": 0, "fail": 0} - - -class C(Client): - async def request_permission(self, session_id, tool_call, options, **kw): - o = options[0] - oid = o["optionId"] if isinstance(o, dict) else o.option_id - print(" permission request", oid, flush=True) - return {"outcome": {"outcome": "selected", "optionId": oid}} - - async def session_update(self, session_id, update, **kw): - global UPDATES, TOTAL - UPDATES += 1 - TOTAL += 1 - c = getattr(update, "content", None) - print(" update:", getattr(c, "text", update), flush=True) - - -async def step(name, coro): - global UPDATES - UPDATES = 0 - t = time.time() - try: - r = await asyncio.wait_for(coro, 15) - COUNTS["pass"] += 1 - print(f"STEP {name} PASS ({int((time.time() - t) * 1000)} ms) -> {r!r}"[:220], flush=True) - return r - except BaseException as e: # noqa: BLE001 - report every failure as a step result - COUNTS["fail"] += 1 - print(f"STEP {name} FAIL ({int((time.time() - t) * 1000)} ms) -> {type(e).__name__}: {e}", flush=True) - return None - finally: - PER_STEP[re.sub(r"[^A-Za-z0-9]+", "_", name) + "_updates"] = UPDATES - - -async def main(): - tr = create_http_stream(sys.argv[1]) - conn = connect_to_agent(C(), tr) - await step("initialize", conn.initialize(protocol_version=1)) - s = await step("session/new", conn.new_session(cwd="/tmp", mcp_servers=[])) - sid = s.session_id if s else "missing" - for n, t in (("prompt1", "hello one"), ("prompt2", "hello two"), ("prompt3-permission", "please ask permission")): - await step(n, conn.prompt(session_id=sid, prompt=[TextContentBlock(type="text", text=t)])) - await step("session/load", conn.load_session(cwd="/tmp", session_id=sid, mcp_servers=[])) - await step("prompt-after-load", conn.prompt(session_id=sid, prompt=[TextContentBlock(type="text", text="after load")])) - await step("close", conn.close()) - await tr.close() - print(f"RESULT pass={COUNTS['pass']} fail={COUNTS['fail']} updates_total={TOTAL} " + " ".join(f"{k}={v}" for k, v in PER_STEP.items()), flush=True) - - -asyncio.run(main()) diff --git a/integration-testing/programs/python/py-server.py b/integration-testing/programs/python/py-server.py deleted file mode 100644 index a0c4c77f..00000000 --- a/integration-testing/programs/python/py-server.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Interop agent on the Python SDK's Streamable HTTP ASGI app, served by Hypercorn with h2c enabled. - -Each prompt streams two updates; a prompt containing "permission" first asks the client. -Env: PORT. Prints "READY " on stdout once bound; one [http] line per request with the -HTTP version Hypercorn negotiated (scenarios assert HTTP/2 for the Java client). -""" -import asyncio -import os -import sys - -from acp import Agent, InitializeResponse, LoadSessionResponse, NewSessionResponse, PromptResponse -from acp.http.asgi import create_asgi_app -from acp.schema import AgentCapabilities, AgentMessageChunk, PermissionOption, TextContentBlock, ToolCallUpdate - -SESSIONS = set() - - -def log(*a): - print(*a, file=sys.stderr, flush=True) - - -def chunk(text): - return AgentMessageChunk(session_update="agent_message_chunk", content=TextContentBlock(type="text", text=text)) - - -class EchoAgent(Agent): - def on_connect(self, conn): - self._conn = conn - - async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kw): - log("[agent] initialize") - return InitializeResponse(protocol_version=protocol_version, agent_capabilities=AgentCapabilities(load_session=True)) - - async def new_session(self, cwd="", **kw): - sid = "py-" + os.urandom(4).hex() - SESSIONS.add(sid) - log("[agent] session/new", sid) - return NewSessionResponse(session_id=sid) - - async def load_session(self, cwd, session_id, mcp_servers=None, **kw): - log("[agent] session/load", session_id) - await self._conn.session_update(session_id=session_id, update=chunk("replayed history")) - return LoadSessionResponse() - - async def prompt(self, session_id, prompt, **kw): - text = " ".join((b.get("text", "") if isinstance(b, dict) else getattr(b, "text", "")) for b in prompt) - log("[agent] session/prompt", text) - if "permission" in text: - r = await self._conn.request_permission( - session_id=session_id, - tool_call=ToolCallUpdate(tool_call_id="tc-1", title="Run thing", kind="execute", status="pending"), - options=[PermissionOption(option_id="allow_once", name="Allow once", kind="allow_once"), - PermissionOption(option_id="reject_once", name="Reject once", kind="reject_once")]) - log("[agent] permission result", r) - for t in ("echo: ", text): - await self._conn.session_update(session_id=session_id, update=chunk(t)) - return PromptResponse(stop_reason="end_turn") - - -inner = create_asgi_app(lambda conn: EchoAgent()) - - -async def app(scope, receive, send): - if scope["type"] != "http": - return await inner(scope, receive, send) - h = {k.decode().lower(): v.decode() for k, v in scope["headers"]} - line = (f"[http] {scope['method']} {scope['path']} HTTP/{scope['http_version']} ct={h.get('content-type')} " - f"accept={h.get('accept')} conn={h.get('acp-connection-id')} sess={h.get('acp-session-id')} upgrade={h.get('upgrade')}") - - async def send2(msg): - if msg["type"] == "http.response.start": - rh = {k.decode().lower(): v.decode() for k, v in msg.get("headers", [])} - log(f"{line} -> {msg['status']} ct={rh.get('content-type')}") - await send(msg) - - return await inner(scope, receive, send2) - - -async def main(): - import hypercorn.asyncio - from hypercorn.config import Config - - port = int(os.environ.get("PORT", "0")) - c = Config() - c.bind = [f"127.0.0.1:{port}"] - c.alpn_protocols = ["h2", "http/1.1"] - ready = asyncio.Event() - - async def announce(): - # Hypercorn has no "bound" callback; poll the port, then announce. - for _ in range(200): - try: - _, w = await asyncio.open_connection("127.0.0.1", port) - w.close() - print(f"READY {port}", flush=True) - return - except OSError: - await asyncio.sleep(0.05) - - asyncio.create_task(announce()) - await hypercorn.asyncio.serve(app, c) - - -asyncio.run(main()) diff --git a/integration-testing/programs/quarkus/launch/agent.sh b/integration-testing/programs/quarkus/launch/agent.sh new file mode 100755 index 00000000..2a4862df --- /dev/null +++ b/integration-testing/programs/quarkus/launch/agent.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Starts the Quarkus smoke agent: --transport stdio | --transport http|ws --port (one server +# serves HTTP and WebSocket). Launcher contract: integration-testing/README.md, "Contracts". +# Prints nothing itself: on stdio, stdout is the protocol stream. JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +usage() { echo "usage: agent.sh --transport stdio | --transport http|ws --port " >&2; exit 2; } +transport="" port="" +while [ $# -gt 0 ]; do + case "$1" in + --transport) transport="${2:-}"; shift 2 ;; + --port) port="${2:-}"; shift 2 ;; + *) usage ;; + esac +done +case "$transport" in + stdio) + # shellcheck disable=SC2086 + exec java ${JAVA_OPTS:-} -jar "$here/target/stdio/quarkus-run.jar" ;; + http|ws) + [ -n "$port" ] || usage + # shellcheck disable=SC2086 + exec java ${JAVA_OPTS:-} -Dinterop.ready=true -Dquarkus.http.host=127.0.0.1 -Dquarkus.http.port="$port" \ + -jar "$here/target/quarkus-app/quarkus-run.jar" ;; + *) usage ;; +esac diff --git a/integration-testing/programs/quarkus/launch/build.sh b/integration-testing/programs/quarkus/launch/build.sh new file mode 100755 index 00000000..73f9713b --- /dev/null +++ b/integration-testing/programs/quarkus/launch/build.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Builds the Quarkus smoke programs against the SDK installed from this checkout, with the root +# pom's Quarkus version. The agent transport and whether the agent is served are build-time +# settings of acp-quarkus, so there are three packages: +# target/quarkus-app the agent over HTTP and WebSocket (application.properties) +# target/stdio the agent over stdio +# target/client no agent served: the client program (launch/client.sh) +# Launcher contract: integration-testing/README.md, "Contracts". Env: MVNW, ACP_VERSION, IT_ROOT. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +quarkus_version="$(sed -n 's:.*\(.*\).*:\1:p' "${IT_ROOT:?IT_ROOT is not set}/../pom.xml" | head -1)" +[ -n "$quarkus_version" ] || { echo "build.sh: no in the root pom" >&2; exit 1; } +mvn_q() { + "${MVNW:?MVNW is not set}" -q -B -Dacp.version="${ACP_VERSION:?ACP_VERSION is not set}" \ + -Dquarkus.version="$quarkus_version" "$@" +} +mvn_q package +mvn_q package -Dquarkus.acp.agent.transport.type=stdio -Dquarkus.package.output-directory=stdio +mvn_q package -Dquarkus.acp.agent.enabled=false -Dquarkus.package.output-directory=client diff --git a/integration-testing/programs/quarkus/launch/client.sh b/integration-testing/programs/quarkus/launch/client.sh new file mode 100755 index 00000000..a0d60caa --- /dev/null +++ b/integration-testing/programs/quarkus/launch/client.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Starts the Quarkus smoke client: --transport http|ws --url (env STEPS). The ACP client is +# the bean acp-quarkus builds from quarkus.acp.client.*. Launcher contract: +# integration-testing/README.md, "Contracts". JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +usage() { echo "usage: client.sh --transport http|ws --url (env STEPS)" >&2; exit 2; } +transport="" url="" +while [ $# -gt 0 ]; do + case "$1" in + --transport) transport="${2:-}"; shift 2 ;; + --url) url="${2:-}"; shift 2 ;; + *) usage ;; + esac +done +case "$transport" in + http) key=quarkus.acp.client.transport.http.uri ;; + ws) key=quarkus.acp.client.transport.websocket.uri ;; + *) usage ;; +esac +[ -n "$url" ] || usage +# The client build serves no agent; its HTTP server takes an ephemeral port on loopback. +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -Dinterop.mode=client -D"$key=$url" \ + -Dquarkus.acp.client.capabilities.read-text-file=true \ + -Dquarkus.acp.client.capabilities.elicitation-form=true \ + -Dquarkus.http.host=127.0.0.1 -Dquarkus.http.port=0 -Dquarkus.banner.enabled=false \ + -jar "$here/target/client/quarkus-run.jar" diff --git a/integration-testing/programs/quarkus/pom.xml b/integration-testing/programs/quarkus/pom.xml new file mode 100644 index 00000000..c130804e --- /dev/null +++ b/integration-testing/programs/quarkus/pom.xml @@ -0,0 +1,90 @@ + + + + 4.0.0 + + com.agentclientprotocol.integration + acp-integration-quarkus-agent + 1 + + + + set-by-build.sh + set-by-build.sh + 17 + UTF-8 + + + + + + io.quarkus + quarkus-bom + ${quarkus.version} + pom + import + + + + + + + com.agentclientprotocol + acp-quarkus + ${acp.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + framework-sources + generate-sources + + add-source + + + + ${project.basedir}/../framework/src/main/java + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + + + + + + + + diff --git a/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Main.java b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Main.java new file mode 100644 index 00000000..cc70fc18 --- /dev/null +++ b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Main.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package interop.quarkus; + +import java.util.List; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import interop.framework.SmokeClient; +import io.quarkus.arc.Unremovable; +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.QuarkusApplication; +import io.quarkus.runtime.annotations.QuarkusMain; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +/** + * The application's main. In the agent builds it waits until the application exits (the agent is + * served by acp-quarkus; over stdio it exits when its input ends). In the client build + * ({@code -Dinterop.mode=client}, launch/client.sh) it runs the steps in {@code STEPS} on the + * {@code AcpAsyncClient} bean acp-quarkus builds from {@code quarkus.acp.client.*}, whose handlers + * the {@link SmokeClient} customizer bean registers, then exits. + */ +@QuarkusMain +public class Main implements QuarkusApplication { + + @Inject + Instance client; + + @Inject + Instance smokeClient; + + @Override + public int run(String... args) throws Exception { + if (!"client".equals(System.getProperty("interop.mode"))) { + Quarkus.waitForExit(); + return 0; + } + List steps = SmokeClient.steps(); + smokeClient.get().run(client.get(), steps); + return 0; + } + + /** The shared client steps and the customizer that registers their handlers. */ + @Singleton + public static class Beans { + + @Produces + @Singleton + @Unremovable + SmokeClient smokeClient() { + return new SmokeClient(); + } + + @Produces + @Singleton + @Unremovable + AcpClientCustomizer smokeClientHandlers(SmokeClient smokeClient) { + return smokeClient::customize; + } + + } + +} diff --git a/integration-testing/programs/quarkus/src/main/java/interop/quarkus/QuarkusSmokeAgent.java b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/QuarkusSmokeAgent.java new file mode 100644 index 00000000..b64580ef --- /dev/null +++ b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/QuarkusSmokeAgent.java @@ -0,0 +1,17 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package interop.quarkus; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import interop.framework.SmokeAgent; + +/** + * The shared smoke agent on Quarkus: {@code @AcpAgent} alone makes it a bean, which acp-quarkus + * finds at build time and serves over stdio or HTTP and WebSocket, as the build chose. + */ +@AcpAgent(name = "interop-quarkus-agent", version = "1") +public class QuarkusSmokeAgent extends SmokeAgent { + +} diff --git a/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Ready.java b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Ready.java new file mode 100644 index 00000000..8a13f98b --- /dev/null +++ b/integration-testing/programs/quarkus/src/main/java/interop/quarkus/Ready.java @@ -0,0 +1,29 @@ +/* + * Copyright 2025-2026 the original author or authors. + */ + +package interop.quarkus; + +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Singleton; +import org.eclipse.microprofile.config.ConfigProvider; + +/** + * Prints {@code READY } once the HTTP server accepts connections (the launcher contract), + * only when launch/agent.sh asks for it ({@code -Dinterop.ready=true}, HTTP and WebSocket): over + * stdio, standard output is the protocol stream. + */ +@Singleton +public class Ready { + + void ready(@Observes StartupEvent event) { + if (!Boolean.getBoolean("interop.ready")) { + return; + } + int port = ConfigProvider.getConfig().getValue("quarkus.http.port", Integer.class); + System.out.println("READY " + port); + System.out.flush(); + } + +} diff --git a/integration-testing/programs/quarkus/src/main/resources/application.properties b/integration-testing/programs/quarkus/src/main/resources/application.properties new file mode 100644 index 00000000..f7ed1082 --- /dev/null +++ b/integration-testing/programs/quarkus/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# Served over Streamable HTTP and WebSocket at /acp on the Quarkus HTTP server. +quarkus.acp.agent.transport.type=http +quarkus.acp.agent.transport.http.path=/acp +quarkus.banner.enabled=false diff --git a/integration-testing/programs/raw/gen_conf.py b/integration-testing/programs/raw/gen_conf.py new file mode 100644 index 00000000..32addfc7 --- /dev/null +++ b/integration-testing/programs/raw/gen_conf.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""Generates the raw conformance scenarios, configs/conf-java-*-.json, from the case +lists in raw.py, the catalogue (steps.json) and expectations/raw.json. + + python3 programs/raw/gen_conf.py write the nine configs + python3 programs/raw/gen_conf.py --check exit 1 if a committed config is stale or missing + python3 programs/raw/gen_conf.py --list print the scenarios and their case counts + +conf-java-agent-: the raw client probes the Java agent (programs/java) over . +conf-java-client-: the Java client's raw mode (client.sh --mode raw, Raw.java) against + the raw agent, which answers its "#raw " prompts. +conf-java-client-catalogue-: the Java client runs AGENT_ROLE_STEPS (catalogue steps) against the + raw agent, which shapes some answers and, during the plain prompt, + runs AGENT_CASES against the client. + +expectations/raw.json has two arrays. "expectations" is read by GenConfigs.java like every +other expectations file; the raw driver is no matrix language, so it stays empty. "conformance" +holds the expected failures of these scenarios: + + { "case": "raw.invalid-params.type" | "agent.raw.id.null" | "", + "when": { "side": "agent" | "client" | "client-catalogue", "transport": "stdio" | ["http", "ws"] }, + "contains": "-32603", "see": "P2" | "peer: " | "spec:", + "reason": "one line" } + +side "agent" selects conf-java-agent-* (the Java agent under test, case ids raw.*); "client" and +"client-catalogue" select conf-java-client-*, conf-java-client-catalogue-* (the Java client under +test: its own step ids, and the agent.raw.* lines of the raw agent). Omitted when keys match +anything. "see" takes the forms GenConfigs.java accepts. +""" + +import json +import os +import re +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) +sys.dont_write_bytecode = True +import raw # noqa: E402 + +MARKER = "GENERATED by programs/raw/gen_conf.py from raw.py, steps.json and expectations/raw.json; do not edit, regenerate." +TRANSPORTS = ("stdio", "http", "ws") +SIDES = ("agent", "client", "client-catalogue") +SEE = re.compile(r"^(P\d+b?|peer:\S+ \S+.*|spec:\S.*)$") +JAVA_BUILD = 'IT_ROOT="${ROOT}" MVNW="${MVNW}" ACP_VERSION="${ACP_VERSION}" CACHE="${CACHE}" ${ROOT}/programs/java/launch/build.sh' +RAW_BUILD = '${ROOT}/programs/raw/launch/build.sh' + + +def load(path): + with open(os.path.join(ROOT, path)) as f: + return json.load(f) + + +def entries(problems): + doc = load("expectations/raw.json") + if doc.get("expectations") != []: + problems.append("expectations/raw.json: \"expectations\" must be [] (the raw driver is no matrix language)") + out = [] + for i, e in enumerate(doc.get("conformance", [])): + where = "expectations/raw.json conformance[%d]" % i + for k in e: + if k not in ("case", "when", "contains", "see", "reason", "comment"): + problems.append("%s: unknown field %s" % (where, k)) + for k in ("case", "contains", "see", "reason"): + if not isinstance(e.get(k), str): + problems.append("%s: \"%s\" must be a string" % (where, k)) + when = e.get("when", {}) + for k, v in when.items(): + vals = v if isinstance(v, list) else [v] + valid = {"side": SIDES, "transport": TRANSPORTS}.get(k) + if valid is None: + problems.append("%s: unknown when.%s" % (where, k)) + continue + for x in vals: + if x not in valid: + problems.append("%s: when.%s has unknown value %s" % (where, k, x)) + if isinstance(e.get("see"), str) and not SEE.match(e["see"]): + problems.append("%s: see must be P, peer: or spec:" % where) + if not str(e.get("reason", "")).strip(): + problems.append("%s: reason is empty" % where) + out.append(dict(e, _where=where)) + return out + + +def matches(e, side, transport): + for k, v in e.get("when", {}).items(): + vals = v if isinstance(v, list) else [v] + if (side if k == "side" else transport) not in vals: + return False + return True + + +def scenario(side, t, steps, exps, problems): + stdio = t == "stdio" + url = {"http": " --url http://127.0.0.1:${PORT}/acp", "ws": " --url ws://127.0.0.1:${PORT}/acp"}.get(t, "") + if side == "agent": + # The raw client probes the Java agent. + ids = raw.client_case_ids(t) + client = {"name": "client", "language": "python", "role": "client", "dir": "${ROOT}/programs/raw", + "build": RAW_BUILD + (" && " + JAVA_BUILD if stdio else ""), + "run": "${ROOT}/programs/raw/launch/client.sh --transport " + t + url, "env": {}} + if stdio: + client["env"]["AGENT_CMD"] = "${ROOT}/programs/java/launch/agent.sh --transport stdio" + server = {"name": "agent", "language": "java", "role": "server", "dir": "${ROOT}/programs/java", + "build": JAVA_BUILD, "run": "${ROOT}/programs/java/launch/agent.sh --transport " + t + " --port ${PORT}", + "env": {}, "ready": {"line": "READY", "timeoutSec": 60}} + agent_lines = [] + desc = "raw client -> java agent over %s: %d raw JSON-RPC cases (generated)" % (t, len(ids)) + else: + # The Java client against the raw agent: its raw mode, or catalogue steps while the raw + # agent probes it. + catalogue = side == "client-catalogue" + ids = raw.agent_step_ids(t) if catalogue else list(raw.JAVA_RAW_MODE_STEPS) + client = {"name": "client", "language": "java", "role": "client", "dir": "${ROOT}/programs/java", + "build": JAVA_BUILD + (" && " + RAW_BUILD if stdio else ""), + "run": "${ROOT}/programs/java/launch/client.sh --transport " + t + url + ( + "" if catalogue else " --mode raw"), + "env": {"STEPS": ",".join(ids)}} + if stdio: + client["env"]["AGENT_CMD"] = "${ROOT}/programs/raw/launch/agent.sh --transport stdio" + server = {"name": "agent", "language": "python", "role": "server", "dir": "${ROOT}/programs/raw", + "build": RAW_BUILD, "run": "${ROOT}/programs/raw/launch/agent.sh --transport " + t + " --port ${PORT}", + "env": {}, "ready": {"line": "READY", "timeoutSec": 30}} + if catalogue: + agent_lines = ["agent.raw." + c[len("raw."):] for c in raw.agent_case_ids(t)] + agent_lines += ["agent." + s for s in ids if isinstance(steps.get(s, {}).get("pass", {}).get("agent"), str)] + desc = "java client -> raw agent over %s: %d catalogue steps, %d raw cases on the client (generated)" % ( + t, len(ids), len(agent_lines)) + else: + agent_lines = [] + desc = "java client (--mode raw) -> raw agent over %s: %d steps (generated)" % (t, len(ids)) + agent_proc = "client" if stdio else "agent" + chosen = {} + for e in exps: + if not matches(e, side, t): + continue + key = e["case"] + if key not in ids and key not in agent_lines: + continue + prev = chosen.get(key) + if prev is None or len(e.get("when", {})) > len(prev.get("when", {})): + chosen[key] = e + client_xfail = sum(1 for k in chosen if k in ids) + required = [("STEP %s PASS" % a) for a in agent_lines if a not in chosen] + xf = [] + for k, e in chosen.items(): + xf.append({"process": "client" if k in ids else agent_proc, "step": k, "contains": e["contains"], + "reason": e["reason"], "see": e["see"] + " [expectations/raw.json]"}) + timeout = 120 + 5 * len(ids) + client["timeoutSec"] = timeout + cfg = { + "name": "conf-java-%s-%s" % (side, t), + "description": desc, + "timeoutSec": timeout + 120, + "peers": [], + "processes": ([client] if stdio else [server, client]), + "assertions": { + "requiredOutput": {agent_proc: required} if required else {}, + "checks": ["client RESULT pass == %d" % (len(ids) - client_xfail), "client RESULT fail == %d" % client_xfail], + "expectedFailures": xf, + "report": {"client": ["TIMEOUT"]}, + }, + "comment": MARKER, + } + if not cfg["assertions"]["requiredOutput"]: + del cfg["assertions"]["requiredOutput"] + return cfg, set(chosen) + + +def generate(): + problems = [] + steps = {s["id"]: s for s in load("steps.json")["steps"]} + for s in raw.AGENT_ROLE_STEPS: + if s not in steps: + problems.append("raw.py AGENT_ROLE_STEPS: no step %s in steps.json" % s) + exps = entries(problems) + out = {} + used = set() + for side in SIDES: + for t in TRANSPORTS: + cfg, chosen = scenario(side, t, steps, exps, problems) + out[cfg["name"]] = json.dumps(cfg, indent=2) + "\n" + used |= {(e["_where"]) for e in exps if e["case"] in chosen and matches(e, side, t)} + for e in exps: + if e["_where"] not in used: + problems.append("%s (%s): matches no case of any conf scenario" % (e["_where"], e.get("case"))) + return out, problems + + +def main(argv): + out, problems = generate() + if problems: + for p in problems: + print(p, file=sys.stderr) + sys.exit(1) + configs = os.path.join(ROOT, "configs") + if "--list" in argv: + for name, text in out.items(): + print("%s %s" % (name, json.loads(text)["description"])) + return + if "--check" in argv: + stale = [] + for name, text in out.items(): + path = os.path.join(configs, name + ".json") + if not os.path.exists(path): + stale.append("missing configs/%s.json" % name) + elif open(path).read() != text: + stale.append("stale configs/%s.json" % name) + for f in sorted(os.listdir(configs)): + if f.startswith("conf-java-") and f.endswith(".json") and f[:-5] not in out: + stale.append("extra configs/%s" % f) + if stale: + print("\n".join(stale)) + print("Conformance configs are out of date: run 'python3 programs/raw/gen_conf.py'") + sys.exit(1) + print("Conformance configs are up to date (%d)" % len(out)) + return + for name, text in out.items(): + with open(os.path.join(configs, name + ".json"), "w") as f: + f.write(text) + print("Wrote %d config(s) to %s" % (len(out), configs)) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/integration-testing/programs/raw/launch/agent.sh b/integration-testing/programs/raw/launch/agent.sh new file mode 100755 index 00000000..a3381504 --- /dev/null +++ b/integration-testing/programs/raw/launch/agent.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Starts the raw agent: --transport stdio | --transport http|ws --port [--target ]. +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 -u "$here/raw.py" agent "$@" diff --git a/integration-testing/programs/raw/launch/build.sh b/integration-testing/programs/raw/launch/build.sh new file mode 100755 index 00000000..dc4b63b2 --- /dev/null +++ b/integration-testing/programs/raw/launch/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# The raw conformance driver is Python 3 standard library only: nothing to build. +# Launcher contract: integration-testing/README.md, "Contracts". +set -euo pipefail +command -v python3 > /dev/null || { echo "raw: python3 is required" >&2; exit 1; } +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)' || { echo "raw: python3 >= 3.8 is required" >&2; exit 1; } diff --git a/integration-testing/programs/raw/launch/client.sh b/integration-testing/programs/raw/launch/client.sh new file mode 100755 index 00000000..83756147 --- /dev/null +++ b/integration-testing/programs/raw/launch/client.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Starts the raw client: --transport stdio (env AGENT_CMD) | --transport http|ws --url +# [--cases ] [--target ]. Launcher contract: integration-testing/README.md, "Contracts". +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec python3 -u "$here/raw.py" client "$@" diff --git a/integration-testing/programs/raw/raw.py b/integration-testing/programs/raw/raw.py new file mode 100755 index 00000000..387f9a47 --- /dev/null +++ b/integration-testing/programs/raw/raw.py @@ -0,0 +1,1975 @@ +#!/usr/bin/env python3 +"""The raw JSON-RPC conformance driver of the cross-SDK suite (integration-testing/README.md). + +It puts frames on the wire that no SDK's typed API will send: malformed JSON, JSON that is no +JSON-RPC message, ids of every legal and illegal type, unknown methods and notifications, wrong +params, stray responses, an error without "message", "result": null, oversized frames. It checks +the receiver's answer against JSON-RPC 2.0 and the ACP schema's ErrorCode list +(schema/v1/schema.json $defs.ErrorCode: -32700 parse error, -32600 invalid request, -32601 method +not found, -32602 invalid params, -32603 internal error, -32800 request cancelled, -32000 +authentication required, -32002 resource not found) and, on Streamable HTTP, against the +transport RFD (docs/rfds/streamable-http-websocket-transport.mdx). Python 3 standard library +only: no SDK is involved, so what it sends is exactly what the case says. + +Two roles, three transports each: + + raw.py client --transport stdio spawns bash -c "exec $AGENT_CMD", probes the agent + raw.py client --transport http --url http://127.0.0.1:/acp + raw.py client --transport ws --url ws://127.0.0.1:/acp + raw.py agent --transport stdio an agent on stdin/stdout + raw.py agent --transport http|ws --port

    an agent on /acp (Streamable HTTP and WebSocket + upgrade on the same endpoint), prints READY + +client role: runs the cases of CLIENT_CASES that apply to the transport (or --cases a,b), prints +one "STEP raw. PASS|FAIL ( ms) -> " per case and a final RESULT line, exits 0. +After each case it checks that the connection survived (a session/new still answers). A stdio +agent's stderr is relayed as the Contracts say (STEP lines verbatim, the rest "agent| ..."). + +agent role: serves the catalogue steps of AGENT_ROLE_STEPS to an SDK client, answering some of +its requests in ways no SDK agent would (an error without "message", "result": null for the +all-optional responses), and the "#raw " prompts of the Java client's raw mode. During the +plain-text prompt "hello" (update.agent_message_chunk) it runs AGENT_CASES against the client, +printing "STEP agent.raw. PASS|FAIL ..." on stderr. RAW_AGENT_CASES=a,b runs only those. + +Silence checks ("no reply") use a fence rather than a fixed wait: after the frame under test the +driver sends an ordinary request; a receiver answers in order, so an answer to the frame under +test arrives before the fence's (plus FENCE_GRACE for one a handler sends late). + +--target : evidence mode, for running the driver against a peer SDK. Every case line is +printed as "EVIDENCE PASS|FAIL ..." instead of STEP, so the run documents +the peer's behaviour and never gates a scenario; the client role also runs EVIDENCE_ONLY cases. + +gen_conf.py turns these case lists and expectations/raw.json into configs/conf-java-*.json. +""" + +import base64 +import hashlib +import http.client +import json +import os +import queue +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import urllib.parse +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +STEP_TIMEOUT = int(os.environ.get("STEP_TIMEOUT_MS", "15000")) / 1000.0 +FENCE_GRACE = 0.15 # seconds after the fence's answer during which a late answer still counts +MAX_FRAME = 16 * 1024 * 1024 # the Java agent's POST body and WebSocket message limit (G0c) +WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + +# fixtures (steps.json "fixtures") +CLIENT_INIT = { + "protocolVersion": 1, + "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True}, "terminal": False}, + "clientInfo": {"name": "interop-raw-client", "version": "1"}, +} +AGENT_INIT = { + "protocolVersion": 1, + # Advertises what the catalogue steps call: an SDK client sends nothing the agent did not + # advertise. providers is advertised so error.method-not-found reaches the wire and gets the + # message-less -32601 this agent answers it with. + "agentCapabilities": {"loadSession": True, "sessionCapabilities": {"delete": {}}, + "auth": {"logout": {}}, "providers": {}}, + "authMethods": [{"id": "interop-auth", "name": "Interop auth", "description": "Accepts any authenticate call"}], + "agentInfo": {"name": "interop-raw-agent", "version": "1"}, +} +MODES = { + "currentModeId": "interop-mode-a", + "availableModes": [{"id": "interop-mode-a", "name": "Mode A"}, {"id": "interop-mode-b", "name": "Mode B"}], +} +PERMISSION = { + "toolCall": {"toolCallId": "perm-1", "title": "interop permission", "kind": "edit", "status": "pending"}, + "options": [ + {"optionId": "allow", "name": "Allow", "kind": "allow_once"}, + {"optionId": "reject", "name": "Reject", "kind": "reject_once"}, + ], +} +UNKNOWN_UPDATE = {"sessionUpdate": "interop_future_update", "payload": {"x": 1}} +# steps.json fixtures.unknownEnums and fixtures.groupedConfigOption. +ENUM_TOOL_CALL = {"sessionUpdate": "tool_call", "toolCallId": "call-enum", "title": "interop enum tool", + "kind": "interop_future_kind", "status": "interop_future_status"} +ENUM_PLAN = {"sessionUpdate": "plan", "entries": [{"content": "future entry", "priority": "interop_future_priority", + "status": "interop_future_status"}]} +ENUM_STOP = "interop_future_stop" +ENUM_AUDIENCE = ["user", "interop_future_role"] +EFFORT_GROUPS = [ + {"group": "fast", "name": "Fast", "options": [{"value": "effort-low", "name": "Low"}]}, + {"group": "deep", "name": "Deep", "options": [{"value": "effort-medium", "name": "Medium"}, + {"value": "effort-high", "name": "High"}]}, +] + + +def effort_option(value): + return {"id": "effort", "name": "Effort", "type": "select", "currentValue": value, "options": EFFORT_GROUPS} + + +PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS = -32700, -32600, -32601, -32602 + + +class Fail(Exception): + pass + + +OUT_LOCK = threading.Lock() # one writer at a time: relayed agent lines must not split ours + + +def out(line): + with OUT_LOCK: + sys.stdout.write(line + "\n") + sys.stdout.flush() + + +def log(*parts): + with OUT_LOCK: + print(*parts, file=sys.stderr, flush=True) + + +def short(value, n=240): + s = value if isinstance(value, str) else json.dumps(value, separators=(",", ":")) + s = s.replace("\n", "\\n") + return s if len(s) <= n else s[:n] + "... (%d chars)" % len(s) + + +def dumps(obj): + return json.dumps(obj, separators=(",", ":")) + + +def parse(text): + """A received frame: the decoded JSON value, or {"_unparsed": text} when it is not JSON.""" + try: + return json.loads(text) + except ValueError: + return {"_unparsed": text} + + +def is_response(m): + return isinstance(m, dict) and "method" not in m and ("result" in m or "error" in m) + + +def is_request(m): + return isinstance(m, dict) and isinstance(m.get("method"), str) and "id" in m + + +def is_notification(m): + return isinstance(m, dict) and isinstance(m.get("method"), str) and "id" not in m + + +def error_code(m): + e = m.get("error") if isinstance(m, dict) else None + return e.get("code") if isinstance(e, dict) else None + + +def same_id(a, b): + """JSON-RPC ids compare by value and type: 1 is not "1", 0 is not null, true is not 1.""" + return type(a) is type(b) and a == b + + +# ===================================================================================== +# Transports: one message channel per connection. send() writes one frame (text, exactly as +# given); recv() returns the next decoded frame or None on timeout; closed is set at EOF. +# ===================================================================================== + + +UNCLEAN = [] # every stdio agent's stdout lines that are not one JSON-RPC message each + + +class StdioChannel: + """Client side: the agent is a child process; its stderr is relayed to our stdout.""" + + def __init__(self, cmd, relay): + self.proc = subprocess.Popen(["bash", "-c", "exec " + cmd], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.q = queue.Queue() + self.closed = False + self.unclean = UNCLEAN + self.relay = relay + threading.Thread(target=self._read, daemon=True).start() + threading.Thread(target=self._stderr, daemon=True).start() + + def _read(self): + for raw in self.proc.stdout: + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if not line.strip(): + continue + m = parse(line) + msgs = m if isinstance(m, list) and m else [m] # a batch reply is JSON-RPC too + if any(not isinstance(x, dict) or "_unparsed" in x or "jsonrpc" not in x for x in msgs): + self.unclean.append(short(line, 120)) + self.q.put(m) + self.closed = True + self.q.put(None) + + def _stderr(self): + for raw in self.proc.stderr: + self.relay(raw.decode("utf-8", "replace").rstrip("\r\n")) + + def send(self, text, **_): + try: + self.proc.stdin.write(text.encode("utf-8") + b"\n") + self.proc.stdin.flush() + except (BrokenPipeError, OSError) as e: + raise Fail("write to the agent's stdin failed: %s" % e) + return None + + def recv(self, timeout): + try: + return self.q.get(timeout=max(0.0, timeout)) + except queue.Empty: + return None + + def close(self): + try: + self.proc.stdin.close() + except OSError: + pass + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + + +class StdioServerChannel: + """Agent side over our own stdin/stdout.""" + + def __init__(self): + self.q = queue.Queue() + self.closed = False + self.lock = threading.Lock() + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + for raw in sys.stdin.buffer: + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if line.strip(): + self.q.put(parse(line)) + self.closed = True + self.q.put(None) + + def send(self, text, **_): + with self.lock: + sys.stdout.buffer.write(text.encode("utf-8") + b"\n") + sys.stdout.buffer.flush() + + def recv(self, timeout): + try: + return self.q.get(timeout=max(0.0, timeout)) + except queue.Empty: + return None + + +# ---------------------------------------------------------------- WebSocket framing + + +def ws_frame(opcode, payload, mask): + head = bytearray([0x80 | opcode]) + n = len(payload) + mbit = 0x80 if mask else 0 + if n < 126: + head.append(mbit | n) + elif n < 65536: + head.append(mbit | 126) + head += struct.pack("!H", n) + else: + head.append(mbit | 127) + head += struct.pack("!Q", n) + if not mask: + return bytes(head) + payload + if n > 1024 * 1024: + # A zero masking key is legal and leaves the payload as is: no 16 MB XOR in Python. + return bytes(head) + b"\0\0\0\0" + payload + key = os.urandom(4) + rep = (key * (n // 4 + 1))[:n] + masked = (int.from_bytes(payload, "big") ^ int.from_bytes(rep, "big")).to_bytes(n, "big") if n else b"" + return bytes(head) + key + masked + + +class WsChannel: + """A WebSocket connection over a connected socket, either side.""" + + def __init__(self, sock, rfile, client): + self.sock = sock + self.rfile = rfile + self.client = client # clients mask their frames + self.q = queue.Queue() + self.closed = False + self.close_code = None + self.lock = threading.Lock() + threading.Thread(target=self._read, daemon=True).start() + + @staticmethod + def connect(url, timeout=10): + u = urllib.parse.urlsplit(url) + sock = socket.create_connection((u.hostname, u.port or 80), timeout=timeout) + key = base64.b64encode(os.urandom(16)).decode() + req = ("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n") % (u.path or "/", u.hostname, + u.port or 80, key) + sock.sendall(req.encode()) + rfile = sock.makefile("rb") + status = rfile.readline().decode("latin-1").strip() + headers = {} + while True: + line = rfile.readline().decode("latin-1").strip() + if not line: + break + k, _, v = line.partition(":") + headers[k.strip().lower()] = v.strip() + if " 101 " not in status + " ": + sock.close() + raise Fail("WebSocket upgrade answered %r" % status) + sock.settimeout(None) + ch = WsChannel(sock, rfile, client=True) + ch.connection_id = headers.get("acp-connection-id") + return ch + + def _read_exact(self, n): + data = self.rfile.read(n) + if data is None or len(data) < n: + raise EOFError() + return data + + def _read(self): + parts = [] + try: + while True: + b0, b1 = self._read_exact(2) + opcode, fin = b0 & 0x0F, b0 & 0x80 + n = b1 & 0x7F + if n == 126: + n = struct.unpack("!H", self._read_exact(2))[0] + elif n == 127: + n = struct.unpack("!Q", self._read_exact(8))[0] + key = self._read_exact(4) if b1 & 0x80 else None + payload = self._read_exact(n) if n else b"" + if key and n: + rep = (key * (n // 4 + 1))[:n] + payload = (int.from_bytes(payload, "big") ^ int.from_bytes(rep, "big")).to_bytes(n, "big") + if opcode == 0x8: + self.close_code = struct.unpack("!H", payload[:2])[0] if len(payload) >= 2 else 1005 + try: + self._send_frame(0x8, payload[:2]) + except OSError: + pass + break + if opcode == 0x9: + self._send_frame(0xA, payload) + continue + if opcode == 0xA: + continue + if opcode in (0x1, 0x2): + parts = [(opcode, payload)] + elif opcode == 0x0: + parts.append((0x0, payload)) + if fin and parts: + first = parts[0][0] + data = b"".join(p for _, p in parts) + parts = [] + if first == 0x1: # binary frames are ignored (RFD) + self.q.put(parse(data.decode("utf-8", "replace"))) + except (EOFError, OSError, ValueError): + pass + self.closed = True + self.q.put(None) + + def _send_frame(self, opcode, payload): + with self.lock: + self.sock.sendall(ws_frame(opcode, payload, self.client)) + + def send(self, text, **_): + try: + self._send_frame(0x1, text.encode("utf-8")) + except OSError as e: + raise Fail("WebSocket send failed: %s" % e) + + def recv(self, timeout): + try: + return self.q.get(timeout=max(0.0, timeout)) + except queue.Empty: + return None + + def close(self): + try: + self._send_frame(0x8, struct.pack("!H", 1000)) + except OSError: + pass + deadline = time.time() + 2 + while not self.closed and time.time() < deadline: + time.sleep(0.02) + try: + self.sock.close() + except OSError: + pass + + +# ---------------------------------------------------------------- Streamable HTTP, client side + + +class HttpChannel: + """Client side of Streamable HTTP over HTTP/1.1 (the stdlib has no HTTP/2): POST each frame, + read the connection-scoped and session-scoped SSE streams into one queue.""" + + def __init__(self, url): + u = urllib.parse.urlsplit(url) + self.host, self.port, self.path = u.hostname, u.port or 80, u.path or "/" + self.q = queue.Queue() + self.closed = False + self.connection_id = None + self.streams = {} # session id (None: the connection stream) -> HTTPConnection + self.inbound_scope = {} # id of an agent request -> session id of the stream it came on + self.post_conn = None + + def http(self, method, body=None, headers=None, timeout=30): + """One request on its own connection: (status, headers, body bytes).""" + c = http.client.HTTPConnection(self.host, self.port, timeout=timeout) + try: + c.request(method, self.path, body=body, headers=headers or {}) + r = c.getresponse() + data = r.read() + return r.status, {k.lower(): v for k, v in r.getheaders()}, data + finally: + c.close() + + def headers(self, session=None, extra=None): + h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if self.connection_id: + h["Acp-Connection-Id"] = self.connection_id + if session: + h["Acp-Session-Id"] = session + h.update(extra or {}) + return {k: v for k, v in h.items() if v is not None} + + def initialize(self, params, rid=0): + body = dumps({"jsonrpc": "2.0", "id": rid, "method": "initialize", "params": params}) + status, hdrs, data = self.http("POST", body.encode(), self.headers()) + if status != 200: + raise Fail("initialize POST answered %d: %s" % (status, short(data.decode("utf-8", "replace")))) + self.connection_id = hdrs.get("acp-connection-id") + if not self.connection_id: + raise Fail("initialize answered without an Acp-Connection-Id header") + self.open_stream(None) + return parse(data.decode("utf-8")) + + def open_stream(self, session): + if session in self.streams: + return + c = http.client.HTTPConnection(self.host, self.port, timeout=None) + h = {"Accept": "text/event-stream", "Acp-Connection-Id": self.connection_id} + if session: + h["Acp-Session-Id"] = session + c.request("GET", self.path, headers=h) + r = c.getresponse() + if r.status != 200: + body = r.read() + c.close() + raise Fail("GET %s stream answered %d: %s" % (session or "connection", r.status, short(body.decode()))) + self.streams[session] = c + threading.Thread(target=self._sse, args=(r, session), daemon=True).start() + + def _sse(self, resp, session): + data = [] + try: + while True: + line = resp.readline() + if not line: + break + line = line.decode("utf-8", "replace").rstrip("\r\n") + if line == "": + if data: + m = parse("\n".join(data)) + if is_request(m): + self.inbound_scope[dumps(m["id"])] = session + self.q.put(m) + data = [] + elif line.startswith("data:"): + data.append(line[5:].lstrip(" ") if line.startswith("data: ") else line[5:]) + except (OSError, ValueError, http.client.HTTPException): + pass + if session is None: + self.closed = True + self.q.put(None) + + def send(self, text, session=None, extra_headers=None): + """POSTs one frame. The session header comes from the frame's params.sessionId, or for a + response from the stream its request came on. Returns (status, body).""" + if session is None: + m = parse(text) + if isinstance(m, dict): + p = m.get("params") + if isinstance(p, dict) and isinstance(p.get("sessionId"), str): + session = p["sessionId"] + elif is_response(m) and "id" in m: + session = self.inbound_scope.get(dumps(m["id"])) + status, _, data = self.http("POST", text.encode("utf-8"), self.headers(session, extra_headers)) + return status, data.decode("utf-8", "replace") + + def recv(self, timeout): + try: + return self.q.get(timeout=max(0.0, timeout)) + except queue.Empty: + return None + + def close(self): + if self.connection_id: + try: + self.http("DELETE", None, {"Acp-Connection-Id": self.connection_id}, timeout=5) + except OSError: + pass + for c in self.streams.values(): + try: + c.close() + except OSError: + pass + + +# ===================================================================================== +# Client role +# ===================================================================================== + + +class Probe: + """The raw client's view of one connection: sends frames, waits for answers, and answers the + agent's own requests (permission: allow; fs: write/read the file) unless a case overrides.""" + + def __init__(self, transport, url, relay): + self.transport = transport + self.url = url + self.relay = relay + self.next_id = 1000 + self.updates = [] # (sessionId, update) + self.answer = None # case override: fn(request) -> frame text or None for the default + self.agent_requests = [] + self.dir = tempfile.mkdtemp(prefix="acp-raw-") + self.ch = None + + def open(self): + if self.transport == "stdio": + self.ch = StdioChannel(os.environ["AGENT_CMD"], self.relay) + init = self.call("initialize", CLIENT_INIT) + elif self.transport == "ws": + self.ch = WsChannel.connect(self.url) + init = self.call("initialize", CLIENT_INIT) + else: + self.ch = HttpChannel(self.url) + init = self.ch.initialize(CLIENT_INIT, rid=self.new_id()) + if not isinstance(init, dict) or not isinstance(init.get("result"), dict): + raise Fail("initialize failed: %s" % short(init)) + self.init = init["result"] + return self + + def new_id(self): + self.next_id += 1 + return self.next_id + + def send(self, text, **kw): + return self.ch.send(text, **kw) + + def frame(self, method, params=None, rid="auto"): + m = {"jsonrpc": "2.0"} + if rid != "none": + m["id"] = self.new_id() if rid == "auto" else rid + m["method"] = method + if params is not None: + m["params"] = params + return m + + def handle(self, m): + """Anything that is not the awaited reply: record updates, answer agent requests.""" + if is_notification(m): + if m["method"] == "session/update" and isinstance(m.get("params"), dict): + self.updates.append((m["params"].get("sessionId"), m["params"].get("update"))) + return + if is_request(m): + self.agent_requests.append(m) + text = self.answer(m) if self.answer else None + self.send(text if text is not None else dumps(self.default_answer(m))) + + def default_answer(self, m): + p = m.get("params") if isinstance(m.get("params"), dict) else {} + meth = m["method"] + if meth == "session/request_permission": + opts = p.get("options") or [] + pick = next((o for o in opts if isinstance(o, dict) and o.get("kind") == "allow_once"), + opts[0] if opts else {"optionId": "allow"}) + return {"jsonrpc": "2.0", "id": m["id"], "result": {"outcome": {"outcome": "selected", + "optionId": pick.get("optionId")}}} + if meth == "fs/write_text_file": + try: + with open(p["path"], "w") as f: + f.write(p.get("content", "")) + return {"jsonrpc": "2.0", "id": m["id"], "result": {}} + except (OSError, KeyError, TypeError) as e: + return {"jsonrpc": "2.0", "id": m["id"], "error": {"code": -32603, "message": str(e)}} + if meth == "fs/read_text_file": + try: + with open(p["path"]) as f: + return {"jsonrpc": "2.0", "id": m["id"], "result": {"content": f.read()}} + except (OSError, KeyError, TypeError) as e: + return {"jsonrpc": "2.0", "id": m["id"], "error": {"code": -32002, "message": str(e)}} + return {"jsonrpc": "2.0", "id": m["id"], "error": {"code": METHOD_NOT_FOUND, "message": "Method not found"}} + + def wait(self, pred, timeout=None, what="a reply"): + deadline = time.time() + (STEP_TIMEOUT if timeout is None else timeout) + while True: + left = deadline - time.time() + if left <= 0: + raise Fail("TIMEOUT after %d ms waiting for %s" % (int((timeout or STEP_TIMEOUT) * 1000), what)) + m = self.ch.recv(left) + if m is None: + if self.ch.closed: + raise Fail("the connection closed while waiting for %s%s" % (what, self.close_note())) + continue + if pred(m): + return m + self.handle(m) + + def close_note(self): + code = getattr(self.ch, "close_code", None) + rc = self.ch.proc.poll() if isinstance(self.ch, StdioChannel) else None + return (" (close code %s)" % code if code else "") + (" (agent exit %s)" % rc if rc is not None else "") + + def reply_to(self, rid, timeout=None): + return self.wait(lambda m: is_response(m) and "id" in m and same_id(m["id"], rid), timeout, + "the reply to id %s" % dumps(rid)) + + def call(self, method, params, timeout=None): + f = self.frame(method, params) + self.send(dumps(f)) + return self.reply_to(f["id"], timeout) + + def drain(self, seconds): + """Handles what arrives for a while; returns the responses (none were awaited).""" + got = [] + deadline = time.time() + seconds + while time.time() < deadline: + m = self.ch.recv(deadline - time.time()) + if m is None: + if self.ch.closed: + raise Fail("the connection closed%s" % self.close_note()) + continue + if is_response(m) or (isinstance(m, dict) and "_unparsed" in m): + got.append(m) + else: + self.handle(m) + return got + + def quiet(self): + """The responses that arrive before a fence: a session/new sent after the frame under + test, plus FENCE_GRACE. A receiver answers frames in order, so an answer to the frame + under test comes first; the grace covers one that a handler sends late.""" + fence = self.frame("session/new", {"cwd": self.dir, "mcpServers": []}) + self.send(dumps(fence)) + got = [] + deadline = time.time() + STEP_TIMEOUT + fenced = None + while True: + left = (fenced + FENCE_GRACE if fenced else deadline) - time.time() + if left <= 0: + if fenced: + return got + raise Fail("TIMEOUT after %d ms waiting for the fence session/new" % int(STEP_TIMEOUT * 1000)) + m = self.ch.recv(left) + if m is None: + if self.ch.closed: + raise Fail("the connection closed%s" % self.close_note()) + continue + if is_response(m) and "id" in m and same_id(m["id"], fence["id"]): + fenced = time.time() + elif is_response(m) or (isinstance(m, dict) and "_unparsed" in m): + got.append(m) + else: + self.handle(m) + + def new_session(self): + r = self.call("session/new", {"cwd": self.dir, "mcpServers": []}) + sid = (r.get("result") or {}).get("sessionId") if isinstance(r.get("result"), dict) else None + if not isinstance(sid, str) or not sid: + raise Fail("session/new failed: %s" % short(r)) + if self.transport == "http": + self.ch.open_stream(sid) + return sid + + def prompt(self, sid, text, timeout=None): + return self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}, timeout) + + def chunks(self, sid): + out = [] + for s, u in self.updates: + if s == sid and isinstance(u, dict) and u.get("sessionUpdate") == "agent_message_chunk": + c = u.get("content") or {} + if c.get("type") == "text": + out.append(c.get("text")) + return out + + def alive(self): + self.new_session() + + def close(self): + if self.ch: + self.ch.close() + + +def expect_error(m, code, rid="any"): + """An error response with this code (and, unless rid is "any", this exact id).""" + if not is_response(m) or "error" not in m: + raise Fail("expected error %d, got %s" % (code, short(m))) + if rid != "any" and not ("id" in m and same_id(m["id"], rid)): + raise Fail("error %s answered with id %s, expected %s" % (error_code(m), dumps(m.get("id", "")), + dumps(rid))) + if error_code(m) != code: + raise Fail("expected error %d, got %s" % (code, short(m))) + if not isinstance(m["error"].get("message"), str): + raise Fail("error %d without a string message (schema Error.message is required): %s" % (code, short(m))) + return "error %d id %s: %s" % (code, dumps(m.get("id")), short(m["error"].get("message"), 80)) + + +def null_id_error(p, code, timeout=None): + """The answer to an unreadable frame: an error response with "id": null.""" + m = p.wait(lambda x: is_response(x) and "id" in x and x["id"] is None, timeout, + "an error response with \"id\": null") + return expect_error(m, code, None) + + +def http_status(p, status_got, want, body): + if status_got not in want: + raise Fail("HTTP %d, expected %s: %s" % (status_got, "/".join(map(str, want)), short(body, 120))) + return "HTTP %d" % status_got + + +# ---------------------------------------------------------------- client-role cases +# Each takes a Probe (opened and initialized), returns a PASS detail or raises Fail. The +# connection-survived check runs after it unless the case says otherwise. + +def unreadable_case(text, code, http_want): + def case(p): + if p.transport == "http": + # The RFD defines no status for an unreadable body: refused with a 4xx (or the 501 it + # names for a batch), or accepted and answered like on the other transports. + status, body = p.send(text) + if status == 202: + return "HTTP 202, then " + null_id_error(p, code) + return http_status(p, status, http_want, body) + p.send(text) + return null_id_error(p, code) + return case + + +def c_invalid_request_method_type(p): + f = '{"jsonrpc":"2.0","id":"raw-m","method":42}' + if p.transport == "http": + status, body = p.send(f) + if status != 202: + return http_status(p, status, (400,), body) + # Accepted on HTTP: the -32600 must then arrive on the connection stream. + else: + p.send(f) + m = p.wait(lambda x: is_response(x) and "id" in x and (x["id"] is None or x["id"] == "raw-m"), + what="an error response (id \"raw-m\" or null)") + return expect_error(m, INVALID_REQUEST) + + +def c_invalid_request_id_type(p): + f = dumps({"jsonrpc": "2.0", "id": {"x": 1}, "method": "session/new", "params": {"cwd": p.dir, "mcpServers": []}}) + if p.transport == "http": + status, body = p.send(f) + if status == 202: + # Accepted on HTTP: the error must then arrive on a stream. + m = p.wait(lambda x: is_response(x) and "id" in x and x["id"] in (None, {"x": 1}), + what="an error response for the object id") + return expect_error(m, INVALID_REQUEST) + return http_status(p, status, (400,), body) + p.send(f) + m = p.wait(lambda x: is_response(x) and "id" in x and (x["id"] is None or x["id"] == {"x": 1}), + what="an error response for the object id") + if m["id"] is not None: + raise Fail("answered with the object id echoed (JSON-RPC: id must be a string, number or null; " + "an invalid request is answered with id null): %s" % short(m)) + return expect_error(m, INVALID_REQUEST, None) + + +def c_invalid_request_version(p): + f = dumps({"jsonrpc": "1.0", "id": "raw-v", "method": "session/new", "params": {"cwd": p.dir, "mcpServers": []}}) + if p.transport == "http": + status, body = p.send(f) + if status != 202: + return http_status(p, status, (400,), body) + else: + p.send(f) + m = p.wait(lambda x: is_response(x) and "id" in x and x["id"] in (None, "raw-v"), + what="the answer to a \"jsonrpc\": \"1.0\" request") + if "result" in m: + raise Fail("a request with \"jsonrpc\": \"1.0\" was executed (JSON-RPC 2.0: jsonrpc MUST be exactly " + "\"2.0\"; expected -32600): %s" % short(m)) + return expect_error(m, INVALID_REQUEST) + + +def c_batch(p): + f = "[" + dumps({"jsonrpc": "2.0", "id": "raw-b", "method": "session/new", + "params": {"cwd": p.dir, "mcpServers": []}}) + "]" + if p.transport == "http": + status, body = p.send(f) + return http_status(p, status, (501,), body) # RFD: "Batch JSON-RPC requests return 501." + p.send(f) + m = p.wait(lambda x: isinstance(x, list) or (is_response(x) and "id" in x and x["id"] in (None, "raw-b")), + what="an answer to a one-element batch") + if isinstance(m, list): + return "answered as a batch: %s" % short(m, 100) + if "result" in m: + raise Fail("the batch's request was answered unbatched (JSON-RPC 2.0: a batch is answered with an " + "array, or rejected): %s" % short(m)) + return expect_error(m, INVALID_REQUEST) + + +def c_method_not_found(method): + def case(p): + f = p.frame(method, {}) + if p.transport == "http": + status, body = p.send(dumps(f)) + if status != 202: + raise Fail("POST answered %d (expected 202 and a -32601 on a stream): %s" % (status, short(body))) + else: + p.send(dumps(f)) + return expect_error(p.reply_to(f["id"]), METHOD_NOT_FOUND, f["id"]) + return case + + +def c_no_reply(frame_fn): + def case(p): + f = frame_fn(p) + if p.transport == "http": + status, body = p.send(f) + if status not in (202, 400): + raise Fail("POST answered %d (expected 202): %s" % (status, short(body))) + else: + p.send(f) + got = p.quiet() + if got: + raise Fail("answered a notification or response, which JSON-RPC 2.0 forbids: %s" % short(got[0])) + return "no reply" + return case + + +def c_id(rid): + def case(p): + f = {"jsonrpc": "2.0", "id": rid, "method": "session/new", "params": {"cwd": p.dir, "mcpServers": []}} + text = dumps(f) + if p.transport == "http": + status, body = p.send(text) + if status != 202: + raise Fail("POST answered %d: %s" % (status, short(body))) + else: + p.send(text) + # Any response: the flow is sequential, so one with another id is this request's, changed. + m = p.wait(lambda x: is_response(x) and "id" in x, what="the reply to id %s" % dumps(rid)) + if not same_id(m["id"], rid): + raise Fail("answered with id %s for request id %s: %s" % (dumps(m["id"]), dumps(rid), short(m))) + if "result" not in m: + raise Fail("session/new with id %s failed: %s" % (dumps(rid), short(m))) + return "answered with id %s" % dumps(m["id"]) + return case + + +def c_invalid_params(build): + def case(p): + method, params = build(p) + f = p.frame(method, params) + if p.transport == "http": + status, body = p.send(dumps(f)) + if status != 202: + return http_status(p, status, (400,), body) + else: + p.send(dumps(f)) + return expect_error(p.reply_to(f["id"]), INVALID_PARAMS, f["id"]) + return case + + +def prompt_params(p): + sid = p.new_session() + return "session/prompt", {"sessionId": sid, "prompt": "hello"} + + +def c_callee(prompt_text, answer, check): + """The agent calls the client during a prompt; the raw client answers per `answer`.""" + def case(p): + sid = p.new_session() + seen = [] + + def override(m): + seen.append(m) + return answer(m) + p.answer = override + try: + r = p.prompt(sid, prompt_text.replace("{dir}", p.dir)) + finally: + p.answer = None + if not seen: + raise Fail("the agent never called the client: %s" % short(r)) + return check(p, sid, r, seen) + return case + + +def ans_error_without_message(m): + return dumps({"jsonrpc": "2.0", "id": m["id"], "error": {"code": -32603}}) + + +def ans_null_result(m): + return dumps({"jsonrpc": "2.0", "id": m["id"], "result": None}) + + +def chk_fs_write_ok(p, sid, r, seen): + if "result" not in r or r["result"].get("stopReason") != "end_turn": + raise Fail("the prompt did not end end_turn: %s" % short(r)) + end = time.time() + 1 + while "fs write ok" not in p.chunks(sid) and time.time() < end: + p.drain(0.05) + if "fs write ok" not in p.chunks(sid): + raise Fail("no chunk \"fs write ok\" (the agent refused \"result\": null for the all-optional " + "WriteTextFileResponse): %s" % p.chunks(sid)) + return "\"result\": null accepted for fs/write_text_file; chunk \"fs write ok\"" + + +def chk_fs_read_error(p, sid, r, seen): + end = time.time() + 1 + while not any(c.startswith("fs read error") for c in p.chunks(sid) if c) and time.time() < end: + p.drain(0.05) + chunks = p.chunks(sid) + if "error" in r: + # The agent program let the failed call fail its prompt: still an error, not a hang. + return "the prompt failed with %s (the agent got an error, not a hang)" % short(r["error"], 120) + if not any(c.startswith("fs read error") for c in chunks if c): + raise Fail("the agent did not get an error from fs/read_text_file: chunks %s" % chunks) + return "the agent got an error, not a hang: chunk %s" % short([c for c in chunks if c.startswith("fs read error")][0], 60) + + +def raw_post(p, body, timeout=30, session=None): + """A POST written by hand, so a server that answers before reading the whole body (413 on + Content-Length) is still heard: (status, body).""" + sock = socket.create_connection((p.ch.host, p.ch.port), timeout=timeout) + try: + head = ("POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Type: application/json\r\n" + "Accept: application/json, text/event-stream\r\nAcp-Connection-Id: %s\r\n%sContent-Length: %d\r\n" + "Connection: close\r\n\r\n") % (p.ch.path, p.ch.host, p.ch.port, p.ch.connection_id, + "Acp-Session-Id: %s\r\n" % session if session else "", len(body)) + try: + sock.sendall(head.encode() + body) + except OSError: + pass # refused mid-body: the status is already on the wire + data = b"" + while b"\r\n\r\n" not in data: + chunk = sock.recv(65536) + if not chunk: + break + data += chunk + line = data.split(b"\r\n", 1)[0].decode("latin-1") + parts = line.split(" ") + if len(parts) < 2 or not parts[1].isdigit(): + raise Fail("no HTTP status for a %d-byte POST (got %r)" % (len(body), line)) + return int(parts[1]), data.split(b"\r\n\r\n", 1)[-1].decode("utf-8", "replace") + finally: + sock.close() + + +# Codes the schema's ErrorCode list gives a meaning that a rejected concurrent prompt does not +# have: -32000 is "authentication required", -32002 "resource not found", -32800 "request +# cancelled"; -32700 and -32601 are about the frame and the method. +WRONG_FOR_CONCURRENT = {-32000: "authentication required", -32002: "resource not found", + -32800: "request cancelled", -32700: "parse error", -32601: "method not found"} + + +def c_prompt_concurrent(p): + """A second session/prompt while the first is still running on the same session. The spec + has no rule for it; whatever the agent does, an error must not claim a meaning the schema's + ErrorCode list gives to another condition, and the first prompt must still complete.""" + sid = p.new_session() + a = p.frame("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "#permission allow"}]}) + p.send(dumps(a)) + req = p.wait(lambda m: is_request(m) and m.get("method") == "session/request_permission", + what="the permission request of the first prompt (it holds the turn open)") + b = p.frame("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "concurrent"}]}) + p.send(dumps(b)) + rb = p.reply_to(b["id"]) + p.send(dumps(p.default_answer(req))) + ra = p.reply_to(a["id"]) + if (ra.get("result") or {}).get("stopReason") != "end_turn": + raise Fail("the first prompt did not complete end_turn after the second was answered: %s" % short(ra)) + if "result" in rb: + return "the second prompt was served (stopReason %s); the first completed" % rb["result"].get("stopReason") + code = error_code(rb) + if not isinstance(code, int): + raise Fail("the second prompt's error has no integer code: %s" % short(rb)) + if code in WRONG_FOR_CONCURRENT: + raise Fail("the second prompt was rejected with %d, which the schema's ErrorCode list defines as \"%s\": %s" + % (code, WRONG_FOR_CONCURRENT[code], short(rb, 160))) + return "the second prompt was rejected with %d; the first completed end_turn" % code + + +def big_prompt(p, sid, size): + """A session/prompt frame of exactly `size` bytes: plain text, so an agent echoes it.""" + f = p.frame("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": ""}]}) + pad = size - len(dumps(f)) + f["params"]["prompt"][0]["text"] = "x" * pad + return f + + +def c_oversized(p): + """A valid prompt frame of 16 MB + 1 byte. No spec text limits a message; the Java agent + limits a POST body and a WebSocket message to 16 MB (G0c). Either the transport refuses it + the way HTTP and WebSocket define (413; close 1009) and the server takes a new connection, + or the prompt is answered and the connection survives. stdio has no limit: answered.""" + big = MAX_FRAME + 1 + sid = p.new_session() + f = big_prompt(p, sid, big) + text = dumps(f) + if p.transport == "http": + status, body = raw_post(p, text.encode("utf-8"), session=sid) + if status == 413: + return "HTTP 413 for a %d-byte prompt" % big + if status != 202: + raise Fail("HTTP %d for a %d-byte prompt (expected 413, or 202 and an answer): %s" % (status, big, short(body))) + r = p.reply_to(f["id"], timeout=max(STEP_TIMEOUT, 30)) + return "accepted (202) and answered (%s)" % ("error %s" % error_code(r) if "error" in r else "result") + if p.transport == "ws": + try: + p.ch.send(text) + except Fail as e: + note = str(e) # the server may close before the whole frame is written + deadline = time.time() + 2 + while not p.ch.closed and time.time() < deadline: + time.sleep(0.05) + if p.ch.close_code is None: + raise Fail("the connection was dropped without a close frame (%s); expected close 1009" % note) + deadline = time.time() + max(STEP_TIMEOUT, 30) + r = None + while not p.ch.closed and time.time() < deadline: + m = p.ch.recv(0.2) + if m is None: + continue + if is_response(m) and same_id(m.get("id"), f["id"]): + r = m + break + if not is_response(m): + p.handle(m) + if r is not None: + return "answered (%s)" % ("error %s" % error_code(r) if "error" in r else "result") + if not p.ch.closed: + raise Fail("TIMEOUT: a %d-byte message was neither answered nor refused (close 1009)" % big) + if p.ch.close_code != 1009: + raise Fail("closed with code %s, expected 1009 (message too big)" % p.ch.close_code) + # The connection is gone by design; the server must still take a new one. + p.close() + p.open() + return "closed with 1009 for a %d-byte message; a new connection initializes" % big + p.send(text) + r = p.reply_to(f["id"], timeout=max(STEP_TIMEOUT, 30)) + return "answered a %d-byte line (%s)" % (big, "error %s" % error_code(r) if "error" in r else "result") + + +def c_parse_error_large(p): + """16 MB + 1 byte that is not JSON, on stdio: a parse error like any other.""" + big = MAX_FRAME + 1 + p.send("x" * big) + return null_id_error(p, PARSE_ERROR) + " (for a %d-byte line)" % big + + +def c_eof_answers(p): + """Requests written just before stdin closes are still answered before the agent exits. + No spec text requires it; an agent that exits at EOF with answers unwritten loses replies a + client that pipes requests and closes stdin (`printf ... | agent`) waits for. The Java agent + answers them since its stdio transport treats EOF as a half-close, so this gates + conf-java-agent-stdio; against a peer (--target) it stays evidence.""" + proc = subprocess.Popen(["bash", "-c", "exec " + os.environ["AGENT_CMD"]], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + frames = [dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": CLIENT_INIT})] + frames += [dumps({"jsonrpc": "2.0", "id": 2 + i, "method": "session/new", + "params": {"cwd": p.dir, "mcpServers": []}}) for i in range(5)] + try: + out, _ = proc.communicate(("\n".join(frames) + "\n").encode(), timeout=STEP_TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + raise Fail("TIMEOUT: the agent did not exit within %d ms of stdin's EOF" % int(STEP_TIMEOUT * 1000)) + ids = set() + for line in out.decode("utf-8", "replace").splitlines(): + m = parse(line) + if is_response(m) and isinstance(m.get("id"), int): + ids.add(m["id"]) + missing = sorted(set(range(1, 7)) - ids) + if missing: + raise Fail("the agent exited at EOF with %d of 6 requests unanswered (ids %s)" % (len(missing), missing)) + return "all 6 requests written before EOF were answered" + + +def c_stdout_clean(p): + """Every line a stdio agent wrote to stdout in this run was one JSON-RPC message + (transports.mdx: the agent MUST NOT write anything else to stdout).""" + if UNCLEAN: + raise Fail("non-JSON-RPC lines on the agent's stdout: %s" % UNCLEAN[:3]) + return "every stdout line of the agent was a JSON-RPC message" + + +def h_content_type(p): + status, _, body = p.ch.http("POST", dumps(p.frame("session/new", {"cwd": p.dir, "mcpServers": []})).encode(), + p.ch.headers(extra={"Content-Type": "text/plain"})) + return http_status(p, status, (415,), body.decode()) + + +def h_get_accept(p): + status, _, body = p.ch.http("GET", None, {"Accept": "application/json", "Acp-Connection-Id": p.ch.connection_id}) + return http_status(p, status, (406,), body.decode()) + + +def h_missing_connection(p): + status, _, body = p.ch.http("POST", dumps(p.frame("session/new", {"cwd": p.dir, "mcpServers": []})).encode(), + {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}) + return http_status(p, status, (400,), body.decode()) + + +def h_unknown_connection(p): + status, _, body = p.ch.http("POST", dumps(p.frame("session/new", {"cwd": p.dir, "mcpServers": []})).encode(), + p.ch.headers(extra={"Acp-Connection-Id": "no-such-connection"})) + return http_status(p, status, (404,), body.decode()) + + +def h_unknown_session_stream(p): + try: + status, _, body = p.ch.http("GET", None, {"Accept": "text/event-stream", "Acp-Connection-Id": p.ch.connection_id, + "Acp-Session-Id": "no-such-session"}, timeout=3) + except (socket.timeout, TimeoutError): + raise Fail("no answer within 3 s: the stream for an unknown Acp-Session-Id was held open (RFD: 404)") + return http_status(p, status, (404,), body.decode()) + + +def h_missing_session_header(p): + sid = p.new_session() + f = p.frame("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "hello"}]}) + status, _, body = p.ch.http("POST", dumps(f).encode(), p.ch.headers(session=None)) + if status == 202: + p.reply_to(f["id"]) # drain the answer before the liveness check + return http_status(p, status, (400,), body.decode()) + + +def h_delete_missing(p): + status, _, body = p.ch.http("DELETE", None, {}) + return http_status(p, status, (400,), body.decode()) + + +def nf(method, params_fn=lambda p: {}): + return lambda p: dumps({"jsonrpc": "2.0", "method": method, "params": params_fn(p)}) + + +ALL = ("stdio", "http", "ws") +# (case id, transports, function). Order is run order. +CLIENT_CASES = [ + ("raw.parse-error", ALL, unreadable_case('{"jsonrpc":"2.0","id":1,"method":', PARSE_ERROR, (400,))), + ("raw.invalid-request.empty-object", ALL, unreadable_case("{}", INVALID_REQUEST, (400,))), + ("raw.invalid-request.not-object", ALL, unreadable_case("42", INVALID_REQUEST, (400,))), + ("raw.invalid-request.null", ALL, unreadable_case("null", INVALID_REQUEST, (400,))), + ("raw.invalid-request.empty-batch", ALL, unreadable_case("[]", INVALID_REQUEST, (501,))), + ("raw.invalid-request.method-type", ALL, c_invalid_request_method_type), + ("raw.invalid-request.id-type", ALL, c_invalid_request_id_type), + ("raw.invalid-request.version", ALL, c_invalid_request_version), + ("raw.batch", ALL, c_batch), + ("raw.method-not-found", ALL, c_method_not_found("interop/no_such_method")), + ("raw.method-not-found.ext", ALL, c_method_not_found("_interop/no_such_method")), + ("raw.notification.unknown", ALL, c_no_reply(nf("interop/no_such_notification"))), + ("raw.notification.unknown-ext", ALL, c_no_reply(nf("_interop/note", lambda p: {"n": 1}))), + ("raw.notification.bad-params", ALL, c_no_reply(lambda p: dumps({"jsonrpc": "2.0", "method": "session/cancel", + "params": {"sessionId": 42}}))), + ("raw.id.null", ALL, c_id(None)), + ("raw.id.string", ALL, c_id("raw-id-1")), + ("raw.id.zero", ALL, c_id(0)), + ("raw.id.large", ALL, c_id(9007199254740993)), + ("raw.invalid-params.type", ALL, c_invalid_params(lambda p: ("session/new", {"cwd": 42, "mcpServers": []}))), + ("raw.invalid-params.by-position", ALL, c_invalid_params(lambda p: ("session/new", [p.dir, []]))), + ("raw.invalid-params.missing", ALL, c_invalid_params(lambda p: ("session/new", {"mcpServers": []}))), + ("raw.invalid-params.prompt", ALL, c_invalid_params(prompt_params)), + ("raw.stray-response.unknown-id", ALL, c_no_reply(lambda p: '{"jsonrpc":"2.0","id":"no-such-request","result":{}}')), + ("raw.stray-response.null-id", ALL, c_no_reply(lambda p: '{"jsonrpc":"2.0","id":null,"result":{}}')), + ("raw.stray-response.null-id-error", ALL, c_no_reply( + lambda p: '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}')), + ("raw.callee.error-without-message", ALL, c_callee("#fs read-missing {dir}/no-such-file.txt", ans_error_without_message, + chk_fs_read_error)), + ("raw.callee.result-null-required", ALL, c_callee("#fs read-missing {dir}/no-such-file.txt", ans_null_result, + chk_fs_read_error)), + ("raw.callee.result-null-optional", ALL, c_callee("#fs write {dir}/raw-null.txt raw", ans_null_result, + chk_fs_write_ok)), + ("raw.prompt.concurrent", ALL, c_prompt_concurrent), + ("raw.oversized", ALL, c_oversized), + ("raw.parse-error.large", ("stdio",), c_parse_error_large), + ("raw.http.content-type", ("http",), h_content_type), + ("raw.http.get-accept", ("http",), h_get_accept), + ("raw.http.missing-connection", ("http",), h_missing_connection), + ("raw.http.unknown-connection", ("http",), h_unknown_connection), + ("raw.http.unknown-session-stream", ("http",), h_unknown_session_stream), + ("raw.http.missing-session-header", ("http",), h_missing_session_header), + ("raw.http.delete-missing", ("http",), h_delete_missing), + ("raw.stdio.eof-answers", ("stdio",), c_eof_answers), + ("raw.stdio.stdout-clean", ("stdio",), c_stdout_clean), +] +# Cases whose outcome is timing-dependent: run with --target (evidence) or --cases, never in the +# generated scenarios. +EVIDENCE_ONLY = set() + + +def client_case_ids(transport, evidence=False): + return [c for c, ts, _ in CLIENT_CASES if transport in ts and (evidence or c not in EVIDENCE_ONLY)] + + +class Reporter: + def __init__(self, target, transport): + self.target = target + self.transport = transport + self.passed = self.failed = self.hangs = 0 + self.lock = threading.Lock() + + def line(self, case, ok, ms, detail): + detail = detail.replace("\n", " ").replace("\r", " ") + with self.lock: + if ok: + self.passed += 1 + else: + self.failed += 1 + self.hangs += "TIMEOUT" in detail + if self.target: + out("EVIDENCE %s %s %s %s (%d ms) -> %s" % (self.target, self.transport, case, + "PASS" if ok else "FAIL", ms, detail)) + else: + out("STEP %s %s (%d ms) -> %s" % (case, "PASS" if ok else "FAIL", ms, detail)) + + def result(self): + out("RESULT pass=%d fail=%d hangs=%d" % (self.passed, self.failed, self.hangs)) + + +def relay_line(target): + def relay(line): + # A stdio agent's stderr: STEP lines verbatim (Contracts §2), everything else prefixed. + out(line if line.startswith("STEP ") and not target else "agent| " + line) + return relay + + +def recover(p): + """After a failed case: keep the connection if it still answers, else start a new one.""" + if p is None or p.ch is None or p.ch.closed: + return None + try: + p.answer = None + p.drain(0.3) + p.new_session() + return p + except Exception: # noqa: BLE001 - any failure means a fresh connection + p.close() + return None + + +def run_client(transport, url, cases, target): + rep = Reporter(target, transport) + relay = relay_line(target) + p = None + for case in cases: + fn = next((f for c, _, f in CLIENT_CASES if c == case), None) + t0 = time.time() + if fn is None: + rep.line(case, False, 0, "unknown-step") + continue + try: + if p is None or p.ch is None or p.ch.closed: + if p is not None: + p.close() + p = Probe(transport, url, relay).open() + detail = fn(p) + try: + p.alive() + except Fail as e: + raise Fail("%s; but then the connection did not survive: %s" % (detail, e)) + rep.line(case, True, int((time.time() - t0) * 1000), detail) + except Fail as e: + rep.line(case, False, int((time.time() - t0) * 1000), str(e)) + p = recover(p) + except Exception as e: # noqa: BLE001 - a broken probe is a FAIL with its cause, not a crash + rep.line(case, False, int((time.time() - t0) * 1000), "%s: %s" % (type(e).__name__, e)) + if p is not None: + p.close() + p = None + if p is not None: + p.close() + rep.result() + + +# ===================================================================================== +# Agent role +# ===================================================================================== + +# The client steps (steps.json) a raw-agent cell runs, and how the raw agent shapes each one. +AGENT_ROLE_STEPS = [ + "init.initialize", # answered normally; stray frames precede the answer (stdio, ws) + "session.new", # answered with fixtures.modes; stray frames precede it (stdio, ws) + "session.load", # session/load answered "result": null (all-optional response) + "update.agent_message_chunk", # the AGENT_CASES run during this prompt, then an unknown update + "update.unknown", # #emit unknown: the unknown update, then the chunk "after-unknown" + "enum.tool_call", # #enum tool_call: kind and status the v1 schema does not define + "enum.plan", # #enum plan: an entry with unknown priority and status + "enum.stop", # #enum stop: stopReason interop_future_stop + "enum.audience", # #enum audience: the client's unknown Role, read from the raw frame + "config.grouped", # #config grouped: a grouped select; session/set_config_option effort + "perm.selected", # normal + "fs.write", # normal; the client's answer is checked + "error.method-not-found", # answered -32601 without "message" + "mode.set", # session/set_mode answered "result": null + "auth.authenticate", # authenticate answered "result": null + "auth.logout", # logout answered "result": null + "session.delete", # session/delete answered "result": null + "http.reconnect", # http only + "stdio.eof-exit", # stdio only + "conn.close", +] +# The Java client's own raw mode (client.sh --mode raw, programs/java Raw.java): one prompt +# "#raw " per step, answered by the raw agent as that file describes. +JAVA_RAW_MODE_STEPS = ["init.initialize", "raw.error-no-message", "raw.null-id-response", "raw.unknown-update", + "raw.null-result", "conn.close"] +AGENT_ROLE_STEP_TRANSPORTS = {"http.reconnect": ("http",), "stdio.eof-exit": ("stdio",)} + +# What the raw agent sends the client during the plain-text prompt: (case, transports). +AGENT_CASES = [ + ("raw.parse-error", ALL), ("raw.invalid-request.empty-object", ALL), ("raw.invalid-request.not-object", ALL), + ("raw.invalid-request.null", ALL), ("raw.invalid-request.empty-batch", ALL), + ("raw.invalid-request.method-type", ALL), ("raw.invalid-request.id-type", ALL), + ("raw.invalid-request.version", ALL), + ("raw.method-not-found", ALL), ("raw.method-not-found.ext", ALL), + ("raw.notification.unknown", ALL), ("raw.notification.unknown-ext", ALL), + ("raw.notification.bad-params", ALL), ("raw.update.unknown-session", ALL), + ("raw.id.null", ALL), ("raw.id.string", ALL), ("raw.id.zero", ALL), ("raw.id.large", ALL), + ("raw.invalid-params", ALL), + ("raw.stray-response.unknown-id", ALL), ("raw.stray-response.null-id", ALL), + ("raw.stray-response.null-id-error", ALL), + ("raw.update.unknown", ALL), +] + + +def agent_step_ids(transport): + return [s for s in AGENT_ROLE_STEPS if transport in AGENT_ROLE_STEP_TRANSPORTS.get(s, ALL)] + + +def agent_case_ids(transport): + only = [c.strip() for c in os.environ.get("RAW_AGENT_CASES", "").split(",") if c.strip()] + return [c for c, ts in AGENT_CASES if transport in ts and (not only or c in only)] + + +SESSIONS = set() # every session the agent created, across connections (http.reconnect loads one) +SESSIONS_LOCK = threading.Lock() + + +class AgentConn: + """One client connection of the raw agent. `chan.send(text, session=...)` routes a frame + (the session matters on Streamable HTTP only).""" + + def __init__(self, chan, transport, target): + self.chan = chan + self.transport = transport + self.target = target + self.client_caps = {} + self.next_id = 0 + self.deferred = deque() + self.ran_cases = False + self.sid = None + + # ---------------------------------------------------------------- plumbing + + def out(self, obj_or_text, session=None): + self.chan.send(obj_or_text if isinstance(obj_or_text, str) else dumps(obj_or_text), session=session) + + def recv(self, timeout): + if self.deferred: + return self.deferred.popleft() + return self.chan.recv(timeout) + + def step(self, case, ok, t0, detail): + detail = detail.replace("\n", " ") + ms = int((time.time() - t0) * 1000) + if self.target: + log("EVIDENCE %s %s agent.%s %s (%d ms) -> %s" % (self.target, self.transport, case, + "PASS" if ok else "FAIL", ms, detail)) + else: + log("STEP agent.%s %s (%d ms) -> %s" % (case, "PASS" if ok else "FAIL", ms, detail)) + + def await_reply(self, pred, timeout, what): + """Waits for a client frame matching pred; other frames are kept for the main loop.""" + deadline = time.time() + timeout + held = [] + try: + while True: + left = deadline - time.time() + if left <= 0: + raise Fail("TIMEOUT after %d ms waiting for %s" % (int(timeout * 1000), what)) + m = self.chan.recv(left) + if m is None: + if self.chan.closed: + raise Fail("the connection closed while waiting for %s" % what) + continue + if pred(m): + return m + held.append(m) + finally: + self.deferred.extend(held) + + def quiet(self): + """The responses that arrive before a fence (a permission request sent after the frame + under test) plus FENCE_GRACE; other frames are kept for the main loop.""" + self.next_id += 1 + fid = "raw-fence-%d" % self.next_id + self.out({"jsonrpc": "2.0", "id": fid, "method": "session/request_permission", + "params": dict(PERMISSION, sessionId=self.sid)}, self.sid) + got, held = [], [] + deadline = time.time() + STEP_TIMEOUT + fenced = None + try: + while True: + left = (fenced + FENCE_GRACE if fenced else deadline) - time.time() + if left <= 0: + if fenced: + return got + raise Fail("TIMEOUT after %d ms waiting for the fence" % int(STEP_TIMEOUT * 1000)) + m = self.chan.recv(left) + if m is None: + if self.chan.closed: + raise Fail("the connection closed") + continue + if is_response(m) and "id" in m and same_id(m["id"], fid): + fenced = time.time() + elif is_response(m) or (isinstance(m, dict) and "_unparsed" in m): + got.append(m) + else: + held.append(m) + finally: + self.deferred.extend(held) + + def request(self, method, params, session=None, rid=None, timeout=None): + if rid is None: + self.next_id += 1 + rid = "raw-agent-%d" % self.next_id + self.out({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}, session) + return self.await_reply(lambda m: is_response(m) and "id" in m and same_id(m["id"], rid), + STEP_TIMEOUT if timeout is None else timeout, "the reply to %s" % method) + + def ping(self, sid): + """The liveness check after each case: a permission request the client must answer.""" + r = self.request("session/request_permission", dict(PERMISSION, sessionId=sid), session=sid) + if "result" not in r: + raise Fail("the liveness permission request failed: %s" % short(r)) + + def strays(self, session): + # Frames no SDK sends, ahead of an ordinary answer: on stdio and WebSocket only, since on + # HTTP the answers to initialize and session/new do not travel on a stream. + if self.transport == "http": + return + for f in ('{"jsonrpc":"2.0","id":"no-such-request","result":{}}', + '{"jsonrpc":"2.0","id":null,"result":{}}', + '{"jsonrpc":"2.0","method":"_interop/note","params":{"n":1}}'): + self.out(f, session) + + # ---------------------------------------------------------------- serving + + def serve(self): + while True: + m = self.recv(1.0) + if m is None: + if self.chan.closed: + return + continue + if is_request(m): + self.on_request(m) + # notifications (session/cancel) and responses need nothing here + + def answer(self, m, result=None, error=None, session=None, raw=None): + if raw is not None: + self.out(raw, session) + elif error is not None: + self.out({"jsonrpc": "2.0", "id": m["id"], "error": error}, session) + else: + self.out({"jsonrpc": "2.0", "id": m["id"], "result": result}, session) + + def on_request(self, m): + method = m["method"] + p = m.get("params") if isinstance(m.get("params"), dict) else {} + sid = p.get("sessionId") if isinstance(p.get("sessionId"), str) else None + # Streamable HTTP: the answers to session/load (and initialize, session/new) travel on + # the connection stream; every other request naming a session is answered on its stream. + scope = None if method == "session/load" else sid + log("[raw-agent] %s %s" % (method, short(p, 160))) + if method == "initialize": + self.client_caps = p.get("clientCapabilities") or {} + self.strays(None) + return self.answer(m, AGENT_INIT) + if method == "session/new": + with SESSIONS_LOCK: + sid = "raw-sess-%d" % (len(SESSIONS) + 1) + SESSIONS.add(sid) + self.strays(None) + return self.answer(m, {"sessionId": sid, "modes": MODES}) + if method == "session/load": + # LoadSessionResponse has only optional fields: "result": null must read as {}. + return self.answer(m, None, session=None) + if method in ("session/set_mode", "authenticate", "logout", "session/delete"): + # Each response type has only optional fields: "result": null must read as {}. + t0 = time.time() + if method == "session/set_mode": + with SESSIONS_LOCK: + known = sid in SESSIONS + ok = known and p.get("modeId") == "interop-mode-b" + self.step("mode.set", ok, t0, "set_mode %s on %s session %s" % ( + p.get("modeId"), "a known" if known else "an unknown", sid)) + if p.get("modeId") not in ("interop-mode-a", "interop-mode-b"): + return self.answer(m, error={"code": INVALID_PARAMS, "message": "unknown mode"}, session=scope) + if method == "authenticate": + self.step("auth.authenticate", p.get("methodId") == "interop-auth", t0, "methodId %s" % p.get("methodId")) + return self.answer(m, None, session=scope) + if method == "session/prompt": + return self.prompt(m, sid, p) + if method == "session/set_config_option": + # Only effort exists, and only after #config grouped (the raw agent offers no options on new). + t0 = time.time() + value = p.get("value") + values = [o["value"] for g in EFFORT_GROUPS for o in g["options"]] + if p.get("configId") == "effort" and value in values: + self.step("config.grouped", value == "effort-high", t0, "effort set to %s" % value) + return self.answer(m, {"configOptions": [effort_option(value)]}, session=scope) + return self.answer(m, error={"code": INVALID_PARAMS, "message": "unknown config option %s=%s" + % (p.get("configId"), value)}, session=scope) + # error.method-not-found: an error without "message" (schema Error.message is required), + # so the client must fail the call with -32601 rather than hang or crash on it. + return self.answer(m, raw='{"jsonrpc":"2.0","id":%s,"error":{"code":-32601}}' % dumps(m["id"]), session=scope) + + def chunk(self, sid, text): + self.out({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": sid, "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": text}}}}, sid) + + def prompt(self, m, sid, p): + blocks = p.get("prompt") if isinstance(p.get("prompt"), list) else [] + text = next((b.get("text") for b in blocks if isinstance(b, dict) and b.get("type") == "text"), "") + if text == "#permission allow": + t0 = time.time() + try: + r = self.request("session/request_permission", dict(PERMISSION, sessionId=sid), session=sid) + out = (r.get("result") or {}).get("outcome") or {} + said = "selected %s" % out.get("optionId") if out.get("outcome") == "selected" else "cancelled" + self.step("perm.selected", said == "selected allow", t0, "outcome " + said) + except Fail as e: + said = "error" + self.step("perm.selected", False, t0, str(e)) + self.chunk(sid, "permission: " + said) + elif text.startswith("#fs write "): + t0 = time.time() + rest = text[len("#fs write "):] + path, _, content = rest.partition(" ") + try: + r = self.request("fs/write_text_file", {"sessionId": sid, "path": path, "content": content}, + session=sid) + ok = "result" in r + self.step("fs.write", ok, t0, "fs/write_text_file answered " + short(r, 120)) + self.chunk(sid, "fs write ok" if ok else "fs write error %s" % error_code(r)) + except Fail as e: + self.step("fs.write", False, t0, str(e)) + self.chunk(sid, "fs write error timeout") + elif text == "#emit unknown": + self.out({"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": sid, "update": UNKNOWN_UPDATE}}, sid) + self.chunk(sid, "after-unknown") + elif text in ("#enum tool_call", "#enum plan"): + self.out({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": sid, "update": ENUM_TOOL_CALL if text == "#enum tool_call" else ENUM_PLAN}}, sid) + self.chunk(sid, "after-enum") + elif text == "#enum stop": + self.chunk(sid, "stop") + return self.answer(m, {"stopReason": ENUM_STOP}, session=sid) + elif text == "#enum audience": + t0 = time.time() + first = blocks[0] if blocks and isinstance(blocks[0], dict) else {} + audience = (first.get("annotations") or {}).get("audience") + self.step("enum.audience", audience == ENUM_AUDIENCE, t0, "audience %s" % dumps(audience)) + self.chunk(sid, "audience: " + (",".join(map(str, audience)) if isinstance(audience, list) else "none")) + elif text == "#config grouped": + self.out({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": sid, "update": {"sessionUpdate": "config_option_update", + "configOptions": [effort_option("effort-low")]}}}, sid) + elif text.startswith("#raw "): + # The Java client's --mode raw steps (programs/java Raw.java): raw.. + case = text[len("#raw "):].strip() + if case == "error-no-message": + return self.answer(m, raw='{"jsonrpc":"2.0","id":%s,"error":{"code":-32603}}' % dumps(m["id"]), + session=sid) + if case == "null-id-response": + self.out('{"jsonrpc":"2.0","id":null,"result":{}}', sid) + elif case == "unknown-update": + self.out({"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": sid, "update": UNKNOWN_UPDATE}}, sid) + self.chunk(sid, "after-unknown") + elif case == "null-result": + return self.answer(m, None, session=sid) # PromptResponse requires stopReason + else: + return self.answer(m, error={"code": INVALID_PARAMS, "message": "unknown raw case: " + case}, + session=sid) + elif text.startswith("#"): + return self.answer(m, error={"code": INVALID_PARAMS, "message": "unknown directive: %s" + % text.split(" ")[0]}, session=sid) + else: + if text == "hello" and not self.ran_cases: + # update.agent_message_chunk's prompt: the agent-role cases run inside it, once. + self.ran_cases = True + self.run_cases(sid) + # An update kind from the future, then the ordinary chunks: the client must drop or + # surface it and keep going (the client step checks the chunks that follow). + self.out({"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": sid, "update": UNKNOWN_UPDATE}}, sid) + self.chunk(sid, "echo: ") + self.chunk(sid, text) + return self.answer(m, {"stopReason": "end_turn"}, session=sid) + + # ---------------------------------------------------------------- agent-role cases + + def run_cases(self, sid): + self.sid = sid + for case in agent_case_ids(self.transport): + t0 = time.time() + try: + detail = self.case(case, sid) + try: + self.ping(sid) + except Fail as e: + raise Fail("%s; but then the connection did not survive: %s" % (detail, e)) + self.step(case, True, t0, detail) + except Fail as e: + self.step(case, False, t0, str(e)) + if self.chan.closed: + return + + def unreadable(self, text, code, sid): + self.out(text, sid) + if self.transport == "http": + # On an SSE stream the client has no channel to answer on but a POST of its own; + # JSON-RPC 2.0 has no rule for it. Accept silence or the -32700/-32600 answer. + got = self.quiet() + for g in got: + if not (is_response(g) and g.get("id", 0) is None and error_code(g) == code): + raise Fail("answered %s" % short(g)) + return "skipped" if not got else "answered %d id null" % code + m = self.await_reply(lambda x: is_response(x) and "id" in x and x["id"] is None, STEP_TIMEOUT, + "an error response with \"id\": null") + return expect_error(m, code, None) + + def case(self, case, sid): + perm = dict(PERMISSION, sessionId=sid) + if case == "raw.parse-error": + return self.unreadable('{"jsonrpc":"2.0","id":1,"method":', PARSE_ERROR, sid) + if case == "raw.invalid-request.empty-object": + return self.unreadable("{}", INVALID_REQUEST, sid) + if case == "raw.invalid-request.not-object": + return self.unreadable("42", INVALID_REQUEST, sid) + if case == "raw.invalid-request.null": + return self.unreadable("null", INVALID_REQUEST, sid) + if case == "raw.invalid-request.empty-batch": + return self.unreadable("[]", INVALID_REQUEST, sid) + if case == "raw.invalid-request.method-type": + self.out('{"jsonrpc":"2.0","id":"raw-m","method":42}', sid) + if self.transport == "http": + return self.unreadable_tail("raw-m", INVALID_REQUEST) + m = self.await_reply(lambda x: is_response(x) and "id" in x and x["id"] in (None, "raw-m"), STEP_TIMEOUT, + "an error response") + return expect_error(m, INVALID_REQUEST) + if case == "raw.invalid-request.id-type": + self.out(dumps({"jsonrpc": "2.0", "id": {"x": 1}, "method": "session/request_permission", "params": perm}), + sid) + if self.transport == "http": + return self.unreadable_tail({"x": 1}, INVALID_REQUEST) + m = self.await_reply(lambda x: is_response(x) and "id" in x and x["id"] in (None, {"x": 1}), STEP_TIMEOUT, + "an error response for the object id") + if m["id"] is not None: + raise Fail("answered with the object id echoed: %s" % short(m)) + return expect_error(m, INVALID_REQUEST, None) + if case == "raw.invalid-request.version": + self.out(dumps({"jsonrpc": "1.0", "id": "raw-v", "method": "session/request_permission", "params": perm}), + sid) + m = self.await_reply(lambda x: is_response(x) and "id" in x and x["id"] in (None, "raw-v"), STEP_TIMEOUT, + "the answer to a \"jsonrpc\": \"1.0\" request") + if "result" in m: + raise Fail("a request with \"jsonrpc\": \"1.0\" was executed (expected -32600): %s" % short(m)) + return expect_error(m, INVALID_REQUEST) + if case in ("raw.method-not-found", "raw.method-not-found.ext"): + method = "interop/no_such_method" if case == "raw.method-not-found" else "_interop/no_such_method" + r = self.request(method, {"sessionId": sid}, session=sid) + return expect_error(r, METHOD_NOT_FOUND) + if case.startswith("raw.notification.") or case.startswith("raw.stray-response.") \ + or case == "raw.update.unknown-session": + frame = { + "raw.notification.unknown": {"jsonrpc": "2.0", "method": "interop/no_such_notification", + "params": {"sessionId": sid}}, + "raw.notification.unknown-ext": {"jsonrpc": "2.0", "method": "_interop/note", + "params": {"sessionId": sid, "n": 1}}, + "raw.notification.bad-params": {"jsonrpc": "2.0", "method": "session/update", + "params": {"sessionId": sid, "update": 5}}, + "raw.update.unknown-session": {"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": "no-such-session", "update": {"sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "stray"}}}}, + "raw.stray-response.unknown-id": {"jsonrpc": "2.0", "id": "no-such-request", "result": {}}, + "raw.stray-response.null-id": {"jsonrpc": "2.0", "id": None, "result": {}}, + "raw.stray-response.null-id-error": {"jsonrpc": "2.0", "id": None, + "error": {"code": -32700, "message": "Parse error"}}, + }[case] + self.out(frame, sid) + got = self.quiet() + if got: + raise Fail("answered a notification or response: %s" % short(got[0])) + return "no reply" + if case == "raw.update.unknown": + self.out({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": sid, + "update": UNKNOWN_UPDATE}}, sid) + got = self.quiet() + if got: + raise Fail("answered a notification: %s" % short(got[0])) + return "no reply" + if case.startswith("raw.id."): + rid = {"raw.id.null": None, "raw.id.string": "raw-id-1", "raw.id.zero": 0, + "raw.id.large": 9007199254740993}[case] + self.out({"jsonrpc": "2.0", "id": rid, "method": "session/request_permission", "params": perm}, sid) + m = self.await_reply(lambda x: is_response(x) and "id" in x, STEP_TIMEOUT, "the reply to id %s" % dumps(rid)) + if not same_id(m["id"], rid): + raise Fail("answered with id %s for request id %s: %s" % (dumps(m["id"]), dumps(rid), short(m))) + if "result" not in m: + raise Fail("the permission request with id %s failed: %s" % (dumps(rid), short(m))) + return "answered with id %s" % dumps(m["id"]) + if case == "raw.invalid-params": + r = self.request("session/request_permission", {"sessionId": sid, "toolCall": PERMISSION["toolCall"], + "options": "allow"}, session=sid) + return expect_error(r, INVALID_PARAMS) + raise Fail("unknown-step") + + def unreadable_tail(self, rid, code): + got = self.quiet() + for g in got: + if not (is_response(g) and (g.get("id") is None or g.get("id") == rid) and error_code(g) == code): + raise Fail("answered %s" % short(g)) + return "skipped" if not got else "answered %d" % code + + +# ---------------------------------------------------------------- agent role: stdio + + +def run_agent_stdio(target): + conn = AgentConn(StdioServerChannel(), "stdio", target) + log("[raw-agent] stdio agent started") + conn.serve() + log("[raw-agent] stdin closed; exiting") + + +# ---------------------------------------------------------------- agent role: HTTP and WebSocket + + +class HttpConn: + """One Streamable HTTP connection of the raw agent: an inbound queue fed by POSTs, and an + outbound queue per stream (None: the connection-scoped stream).""" + + def __init__(self, cid): + self.id = cid + self.q = queue.Queue() + self.closed = False + self.streams = {} + self.lock = threading.Lock() + + def stream(self, session): + with self.lock: + return self.streams.setdefault(session, queue.Queue()) + + def send(self, text, session=None): + # The agent passes the session for everything tied to one (its stream); responses to + # initialize, session/new and session/load, and the rest, use the connection stream. + self.stream(session).put(text) + + def recv(self, timeout): + try: + return self.q.get(timeout=max(0.0, timeout)) + except queue.Empty: + return None + + def close(self): + self.closed = True + self.q.put(None) + with self.lock: + for s in self.streams.values(): + s.put(None) + + +def make_handler(target, conns): + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + pass + + def log_line(self, status, extra=""): + log("[http] %s %s %s -> %d conn=\"%s\" sess=\"%s\" upgrade=\"%s\"%s" % ( + self.command, self.path, self.request_version, status, self.headers.get("Acp-Connection-Id", ""), + self.headers.get("Acp-Session-Id", ""), self.headers.get("Upgrade", ""), extra)) + + def reply(self, status, body=b"", ctype="text/plain", headers=None): + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (headers or {}).items(): + self.send_header(k, v) + self.end_headers() + if body: + self.wfile.write(body) + self.log_line(status) + + def conn(self): + cid = self.headers.get("Acp-Connection-Id") + if not cid: + self.reply(400, b"Acp-Connection-Id header required") + return None + c = conns.get(cid) + if c is None: + self.reply(404) + return c + + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(n).decode("utf-8", "replace") + m = parse(body) + if isinstance(m, dict) and m.get("method") == "initialize" and "Acp-Connection-Id" not in self.headers: + cid = "raw-conn-%s" % os.urandom(6).hex() + hc = HttpConn(cid) + agent = AgentConn(hc, "http", target) + first = [] + hc_send = hc.send + hc.send = lambda text, session=None: first.append(text) + agent.on_request(m) + hc.send = hc_send + conns[cid] = hc + threading.Thread(target=agent.serve, daemon=True).start() + return self.reply(200, first[-1].encode(), "application/json", {"Acp-Connection-Id": cid}) + c = self.conn() + if c is None: + return + c.q.put(m) + self.reply(202) + + def do_DELETE(self): + c = self.conn() + if c is None: + return + conns.pop(c.id, None) + c.close() + self.reply(202) + + def do_GET(self): + if self.headers.get("Upgrade", "").lower() == "websocket": + return self.websocket() + if "text/event-stream" not in self.headers.get("Accept", ""): + return self.reply(406, b"client must accept text/event-stream") + c = self.conn() + if c is None: + return + session = self.headers.get("Acp-Session-Id") or None + q = c.stream(session) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + self.log_line(200, " stream=%s" % (session or "connection")) + self.close_connection = True + try: + while True: + try: + text = q.get(timeout=15) + except queue.Empty: + self.wfile.write(b": keep-alive\n\n") + self.wfile.flush() + continue + if text is None: + break + self.wfile.write(b"event: message\ndata: " + text.encode("utf-8") + b"\n\n") + self.wfile.flush() + except OSError: + pass + + def websocket(self): + key = self.headers.get("Sec-WebSocket-Key", "") + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode() + cid = "raw-conn-%s" % os.urandom(6).hex() + self.send_response(101) + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept) + self.send_header("Acp-Connection-Id", cid) + self.end_headers() + self.wfile.flush() + self.log_line(101) + self.close_connection = True + AgentConn(WsChannel(self.connection, self.rfile, client=False), "ws", target).serve() + + return Handler + + +def run_agent_http(port, target, transport): + conns = {} + server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(target, conns)) + server.daemon_threads = True + log("[raw-agent] listening http://127.0.0.1:%d/acp (HTTP and WebSocket)" % server.server_address[1]) + print("READY %d" % server.server_address[1], flush=True) + server.serve_forever() + + +# ===================================================================================== + + +def usage(problem): + log("raw: " + problem) + log("usage: raw.py client --transport stdio (AGENT_CMD) | --transport http|ws --url " + "[--cases a,b] [--target ]") + log(" raw.py agent --transport stdio | --transport http|ws --port [--target ]") + log(" raw.py list client|agent ") + sys.exit(2) + + +def main(argv): + if len(argv) >= 1 and argv[0] == "list": + if len(argv) != 3: + usage("list needs a role and a transport") + ids = client_case_ids(argv[2]) if argv[1] == "client" else agent_case_ids(argv[2]) + print("\n".join(ids)) + return + if not argv or argv[0] not in ("client", "agent"): + usage("the first argument is the role: client or agent") + role, args = argv[0], argv[1:] + opts = {"--transport": None, "--url": None, "--port": "0", "--cases": None, "--target": None} + i = 0 + while i < len(args): + if args[i] not in opts or i + 1 >= len(args): + usage("unknown argument or missing value: %s" % args[i]) + opts[args[i]] = args[i + 1] + i += 2 + transport = opts["--transport"] + if transport not in ALL: + usage("--transport stdio|http|ws is required") + target = opts["--target"] + if role == "client": + if transport == "stdio" and not os.environ.get("AGENT_CMD"): + usage("AGENT_CMD is required for stdio") + if transport != "stdio" and not opts["--url"]: + usage("--url is required for " + transport) + cases = opts["--cases"].split(",") if opts["--cases"] else client_case_ids(transport, evidence=bool(target)) + run_client(transport, opts["--url"], [c.strip() for c in cases if c.strip()], target) + sys.exit(0) + if transport == "stdio": + run_agent_stdio(target) + else: + run_agent_http(int(opts["--port"]), target, transport) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/integration-testing/programs/rust/Cargo.toml b/integration-testing/programs/rust/Cargo.toml index 6c793166..f18f7d26 100644 --- a/integration-testing/programs/rust/Cargo.toml +++ b/integration-testing/programs/rust/Cargo.toml @@ -1,6 +1,8 @@ -# Interop agent and client on the Rust SDK's Streamable HTTP transport. The SDK path points at the -# peer checkout the runner prepares: integration-testing/.cache/peers/rust-sdk is a symlink to -# rust-sdk@, and CARGO_TARGET_DIR is set per ref so switching refs does not clobber builds. +# Interop agents and clients on the Rust SDK. The SDK path points at the peer checkout the runner +# prepares: integration-testing/.cache/peers/rust-sdk is a symlink to rust-sdk@, and +# CARGO_TARGET_DIR is set per ref so switching refs does not clobber builds. +# +# interop-agent / interop-client: the generated cells (steps.json; launch/*.sh, README "Contracts"). [package] name = "acp-interop-rust" version = "0.1.0" @@ -11,17 +13,21 @@ publish = false # Explicit paths: the repository .gitignore ignores every directory named "bin". [[bin]] -name = "rust-agent" -path = "src/rust_agent.rs" +name = "interop-agent" +path = "src/interop_agent.rs" [[bin]] -name = "rust-client" -path = "src/rust_client.rs" +name = "interop-client" +path = "src/interop_client.rs" [dependencies] -agent-client-protocol = { path = "../../.cache/peers/rust-sdk/src/agent-client-protocol" } +# unstable_session_fork: session/fork, the one step of the unstable profile (design 4.7). +agent-client-protocol = { path = "../../.cache/peers/rust-sdk/src/agent-client-protocol", features = ["unstable_session_fork"] } agent-client-protocol-http = { path = "../../.cache/peers/rust-sdk/src/agent-client-protocol-http", features = ["client", "server"] } tokio = { version = "1.52", features = ["full"] } axum = "0.8" tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" +serde = "1" +serde_json = "1" +futures = "0.3" diff --git a/integration-testing/programs/rust/launch/agent.sh b/integration-testing/programs/rust/launch/agent.sh new file mode 100755 index 00000000..2980cdfd --- /dev/null +++ b/integration-testing/programs/rust/launch/agent.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the Rust interop agent: --transport stdio | --transport http|ws --port . +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself: on stdio, +# stdout is the protocol stream. Env: CARGO_TARGET_DIR (where build.sh put the binaries). +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${CARGO_TARGET_DIR:-$here/target}/release/interop-agent" "$@" diff --git a/integration-testing/programs/rust/launch/build.sh b/integration-testing/programs/rust/launch/build.sh new file mode 100755 index 00000000..c8247402 --- /dev/null +++ b/integration-testing/programs/rust/launch/build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Builds the Rust interop programs against the peer checkout under test. +# Launcher contract: integration-testing/README.md, "Contracts". Env: RUST_SDK (the checkout), +# CARGO_TARGET_DIR (per peer ref). Cargo.toml reaches the SDK through .cache/peers/rust-sdk, the +# symlink the runner points at RUST_SDK's ref; a mismatch is an error, not a silent wrong build. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +link="$here/../../.cache/peers/rust-sdk" +if [[ -n "${RUST_SDK:-}" && "$(cd "$link" && pwd -P)" != "$(cd "$RUST_SDK" && pwd -P)" ]]; then + echo "build.sh: $link does not point at RUST_SDK=$RUST_SDK" >&2 + exit 1 +fi +cd "$here" +exec cargo build --release --quiet --bin interop-agent --bin interop-client diff --git a/integration-testing/programs/rust/launch/client.sh b/integration-testing/programs/rust/launch/client.sh new file mode 100755 index 00000000..f6588a48 --- /dev/null +++ b/integration-testing/programs/rust/launch/client.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Starts the Rust interop client: --transport stdio (env AGENT_CMD) | --transport http|ws --url . +# Env STEPS (required), STEP_TIMEOUT_MS (default 15000), CARGO_TARGET_DIR (where build.sh put the +# binaries). Launcher contract: integration-testing/README.md, "Contracts". +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +exec "${CARGO_TARGET_DIR:-$here/target}/release/interop-client" "$@" diff --git a/integration-testing/programs/rust/src/common.rs b/integration-testing/programs/rust/src/common.rs new file mode 100644 index 00000000..7add8e77 --- /dev/null +++ b/integration-testing/programs/rust/src/common.rs @@ -0,0 +1,150 @@ +//! Shared by the interop agent and client: the fixtures of steps.json (hard-coded, as the +//! contract asks), JSON helpers and the STEP line format. +#![allow(dead_code)] + +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use std::time::Instant; + +pub const META_KEY: &str = "interop"; +pub const META_VALUE: &str = "m1"; +pub const FS_READ_CONTENT: &str = "line1\nline2\nline3\n"; +pub const EXT_METHOD: &str = "_interop/ping"; +pub const EXT_NOTIFICATION: &str = "_interop/note"; + +/// Deserializes a fixture into a typed schema value; the fixtures are constants, so a failure +/// is a program bug. +pub fn typed(v: Value) -> T { + let text = v.to_string(); + serde_json::from_value(v).unwrap_or_else(|e| panic!("fixture does not fit the schema type: {e}: {text}")) +} + +pub fn to_json(t: &T) -> Value { + serde_json::to_value(t).unwrap_or(Value::Null) +} + +/// `{"interop": "m1"}` +pub fn meta() -> serde_json::Map { + let mut m = serde_json::Map::new(); + m.insert(META_KEY.into(), Value::String(META_VALUE.into())); + m +} + +pub fn has_meta(v: &Value) -> bool { + v.get("_meta").and_then(|m| m.get(META_KEY)).and_then(Value::as_str) == Some(META_VALUE) +} + +/// fixtures.client.clientCapabilities: everything the Rust client implements. +pub fn client_capabilities() -> Value { + json!({ + "fs": { "readTextFile": true, "writeTextFile": true }, + "terminal": true, + "elicitation": { "form": {}, "url": {} }, + "auth": { "terminal": true }, + "session": { "configOptions": { "boolean": {} } } + }) +} + +pub fn agent_capabilities() -> Value { + json!({ + "loadSession": true, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + "sessionCapabilities": { "list": {}, "resume": {}, "close": {}, "delete": {} }, + "auth": { "logout": {} } + }) +} + +pub fn auth_method() -> Value { + json!({ "id": "interop-auth", "name": "Interop auth", "description": "Accepts any authenticate call" }) +} + +pub fn terminal_auth_method() -> Value { + json!({ "type": "terminal", "id": "interop-terminal-auth", "name": "Interop terminal auth", "args": ["--login"] }) +} + +pub fn modes(current: &str) -> Value { + json!({ + "currentModeId": current, + "availableModes": [ + { "id": "interop-mode-a", "name": "Mode A" }, + { "id": "interop-mode-b", "name": "Mode B" } + ] + }) +} + +/// fixtures.configOptions with the given values; `verbose` only when the client advertised +/// session.configOptions.boolean. +pub fn config_options(model: &str, verbose: Option) -> Value { + let mut list = vec![json!({ + "id": "model", "name": "Model", "type": "select", "currentValue": model, + "options": [ { "value": "model-a", "name": "Model A" }, { "value": "model-b", "name": "Model B" } ] + })]; + if let Some(v) = verbose { + list.push(json!({ "id": "verbose", "name": "Verbose", "type": "boolean", "currentValue": v })); + } + Value::Array(list) +} + +pub fn permission_tool_call() -> Value { + json!({ "toolCallId": "perm-1", "title": "interop permission", "kind": "edit", "status": "pending" }) +} + +pub fn permission_options() -> Value { + json!([ + { "optionId": "allow", "name": "Allow", "kind": "allow_once" }, + { "optionId": "reject", "name": "Reject", "kind": "reject_once" } + ]) +} + +/// fixtures.emit. as a complete `update` object (with its sessionUpdate tag). +pub fn emit(kind: &str, name: Option<&str>, verbose: Option) -> Option { + let mut v = match kind { + "user_message_chunk" => json!({ "content": { "type": "text", "text": "user-chunk" } }), + "agent_thought_chunk" => json!({ "content": { "type": "text", "text": "thinking" } }), + "tool_call" => json!({ "toolCallId": "call-1", "title": "interop tool", "kind": "read", "status": "pending" }), + "tool_call_update" => json!({ "toolCallId": "call-1", "status": "completed", + "content": [ { "type": "content", "content": { "type": "text", "text": "tool output" } } ] }), + "plan" => json!({ "entries": [ + { "content": "step one", "priority": "high", "status": "pending" }, + { "content": "step two", "priority": "low", "status": "completed" } ] }), + "available_commands_update" => json!({ "availableCommands": [ + { "name": "interop", "description": "interop command", "input": { "hint": "args" } } ] }), + "current_mode_update" => json!({ "currentModeId": "interop-mode-b" }), + "config_option_update" => json!({ "configOptions": config_options("model-b", verbose) }), + "session_info_update" => json!({ "title": "interop title" }), + "usage_update" => json!({ "used": 100, "size": 1000, "cost": { "amount": 0.01, "currency": "USD" } }), + "unknown" => return Some(json!({ "sessionUpdate": "interop_future_update", "payload": { "x": 1 } })), + _ => return None, + }; + if kind == "tool_call" + && let Some(n) = name + { + v["name"] = Value::String(n.into()); + } + v["sessionUpdate"] = Value::String(kind.into()); + Some(v) +} + +pub fn elicitation_schema() -> Value { + json!({ "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] }) +} + +pub fn ms(t0: Instant) -> u128 { + t0.elapsed().as_millis() +} + +/// One line, never empty. +pub fn one_line(s: &str) -> String { + let s: String = s.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c }).collect(); + if s.chars().count() > 300 { format!("{}... ({} chars)", s.chars().take(300).collect::(), s.len()) } else { s } +} + +/// A JSON-RPC error's code, from the SDK error. +pub fn code_of(e: &agent_client_protocol::Error) -> i64 { + to_json(e).get("code").and_then(Value::as_i64).unwrap_or(0) +} + +pub fn describe(e: &agent_client_protocol::Error) -> String { + one_line(&format!("error {} {}", code_of(e), to_json(e))) +} diff --git a/integration-testing/programs/rust/src/interop_agent.rs b/integration-testing/programs/rust/src/interop_agent.rs new file mode 100644 index 00000000..b851affa --- /dev/null +++ b/integration-testing/programs/rust/src/interop_agent.rs @@ -0,0 +1,976 @@ +//! The Rust interop agent of the step catalogue (integration-testing/steps.json), driven by +//! directives in the prompt text. One binary serves every scenario. +//! +//! interop-agent --transport stdio ACP on stdin/stdout; exits when stdin ends +//! interop-agent --transport http|ws --port

    Streamable HTTP and WebSocket on /acp (one axum +//! server handles both); prints READY +//! +//! Every diagnostic and every `STEP agent.` line goes to stderr (on stdio, stdout is the +//! protocol stream). Sessions live in one process-wide store, so a session created on one HTTP +//! connection can be loaded on another (http.reconnect). +mod common; + +use agent_client_protocol::schema::v1::*; +use agent_client_protocol::{Agent, Client, ConnectTo, ConnectionTo, Dispatch, Handled, Responder, UntypedMessage}; +use common::*; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering::SeqCst}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; +use tokio::sync::watch; + +/// Why a running turn was asked to stop. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Stop { + Running, + Cancel, + Close, +} + +struct Session { + cwd: String, + /// Plain-text turns: (prompt text, agent chunks), replayed on session/load. + history: Vec<(String, Vec)>, + closed: bool, + running: usize, + stop: watch::Sender<(u64, Stop)>, + mode: String, + model: String, + verbose: bool, + /// Whether #config grouped added fixtures.groupedConfigOption (config.grouped), and its value. + grouped: bool, + effort: String, +} + +impl Session { + fn new(cwd: String) -> Self { + Session { + cwd, + history: Vec::new(), + closed: false, + running: 0, + stop: watch::channel((0, Stop::Running)).0, + mode: "interop-mode-a".into(), + model: "model-a".into(), + verbose: false, + grouped: false, + effort: "effort-low".into(), + } + } + + fn signal(&self, why: Stop) { + self.stop.send_modify(|(n, s)| { + *n += 1; + *s = why; + }); + } +} + +static SESSIONS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); + +/// Per-connection state. +#[derive(Default)] +struct Conn { + /// The clientCapabilities of the initialize request, as received. + caps: Mutex>, + last_ext_notification: Mutex>, +} + +impl Conn { + fn cap(&self, path: &[&str]) -> bool { + let caps = self.caps.lock().unwrap(); + let mut v = match caps.as_ref() { + Some(v) => v, + None => return false, + }; + for p in path { + match v.get(p) { + Some(x) => v = x, + None => return false, + } + } + !(v.is_null() || *v == Value::Bool(false)) + } + + fn boolean_options(&self) -> Option { + if self.cap(&["session", "configOptions", "boolean"]) { Some(false) } else { None } + } +} + +fn log(s: &str) { + eprintln!("{s}"); +} + +/// `STEP agent. PASS|FAIL ( ms) -> ` on stderr. +fn step(id: &str, pass: bool, t0: Instant, detail: &str) { + eprintln!("STEP agent.{id} {} ({} ms) -> {}", if pass { "PASS" } else { "FAIL" }, ms(t0), one_line(detail)); +} + +fn invalid(msg: impl Into) -> agent_client_protocol::Error { + agent_client_protocol::Error::new(-32602, msg) +} + +fn session_options(sid: &str, conn: &Conn) -> (Value, Value) { + let sessions = SESSIONS.lock().unwrap(); + let s = sessions.get(sid); + let mode = s.map(|s| s.mode.clone()).unwrap_or_else(|| "interop-mode-a".into()); + let model = s.map(|s| s.model.clone()).unwrap_or_else(|| "model-a".into()); + let verbose = conn.boolean_options().map(|_| s.map(|s| s.verbose).unwrap_or(false)); + let mut options = config_options(&model, verbose); + if let Some(s) = s.filter(|s| s.grouped) + && let Value::Array(list) = &mut options + { + list.push(grouped_config_option(&s.effort)); + } + (modes(&mode), options) +} + +/// fixtures.groupedConfigOption: the select effort, its options in the groups fast and deep. +fn grouped_config_option(effort: &str) -> Value { + json!({ "id": "effort", "name": "Effort", "type": "select", "currentValue": effort, + "options": [ + { "group": "fast", "name": "Fast", "options": [ { "value": "effort-low", "name": "Low" } ] }, + { "group": "deep", "name": "Deep", "options": [ + { "value": "effort-medium", "name": "Medium" }, { "value": "effort-high", "name": "High" } ] } + ] }) +} + +const EFFORT_VALUES: [&str; 3] = ["effort-low", "effort-medium", "effort-high"]; + +#[derive(Clone)] +struct InteropAgent; + +impl ConnectTo for InteropAgent { + async fn connect_to(self, client: impl ConnectTo) -> Result<(), agent_client_protocol::Error> { + let conn = Arc::new(Conn::default()); + let c_init = conn.clone(); + let c_new = conn.clone(); + let c_load = conn.clone(); + let c_resume = conn.clone(); + let c_fork = conn.clone(); + let c_cfg = conn.clone(); + let c_prompt = conn.clone(); + let c_ext = conn.clone(); + Agent + .builder() + .name("interop-rust-agent") + .on_receive_request( + async move |req: InitializeRequest, responder: Responder, _cx| { + let caps = to_json(&req.client_capabilities); + log(&format!("[agent] initialize clientCapabilities={caps}")); + *c_init.caps.lock().unwrap() = Some(caps); + let mut methods = vec![auth_method()]; + if c_init.cap(&["auth", "terminal"]) { + methods.push(terminal_auth_method()); + } + let resp: InitializeResponse = typed(json!({ + "protocolVersion": 1, + "agentCapabilities": agent_capabilities(), + "authMethods": methods, + "agentInfo": { "name": "interop-rust-agent", "version": "1" } + })); + let _ = responder.respond(resp); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: AuthenticateRequest, responder: Responder, _cx| { + let t0 = Instant::now(); + let id = req.method_id.to_string(); + step("auth.authenticate", id == "interop-auth", t0, &format!("methodId {id}")); + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |_req: LogoutRequest, responder: Responder, _cx| { + log("[agent] logout"); + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: NewSessionRequest, responder: Responder, _cx| { + let id = format!("rust-sess-{}", NEXT_SESSION.fetch_add(1, SeqCst)); + let cwd = req.cwd.display().to_string(); + log(&format!("[agent] session/new {id} cwd={cwd}")); + SESSIONS.lock().unwrap().insert(id.clone(), Session::new(cwd)); + let (modes, options) = session_options(&id, &c_new); + let _ = responder.respond(typed(json!({ "sessionId": id, "modes": modes, "configOptions": options }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: LoadSessionRequest, responder: Responder, cx: ConnectionTo| { + let sid = req.session_id.to_string(); + let history = { + let sessions = SESSIONS.lock().unwrap(); + match sessions.get(&sid) { + Some(s) if !s.closed => Some(s.history.clone()), + _ => None, + } + }; + let Some(history) = history else { + log(&format!("[agent] session/load {sid}: unknown")); + let _ = responder.respond_with_error(invalid(format!("unknown session {sid}"))); + return Ok(()); + }; + log(&format!("[agent] session/load {sid}: replaying {} turn(s)", history.len())); + for (prompt, chunks) in history { + let _ = cx.send_notification(notification(&sid, json!({ + "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": prompt } }))); + for c in chunks { + let _ = cx.send_notification(chunk(&sid, &c)); + } + } + let (modes, options) = session_options(&sid, &c_load); + let _ = responder.respond(typed(json!({ "modes": modes, "configOptions": options }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: ResumeSessionRequest, responder: Responder, _cx| { + let sid = req.session_id.to_string(); + let known = SESSIONS.lock().unwrap().get(&sid).is_some_and(|s| !s.closed); + log(&format!("[agent] session/resume {sid} known={known}")); + if !known { + let _ = responder.respond_with_error(invalid(format!("unknown session {sid}"))); + return Ok(()); + } + let (modes, options) = session_options(&sid, &c_resume); + let _ = responder.respond(typed(json!({ "modes": modes, "configOptions": options }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: ForkSessionRequest, responder: Responder, _cx| { + let sid = req.session_id.to_string(); + let id = format!("rust-sess-{}", NEXT_SESSION.fetch_add(1, SeqCst)); + let forked = { + let mut sessions = SESSIONS.lock().unwrap(); + let history = sessions.get(&sid).filter(|s| !s.closed).map(|s| s.history.clone()); + history.map(|h| { + let mut s = Session::new(req.cwd.display().to_string()); + s.history = h; + sessions.insert(id.clone(), s); + }) + }; + log(&format!("[agent] session/fork {sid} -> {id} ok={}", forked.is_some())); + if forked.is_none() { + let _ = responder.respond_with_error(invalid(format!("unknown session {sid}"))); + return Ok(()); + } + let (modes, options) = session_options(&id, &c_fork); + let _ = responder.respond(typed(json!({ "sessionId": id, "modes": modes, "configOptions": options }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: ListSessionsRequest, responder: Responder, _cx| { + // Pages of two, so a client that follows nextCursor is exercised. + let cwd = req.cwd.map(|c| c.display().to_string()); + let mut all: Vec<(u64, String, String)> = SESSIONS + .lock() + .unwrap() + .iter() + .filter(|(_, s)| !s.closed && cwd.as_ref().is_none_or(|c| *c == s.cwd)) + .map(|(id, s)| (id.trim_start_matches("rust-sess-").parse().unwrap_or(0), id.clone(), s.cwd.clone())) + .collect(); + all.sort(); + let start: usize = req.cursor.as_deref().and_then(|c| c.parse().ok()).unwrap_or(0); + let page: Vec = all.iter().skip(start).take(2) + .map(|(_, id, cwd)| json!({ "sessionId": id, "cwd": cwd })).collect(); + let next = if start + 2 < all.len() { Some((start + 2).to_string()) } else { None }; + log(&format!("[agent] session/list cwd={cwd:?} cursor={:?} -> {} of {}", req.cursor, page.len(), all.len())); + let _ = responder.respond(typed(json!({ "sessions": page, "nextCursor": next }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: CloseSessionRequest, responder: Responder, _cx| { + let t0 = Instant::now(); + let sid = req.session_id.to_string(); + let (known, running) = { + let mut sessions = SESSIONS.lock().unwrap(); + match sessions.get_mut(&sid) { + Some(s) => { + s.closed = true; + let running = s.running; + if running > 0 { + s.signal(Stop::Close); + } + (true, running) + } + None => (false, 0), + } + }; + step("session.close", known && running > 0, t0, + &format!("close of {sid}: known={known}, running turns cancelled={running}")); + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: DeleteSessionRequest, responder: Responder, _cx| { + let sid = req.session_id.to_string(); + let removed = SESSIONS.lock().unwrap().remove(&sid); + if let Some(s) = removed { + s.signal(Stop::Close); + } + log(&format!("[agent] session/delete {sid}")); + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: SetSessionModeRequest, responder: Responder, _cx| { + let t0 = Instant::now(); + let sid = req.session_id.to_string(); + let mode = req.mode_id.to_string(); + let known_mode = mode == "interop-mode-a" || mode == "interop-mode-b"; + let known = { + let mut sessions = SESSIONS.lock().unwrap(); + match sessions.get_mut(&sid) { + Some(s) if known_mode => { + s.mode = mode.clone(); + true + } + Some(_) => false, + None => false, + } + }; + step("mode.set", known && mode == "interop-mode-b", t0, &format!("set_mode {sid} {mode}")); + if known { + let _ = responder.respond(typed(json!({}))); + } else { + let _ = responder.respond_with_error(invalid(format!("unknown session or mode {sid} {mode}"))); + } + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: SetSessionConfigOptionRequest, responder: Responder, _cx| { + let sid = req.session_id.to_string(); + let id = req.config_id.to_string(); + let value = to_json(&req.value); + log(&format!("[agent] set_config_option {sid} {id} {value}")); + let result = { + let mut sessions = SESSIONS.lock().unwrap(); + match sessions.get_mut(&sid) { + None => Err(format!("unknown session {sid}")), + Some(s) => match (id.as_str(), &value["value"]) { + ("model", Value::String(v)) if v == "model-a" || v == "model-b" => { + s.model = v.clone(); + Ok(()) + } + ("effort", Value::String(v)) if s.grouped && EFFORT_VALUES.contains(&v.as_str()) => { + s.effort = v.clone(); + step("config.grouped", v == "effort-high", Instant::now(), &format!("effort set to {v}")); + Ok(()) + } + ("verbose", Value::Bool(b)) if c_cfg.boolean_options().is_some() => { + s.verbose = *b; + Ok(()) + } + _ => Err(format!("unknown option or value {id} {value}")), + }, + } + }; + match result { + Ok(()) => { + let (_, options) = session_options(&sid, &c_cfg); + let _ = responder.respond(typed(json!({ "configOptions": options }))); + } + Err(e) => { + let _ = responder.respond_with_error(invalid(e)); + } + } + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |n: CancelNotification, _cx| { + let sid = n.session_id.to_string(); + let sessions = SESSIONS.lock().unwrap(); + match sessions.get(&sid) { + Some(s) => { + log(&format!("[agent] session/cancel {sid} running={}", s.running)); + s.signal(Stop::Cancel); + } + None => log(&format!("[agent] session/cancel {sid}: unknown session")), + } + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |req: PromptRequest, responder: Responder, cx: ConnectionTo| { + let conn = c_prompt.clone(); + let cx2 = cx.clone(); + // Answered as JSON: #enum stop answers a stopReason the typed StopReason cannot hold. + let responder = responder.erase_to_json(); + cx.spawn(async move { + let sid = req.session_id.to_string(); + let cancellation = responder.cancellation(); + let result = prompt(&cx2, &conn, req, cancellation).await; + match result { + Some(Ok(resp)) => { + let _ = responder.respond(resp); + } + Some(Err(e)) => { + log(&format!("[agent] prompt on {sid} failed: {}", describe(&e))); + let _ = responder.respond_with_error(e); + } + None => { + // #hang: never answer. + std::future::pending::<()>().await; + } + } + Ok(()) + })?; + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_dispatch( + async move |d: Dispatch, _cx: ConnectionTo| -> Result, agent_client_protocol::Error> { + match d { + Dispatch::Request(req, responder) if req.method() == EXT_METHOD => { + log(&format!("[agent] extension request {} {}", req.method(), req.params())); + let _ = responder.respond(json!({ "pong": 1 })); + Ok(Handled::Yes) + } + Dispatch::Notification(n) if n.method().starts_with('_') => { + log(&format!("[agent] extension notification {} {}", n.method(), n.params())); + *c_ext.last_ext_notification.lock().unwrap() = Some(n.method().to_string()); + Ok(Handled::Yes) + } + other => Ok(Handled::No { message: other, retry: false }), + } + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(client) + .await + } +} + +fn notification(sid: &str, update: Value) -> SessionNotification { + typed(json!({ "sessionId": sid, "update": update })) +} + +fn chunk(sid: &str, text: &str) -> SessionNotification { + notification(sid, json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": text } })) +} + +fn end(reason: &str) -> Value { + json!({ "stopReason": reason }) +} + +/// Tracks a running turn for session/cancel and session/close. +struct Turn { + sid: String, + rx: watch::Receiver<(u64, Stop)>, +} + +impl Turn { + fn start(sid: &str) -> Result { + let mut sessions = SESSIONS.lock().unwrap(); + match sessions.get_mut(sid) { + Some(s) if !s.closed => { + s.running += 1; + let mut rx = s.stop.subscribe(); + rx.mark_unchanged(); + Ok(Turn { sid: sid.into(), rx }) + } + Some(_) => Err(invalid(format!("session {sid} is closed"))), + None => Err(invalid(format!("unknown session {sid}"))), + } + } + + /// Waits for the next session/cancel or session/close of the session. + async fn stopped(&mut self) -> Stop { + match self.rx.changed().await { + Ok(()) => self.rx.borrow_and_update().1, + Err(_) => { + std::future::pending::<()>().await; + Stop::Running + } + } + } +} + +impl Drop for Turn { + fn drop(&mut self) { + if let Some(s) = SESSIONS.lock().unwrap().get_mut(&self.sid) { + s.running = s.running.saturating_sub(1); + } + } +} + +/// Runs one prompt turn. `None` means never answer (#hang). +async fn prompt( + cx: &ConnectionTo, + conn: &Conn, + req: PromptRequest, + cancellation: agent_client_protocol::RequestCancellation, +) -> Option> { + let sid = req.session_id.to_string(); + let text: String = req + .prompt + .iter() + .find_map(|b| if let ContentBlock::Text(t) = b { Some(t.text.clone()) } else { None }) + .unwrap_or_default(); + log(&format!("[agent] session/prompt {sid} {}", one_line(&text))); + let mut turn = match Turn::start(&sid) { + Ok(t) => t, + Err(e) => return Some(Err(e)), + }; + let send = |t: &str| { + let _ = cx.send_notification(chunk(&sid, t)); + }; + if !text.starts_with('#') { + send("echo: "); + send(&text); + if let Some(s) = SESSIONS.lock().unwrap().get_mut(&sid) { + s.history.push((text.clone(), vec!["echo: ".into(), text.clone()])); + } + return Some(Ok(end("end_turn"))); + } + let mut words = text.splitn(3, ' '); + let name = words.next().unwrap_or("#"); + let arg1 = words.next().unwrap_or(""); + let rest = words.next().unwrap_or(""); + let all_args = text.split_once(' ').map(|x| x.1).unwrap_or(""); + let t0 = Instant::now(); + Some(match (name, arg1) { + // #permission allow: perm.selected; #permission allow meta: meta.permission (_meta on the + // request); #permission hold: perm.cancelled. + ("#permission", "allow") | ("#permission", "hold") if rest.is_empty() || (arg1 == "allow" && rest == "meta") => { + let hold = arg1 == "hold"; + let with_meta = rest == "meta"; + let mut perm = json!({ "sessionId": sid, "toolCall": permission_tool_call(), "options": permission_options() }); + if with_meta { + perm["_meta"] = Value::Object(meta()); + } + let perm: RequestPermissionRequest = typed(perm); + match cx.send_request(perm).block_task().await { + Ok(r) => { + let r = to_json(&r); + let outcome = &r["outcome"]; + let said = match outcome["outcome"].as_str() { + Some("selected") => format!("selected {}", outcome["optionId"].as_str().unwrap_or("?")), + Some(o) => o.to_string(), + None => format!("unparsed {outcome}"), + }; + if hold { + step("perm.cancelled", said == "cancelled", t0, &format!("outcome {said}")); + } else if with_meta { + step("meta.permission", has_meta(&r), t0, + &format!("response _meta {}", r.get("_meta").unwrap_or(&Value::Null))); + } else { + step("perm.selected", said == "selected allow", t0, &format!("outcome {said}")); + } + send(&format!("permission: {said}")); + Ok(end(if hold { "cancelled" } else { "end_turn" })) + } + Err(e) => { + step(if hold { "perm.cancelled" } else if with_meta { "meta.permission" } else { "perm.selected" }, false, t0, &describe(&e)); + send(&format!("permission error {}", code_of(&e))); + Ok(end(if hold { "cancelled" } else { "end_turn" })) + } + } + } + ("#fs", "write") => { + let (path, content) = rest.split_once(' ').unwrap_or((rest, "")); + if !conn.cap(&["fs", "writeTextFile"]) { + step("fs.write", false, t0, "the client did not advertise fs.writeTextFile"); + send("fs write error capability"); + return Some(Ok(end("end_turn"))); + } + let r = cx.send_request::(typed(json!({ "sessionId": sid, "path": path, "content": content }))) + .block_task().await; + match r { + Ok(_) => { + step("fs.write", true, t0, "fs/write_text_file answered without error"); + send("fs write ok"); + } + Err(e) => { + step("fs.write", false, t0, &format!("fs/write_text_file failed: {}", describe(&e))); + send(&format!("fs write error {}", code_of(&e))); + } + } + Ok(end("end_turn")) + } + ("#fs", "read") | ("#fs", "read-range") | ("#fs", "read-missing") => { + let mut parts = rest.split(' '); + let path = parts.next().unwrap_or(""); + let mut params = json!({ "sessionId": sid, "path": path }); + for p in parts { + if let Some((k, v)) = p.split_once('=') + && let Ok(n) = v.parse::() + { + params[k] = json!(n); + } + } + let id = match arg1 { "read-range" => "fs.read-range", "read-missing" => "fs.read-missing", _ => "fs.read" }; + if !conn.cap(&["fs", "readTextFile"]) { + step(id, false, t0, "the client did not advertise fs.readTextFile"); + send("fs read error capability"); + return Some(Ok(end("end_turn"))); + } + match cx.send_request::(typed(params)).block_task().await { + Ok(r) => { + match id { + "fs.read-missing" => step(id, false, t0, "fs/read_text_file of a missing file succeeded"), + "fs.read-range" => step(id, r.content.trim() == "line2", t0, &format!("content {:?}", r.content)), + _ => step(id, r.content == FS_READ_CONTENT, t0, &format!("content {:?}", r.content)), + } + send(&r.content); + } + Err(e) => { + step(id, id == "fs.read-missing", t0, &format!("fs/read_text_file failed: {}", describe(&e))); + send(&format!("fs read error {}", code_of(&e))); + } + } + Ok(end("end_turn")) + } + ("#fs", "read-slow") => { + let path = rest.split(' ').next().unwrap_or(""); + let sent = cx.send_request::(typed(json!({ "sessionId": sid, "path": path }))); + tokio::time::sleep(Duration::from_millis(200)).await; + let t1 = Instant::now(); + if let Err(e) = sent.cancel() { + log(&format!("[agent] $/cancel_request failed: {}", describe(&e))); + } + send("cancel-request sent"); + match tokio::time::timeout(Duration::from_secs(5), sent.block_task()).await { + Ok(Err(e)) => step("cancel-request.agent", code_of(&e) == -32800, t1, + &format!("fs/read_text_file ended with {}", describe(&e))), + Ok(Ok(r)) => step("cancel-request.agent", false, t1, &format!("fs/read_text_file succeeded: {:?}", r.content)), + Err(_) => step("cancel-request.agent", false, t1, "TIMEOUT: no answer within 5 s of $/cancel_request"), + } + Ok(end("end_turn")) + } + ("#emit", kind) => { + let tool_name = rest.split(' ').find_map(|p| p.strip_prefix("name=")); + let Some(update) = emit(kind, tool_name, conn.boolean_options().map(|_| false)) else { + return Some(Err(invalid(format!("unknown directive: #emit {kind}")))); + }; + if kind == "tool_call_update" { + let _ = cx.send_notification(notification(&sid, emit("tool_call", None, None).unwrap())); + } + if kind == "unknown" { + // The typed SessionUpdate cannot carry an unknown variant: send it untyped. + let raw = UntypedMessage::new("session/update", json!({ "sessionId": sid, "update": update })) + .expect("untyped session/update"); + let _ = cx.send_notification(raw); + send("after-unknown"); + } else { + let _ = cx.send_notification(notification(&sid, update)); + } + Ok(end("end_turn")) + } + ("#stop", reason) => { + send("stop"); + match serde_json::from_value::(json!(reason)) { + Ok(r) => Ok(json!({ "stopReason": r })), + Err(_) => Err(invalid(format!("unknown stop reason {reason}"))), + } + } + ("#slow", _) => { + let grace = all_args.split(' ').find_map(|p| p.strip_prefix("grace=")).and_then(|g| g.parse::().ok()).unwrap_or(0); + let deadline = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(deadline); + let mut tick = tokio::time::interval(Duration::from_millis(100)); + let why = loop { + tokio::select! { + _ = tick.tick() => send("tick"), + w = turn.stopped() => break Some(w), + _ = cancellation.cancelled() => break None, + _ = &mut deadline => return Some(Ok(end("end_turn"))), + } + }; + match why { + Some(Stop::Cancel) => step("cancel.prompt", true, t0, &format!("session/cancel arrived for {sid}")), + Some(_) => log(&format!("[agent] #slow on {sid} stopped by session/close")), + None => step("cancel-request.client", true, t0, "$/cancel_request observed for the prompt"), + } + let until = Instant::now() + Duration::from_millis(grace); + while Instant::now() < until { + send("tick"); + tokio::time::sleep(Duration::from_millis(100).min(until - Instant::now())).await; + } + Ok(end("cancelled")) + } + ("#hang", _) => return None, + // Values the v1 schema does not define: the typed ToolCallStatus, PlanEntryPriority, + // PlanEntryStatus and StopReason are closed enums, so these go out untyped. + ("#enum", what @ ("tool_call" | "plan")) => { + let update = if what == "tool_call" { + json!({ "sessionUpdate": "tool_call", "toolCallId": "call-enum", "title": "interop enum tool", + "kind": "interop_future_kind", "status": "interop_future_status" }) + } else { + json!({ "sessionUpdate": "plan", "entries": [ { "content": "future entry", + "priority": "interop_future_priority", "status": "interop_future_status" } ] }) + }; + let raw = UntypedMessage::new("session/update", json!({ "sessionId": sid, "update": update })) + .expect("untyped session/update"); + let _ = cx.send_notification(raw); + send("after-enum"); + Ok(end("end_turn")) + } + ("#enum", "stop") => { + send("stop"); + Ok(end("interop_future_stop")) + } + ("#enum", "audience") => { + // Reached only if the SDK parsed the PromptRequest, i.e. accepted the unknown Role. + let first = req.prompt.first().map(to_json).unwrap_or(Value::Null); + let audience: Option> = first["annotations"]["audience"] + .as_array() + .map(|a| a.iter().map(|r| r.as_str().unwrap_or("?").to_string()).collect()); + let ok = audience.as_deref() == Some(&["user".to_string(), "interop_future_role".to_string()][..]); + step("enum.audience", ok, t0, &format!("audience {audience:?}")); + send(&format!("audience: {}", audience.map(|a| a.join(",")).unwrap_or_else(|| "none".into()))); + Ok(end("end_turn")) + } + ("#config", "grouped") => { + if let Some(s) = SESSIONS.lock().unwrap().get_mut(&sid) { + s.grouped = true; + } + let (_, options) = session_options(&sid, conn); + let _ = cx.send_notification(notification(&sid, json!({ "sessionUpdate": "config_option_update", "configOptions": options }))); + Ok(end("end_turn")) + } + ("#terminal", "run") | ("#terminal", "kill") => { + let kill = arg1 == "kill"; + let id = if kill { "term.kill" } else { "term.run" }; + let mut cmd = rest.split(' ').filter(|s| !s.is_empty()).map(String::from); + let command = cmd.next().unwrap_or_default(); + let args: Vec = cmd.collect(); + if !conn.cap(&["terminal"]) { + step(id, false, t0, "the client did not advertise terminal"); + send("terminal error capability"); + return Some(Ok(end("end_turn"))); + } + Ok(match terminal(cx, &sid, &command, args, kill, t0).await { + Ok(chunk_text) => { + send(&chunk_text); + end("end_turn") + } + Err(e) => { + step(id, false, t0, &describe(&e)); + send(&format!("terminal error {}", code_of(&e))); + end("end_turn") + } + }) + } + ("#elicit", mode @ ("form" | "url")) => { + let need = if mode == "form" { "form" } else { "url" }; + if !conn.cap(&["elicitation", need]) { + if mode == "form" { + step("elicit.form", false, t0, "the client did not advertise elicitation.form"); + } + send("elicit error capability"); + return Some(Ok(end("end_turn"))); + } + let req: CreateElicitationRequest = if mode == "form" { + typed(json!({ "mode": "form", "sessionId": sid, "message": "interop form", "requestedSchema": elicitation_schema() })) + } else { + typed(json!({ "mode": "url", "sessionId": sid, "elicitationId": "elic-1", + "url": "https://example.invalid/interop", "message": "interop url" })) + }; + match cx.send_request(req).block_task().await { + Ok(r) => { + let r = to_json(&r); + let action = r["action"].as_str().unwrap_or("?").to_string(); + let content = r.get("content").cloned().unwrap_or(Value::Null); + if mode == "form" { + step("elicit.form", action == "accept" && content["name"] == "interop", t0, &format!("response {r}")); + send(&format!("elicit: {action} {content}")); + } else { + let done: CompleteElicitationNotification = typed(json!({ "elicitationId": "elic-1" })); + let _ = cx.send_notification(done); + send("elicit url done"); + } + } + Err(e) => { + if mode == "form" { + step("elicit.form", false, t0, &describe(&e)); + } + send(&format!("elicit error {}", code_of(&e))); + } + } + Ok(end("end_turn")) + } + ("#ext", "request") => { + let method = rest.split(' ').next().unwrap_or(EXT_METHOD); + let req = UntypedMessage::new(method, json!({ "n": 1 })).expect("untyped request"); + match cx.send_request(req).block_task().await { + Ok(r) => { + step("ext.agent-request", r == json!({ "pong": 1 }), t0, &format!("result {r}")); + send(&format!("ext: {r}")); + } + Err(e) => { + step("ext.agent-request", false, t0, &describe(&e)); + send(&format!("ext error {}", code_of(&e))); + } + } + Ok(end("end_turn")) + } + ("#ext", "notify") => { + let method = rest.split(' ').next().unwrap_or(EXT_NOTIFICATION); + let n = UntypedMessage::new(method, json!({ "n": 1 })).expect("untyped notification"); + let _ = cx.send_notification(n); + send("ext notified"); + Ok(end("end_turn")) + } + ("#ext", "last-notification") => { + let last = conn.last_ext_notification.lock().unwrap().clone().unwrap_or_else(|| "none".into()); + send(&format!("ext last: {last}")); + Ok(end("end_turn")) + } + ("#meta", _) => { + let m = req.meta.clone().map(Value::Object).unwrap_or(Value::Null); + let mut update = json!({ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "meta" } }); + let mut resp = json!({ "stopReason": "end_turn" }); + if !m.is_null() { + update["_meta"] = m.clone(); + resp["_meta"] = m; + } + let _ = cx.send_notification(notification(&sid, update)); + Ok(resp) + } + ("#echo-caps", _) => { + let caps = conn.caps.lock().unwrap().clone(); + step("init.client-capabilities", caps.is_some(), t0, &format!("clientCapabilities {}", caps.clone().unwrap_or(Value::Null))); + send(&caps.unwrap_or(Value::Null).to_string()); + Ok(end("end_turn")) + } + ("#len", _) => { + send(&format!("len={}", all_args.chars().count())); + Ok(end("end_turn")) + } + ("#big", n) => match n.parse::() { + Ok(n) => { + send(&"x".repeat(n)); + Ok(end("end_turn")) + } + Err(_) => Err(invalid(format!("bad size {n}"))), + }, + _ => Err(invalid(format!("unknown directive: {name}"))), + }) +} + +async fn terminal( + cx: &ConnectionTo, + sid: &str, + command: &str, + args: Vec, + kill: bool, + t0: Instant, +) -> Result { + let created = cx + .send_request::(typed(json!({ "sessionId": sid, "command": command, "args": args }))) + .block_task() + .await?; + let tid = created.terminal_id.to_string(); + let ids = json!({ "sessionId": sid, "terminalId": tid }); + if kill { + tokio::time::sleep(Duration::from_millis(200)).await; + cx.send_request::(typed(ids.clone())).block_task().await?; + let t1 = Instant::now(); + let waited = tokio::time::timeout( + Duration::from_secs(5), + cx.send_request::(typed(ids.clone())).block_task(), + ) + .await; + match &waited { + Ok(Ok(w)) => step("term.kill", true, t1, &format!("wait_for_exit returned {} after the kill", to_json(w))), + Ok(Err(e)) => step("term.kill", false, t1, &describe(e)), + Err(_) => step("term.kill", false, t1, "TIMEOUT: wait_for_exit did not return within 5 s of the kill"), + } + let _ = cx.send_request::(typed(ids)).block_task().await; + return Ok("terminal killed".into()); + } + let exit = cx.send_request::(typed(ids.clone())).block_task().await?; + let out = cx.send_request::(typed(ids.clone())).block_task().await?; + let _ = cx.send_request::(typed(ids)).block_task().await; + let code = exit.exit_status.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "none".into()); + step("term.run", out.output.contains("hi") && code == "0", t0, &format!("output {:?} exit={code}", out.output)); + Ok(format!("terminal: {} exit={code}", out.output.trim())) +} + +fn usage(problem: &str) -> ! { + eprintln!("agent: {problem}"); + eprintln!("usage: interop-agent --transport stdio | --transport http|ws --port "); + std::process::exit(2); +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let mut transport = None; + let mut port: u16 = 0; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--transport" => transport = Some(args.next().unwrap_or_else(|| usage("--transport needs a value"))), + "--port" => { + port = args + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or_else(|| usage("--port needs a number")) + } + other => usage(&format!("unknown argument {other}")), + } + } + match transport.as_deref() { + Some("stdio") => { + let r = InteropAgent.connect_to(agent_client_protocol::Stdio::new()).await; + log(&format!("stdin closed; exiting ({r:?})")); + std::process::exit(0); + } + Some("http") | Some("ws") => { + let router = agent_client_protocol_http::AcpHttpServer::new(|| InteropAgent).into_router().layer( + axum::middleware::from_fn(|req: axum::extract::Request, next: axum::middleware::Next| async move { + let (head, tail) = { + let h = |n: &str| req.headers().get(n).and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + (format!("[http] {} {} {:?}", req.method(), req.uri().path(), req.version()), + format!("ct=\"{}\" accept=\"{}\" conn=\"{}\" sess=\"{}\" upgrade=\"{}\"", + h("content-type"), h("accept"), h("acp-connection-id"), h("acp-session-id"), h("upgrade"))) + }; + let resp = next.run(req).await; + eprintln!("{head} -> {} {tail}", resp.status().as_u16()); + resp + }), + ); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; + let bound = listener.local_addr()?.port(); + log(&format!("listening http://127.0.0.1:{bound}/acp (HTTP and WebSocket)")); + println!("READY {bound}"); + axum::serve(listener, router).await?; + Ok(()) + } + Some(t) => usage(&format!("unknown transport {t}")), + None => usage("--transport is required"), + } +} diff --git a/integration-testing/programs/rust/src/interop_client.rs b/integration-testing/programs/rust/src/interop_client.rs new file mode 100644 index 00000000..0569fc60 --- /dev/null +++ b/integration-testing/programs/rust/src/interop_client.rs @@ -0,0 +1,1556 @@ +//! The Rust interop client of the step catalogue (integration-testing/steps.json). Runs the step +//! ids in STEPS (comma separated) in order and prints one `STEP PASS|FAIL ( ms) -> ` +//! line per step, then `RESULT pass=.. fail=.. updates_total=.. upd_=..`. +//! +//! STEPS=... interop-client --transport stdio spawns bash -c "exec $AGENT_CMD" (AcpAgent) +//! STEPS=... interop-client --transport http --url http://127.0.0.1:

    /acp +//! STEPS=... interop-client --transport ws --url ws://127.0.0.1:

    /acp (HttpClient with a ws:// URL) +//! +//! On stdio the agent's stderr is relayed to stdout: lines starting with `STEP ` verbatim, every +//! other line prefixed `agent| `. Exits 0 once RESULT is printed, whatever the step outcomes. +mod common; + +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::schema::v1::*; +use agent_client_protocol::{ + AcpAgent, AcpAgentConfig, Agent, ConnectTo, ConnectionTo, Dispatch, Handled, LineDirection, Responder, UntypedMessage, +}; +use common::*; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::future::Future; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::AsyncReadExt; +use tokio::sync::{oneshot, watch}; + +type AcpError = agent_client_protocol::Error; + +// ------------------------------------------------------------------ shared client state + +struct Term { + output: Arc>, + exit: watch::Receiver, Option)>>, + kill: Arc, +} + +#[derive(Default)] +struct State { + /// Every session/update received on any connection: (sessionId, update as JSON). + updates: Mutex>, + total: AtomicUsize, + kinds: Mutex>, + /// session/request_permission requests: (sessionId, request as JSON). + perms: Mutex>, + /// Sessions whose next permission request is answered by cancelling the turn (perm.cancelled). + cancel_on_perm: Mutex>, + cancel_sent: Mutex>, + ext_notifications: Mutex>, + elicit_complete: Mutex>, + terminals: Mutex>, +} + +static S: LazyLock = LazyLock::new(State::default); +static NEXT_TERMINAL: AtomicUsize = AtomicUsize::new(1); + +struct Config { + transport: String, + url: Option, + agent_cmd: Option, + timeout: Duration, + dir: PathBuf, +} + +static CFG: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn cfg() -> &'static Config { + CFG.get().expect("config") +} + +fn dir() -> String { + cfg().dir.display().to_string() +} + +const UPDATE_GRACE: Duration = Duration::from_millis(1000); +const REPLAY_GRACE: Duration = Duration::from_millis(2000); + +fn record_update(sid: String, update: Value) { + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or("other").to_string(); + S.total.fetch_add(1, SeqCst); + *S.kinds.lock().unwrap().entry(kind).or_default() += 1; + S.updates.lock().unwrap().push((sid, update)); +} + +fn relay(line: &str, dir: LineDirection) { + if dir == LineDirection::Stderr { + if line.starts_with("STEP ") { + println!("{line}"); + } else { + println!("agent| {line}"); + } + } +} + +// ------------------------------------------------------------------ connections + +struct Conn { + cx: ConnectionTo, + close: Option>, + join: tokio::task::JoinHandle>, +} + +impl Conn { + async fn open() -> Result { + let (cx_tx, cx_rx) = oneshot::channel(); + let (close_tx, close_rx) = oneshot::channel(); + let join = if cfg().transport == "stdio" { + let cmd = cfg().agent_cmd.clone().unwrap_or_default(); + let agent = AcpAgent::new(AcpAgentConfig::new("bash").arg("-c").arg(format!("exec {cmd}"))).with_debug(relay); + tokio::spawn(run_conn(agent, cx_tx, close_rx)) + } else { + let url = cfg().url.clone().unwrap_or_default(); + let http = agent_client_protocol_http::HttpClient::with_endpoint(&url).map_err(|e| format!("bad url {url}: {e}"))?; + tokio::spawn(run_conn(http, cx_tx, close_rx)) + }; + match cx_rx.await { + Ok(cx) => Ok(Conn { cx, close: Some(close_tx), join }), + Err(_) => Err(format!("connection failed: {:?}", join.await)), + } + } + + /// The SDK's graceful close: return from connect_with (HTTP: DELETE; WS: close; stdio: end the child). + async fn close(mut self) -> Result { + if let Some(c) = self.close.take() { + let _ = c.send(()); + } + let r = tokio::time::timeout(cfg().timeout, self.join).await; + if cfg().transport == "http" { + // The SDK sends the DELETE from a detached task (HttpConnection::spawn_close, + // agent-client-protocol-http client.rs:525) after connect_with has returned, so nothing + // can await it: give it a moment before the process may exit. + tokio::time::sleep(Duration::from_millis(500)).await; + } + match r { + Ok(Ok(Ok(()))) => Ok("closed".into()), + Ok(Ok(Err(e))) => Err(format!("close failed: {}", describe(&e))), + Ok(Err(e)) => Err(format!("connection task failed: {e}")), + Err(_) => Err("TIMEOUT: the close did not complete".into()), + } + } +} + +async fn run_conn + 'static>( + transport: T, + cx_tx: oneshot::Sender>, + close_rx: oneshot::Receiver<()>, +) -> Result<(), AcpError> { + agent_client_protocol::Client + .builder() + .name("interop-rust-client") + .on_receive_notification( + async move |n: SessionNotification, _cx| { + record_update(n.session_id.to_string(), to_json(&n.update)); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |req: RequestPermissionRequest, responder: Responder, cx: ConnectionTo| { + let j = to_json(&req); + let sid = req.session_id.to_string(); + println!(" permission request on {sid}: {} option(s)", req.options.len()); + S.perms.lock().unwrap().push((sid.clone(), j.clone())); + if S.cancel_on_perm.lock().unwrap().remove(&sid) { + let _ = cx.send_notification::(typed(json!({ "sessionId": sid }))); + S.cancel_sent.lock().unwrap().insert(sid, Instant::now()); + let _ = responder.respond(typed(json!({ "outcome": { "outcome": "cancelled" } }))); + return Ok(()); + } + let options = j["options"].as_array().cloned().unwrap_or_default(); + let chosen = options + .iter() + .find(|o| o["kind"] == "allow_once") + .or(options.first()) + .and_then(|o| o["optionId"].as_str()) + .unwrap_or("none") + .to_string(); + let mut resp = json!({ "outcome": { "outcome": "selected", "optionId": chosen } }); + if let Some(m) = j.get("_meta") { + resp["_meta"] = m.clone(); + } + let _ = responder.respond(typed(resp)); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: WriteTextFileRequest, responder: Responder, _cx| { + println!(" fs/write_text_file {}", req.path.display()); + match std::fs::write(&req.path, &req.content) { + Ok(()) => { + let _ = responder.respond(typed(json!({}))); + } + Err(e) => { + let _ = responder.respond_with_error(AcpError::new(-32603, format!("write failed: {e}"))); + } + } + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: ReadTextFileRequest, responder: Responder, cx: ConnectionTo| { + println!(" fs/read_text_file {} line={:?} limit={:?}", req.path.display(), req.line, req.limit); + if req.path.display().to_string().ends_with("slow.txt") { + // cancel-request.agent: wait up to 10 s or until the agent cancels the request. + let cancellation = responder.cancellation(); + cx.spawn(async move { + let r = cancellation + .run_until_cancelled(async { + tokio::time::sleep(Duration::from_secs(10)).await; + Ok(typed::(json!({ "content": "slow" }))) + }) + .await; + println!(" slow fs/read_text_file ended: {}", if r.is_ok() { "completed" } else { "cancelled" }); + let _ = responder.respond_with_result(r); + Ok(()) + })?; + return Ok(()); + } + match std::fs::read_to_string(&req.path) { + Ok(content) => { + let content = match (req.line, req.limit) { + (None, None) => content, + (line, limit) => { + let skip = line.map(|l| l.saturating_sub(1) as usize).unwrap_or(0); + let take = limit.map(|l| l as usize).unwrap_or(usize::MAX); + content.split_inclusive('\n').skip(skip).take(take).collect() + } + }; + let _ = responder.respond(typed(json!({ "content": content }))); + } + Err(e) => { + let _ = responder.respond_with_error(AcpError::resource_not_found(Some(req.path.display().to_string())) + .data(json!(e.to_string()))); + } + } + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: CreateTerminalRequest, responder: Responder, _cx| { + println!(" terminal/create {} {:?}", req.command, req.args); + let mut cmd = tokio::process::Command::new(&req.command); + cmd.args(&req.args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + if let Some(cwd) = &req.cwd { + cmd.current_dir(cwd); + } + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = responder.respond_with_error(AcpError::new(-32603, format!("spawn failed: {e}"))); + return Ok(()); + } + }; + let output = Arc::new(Mutex::new(String::new())); + for pipe in [child.stdout.take().map(|p| Box::new(p) as Box), + child.stderr.take().map(|p| Box::new(p) as Box)] + .into_iter() + .flatten() + { + let out = output.clone(); + tokio::spawn(async move { + let mut pipe = pipe; + let mut buf = [0u8; 4096]; + while let Ok(n) = pipe.read(&mut buf).await { + if n == 0 { + break; + } + out.lock().unwrap().push_str(&String::from_utf8_lossy(&buf[..n])); + } + }); + } + let (exit_tx, exit_rx) = watch::channel(None); + let kill = Arc::new(tokio::sync::Notify::new()); + let k = kill.clone(); + tokio::spawn(async move { + let status = tokio::select! { + s = child.wait() => s, + _ = k.notified() => { + let _ = child.start_kill(); + child.wait().await + } + }; + let exit = match status { + Ok(s) => { + use std::os::unix::process::ExitStatusExt; + (s.code(), s.signal().map(|n| if n == 9 { "SIGKILL".to_string() } else { format!("signal {n}") })) + } + Err(e) => (None, Some(format!("wait failed: {e}"))), + }; + let _ = exit_tx.send(Some(exit)); + }); + let id = format!("term-{}", NEXT_TERMINAL.fetch_add(1, SeqCst)); + S.terminals.lock().unwrap().insert(id.clone(), Term { output, exit: exit_rx, kill }); + let _ = responder.respond(typed(json!({ "terminalId": id }))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: TerminalOutputRequest, responder: Responder, _cx| { + let tid = req.terminal_id.to_string(); + let resp = S.terminals.lock().unwrap().get(&tid).map(|t| { + let exit = t.exit.borrow().clone(); + let mut r = json!({ "output": t.output.lock().unwrap().clone(), "truncated": false }); + if let Some((code, signal)) = exit { + r["exitStatus"] = json!({ "exitCode": code, "signal": signal }); + } + r + }); + let _ = match resp { + Some(r) => responder.respond(typed(r)), + None => responder.respond_with_error(AcpError::new(-32602, format!("unknown terminal {tid}"))), + }; + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: WaitForTerminalExitRequest, responder: Responder, cx: ConnectionTo| { + let tid = req.terminal_id.to_string(); + let rx = S.terminals.lock().unwrap().get(&tid).map(|t| t.exit.clone()); + let Some(mut rx) = rx else { + let _ = responder.respond_with_error(AcpError::new(-32602, format!("unknown terminal {tid}"))); + return Ok(()); + }; + cx.spawn(async move { + let exit = match rx.wait_for(Option::is_some).await { + Ok(v) => v.clone().unwrap_or((None, None)), + Err(_) => (None, Some("lost".into())), + }; + let _ = responder.respond(typed(json!({ "exitCode": exit.0, "signal": exit.1 }))); + Ok(()) + })?; + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: KillTerminalRequest, responder: Responder, _cx| { + let tid = req.terminal_id.to_string(); + println!(" terminal/kill {tid}"); + if let Some(t) = S.terminals.lock().unwrap().get(&tid) { + t.kill.notify_one(); + } + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: ReleaseTerminalRequest, responder: Responder, _cx| { + let tid = req.terminal_id.to_string(); + if let Some(t) = S.terminals.lock().unwrap().remove(&tid) { + t.kill.notify_one(); + } + let _ = responder.respond(typed(json!({}))); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + async move |req: CreateElicitationRequest, responder: Responder, _cx| { + let j = to_json(&req); + println!(" elicitation/create mode={}", j["mode"]); + let resp = if j["mode"] == "form" { + json!({ "action": "accept", "content": { "name": "interop" } }) + } else { + json!({ "action": "accept" }) + }; + let _ = responder.respond(typed(resp)); + Ok(()) + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + async move |n: CompleteElicitationNotification, _cx| { + println!(" elicitation/complete {}", n.elicitation_id); + S.elicit_complete.lock().unwrap().push(n.elicitation_id.to_string()); + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_dispatch( + async move |d: Dispatch, _cx: ConnectionTo| -> Result, AcpError> { + match d { + Dispatch::Request(req, responder) if req.method() == EXT_METHOD => { + println!(" extension request {} {}", req.method(), req.params()); + let _ = responder.respond(json!({ "pong": 1 })); + Ok(Handled::Yes) + } + Dispatch::Notification(n) if n.method().starts_with('_') => { + println!(" extension notification {} {}", n.method(), n.params()); + S.ext_notifications.lock().unwrap().push((n.method().to_string(), n.params().clone())); + Ok(Handled::Yes) + } + Dispatch::Notification(n) if n.method() == "session/update" => { + // A session/update the typed handler could not parse (a future variant). + println!(" untyped session/update {}", one_line(&n.params().to_string())); + let sid = n.params()["sessionId"].as_str().unwrap_or("").to_string(); + let mut update = n.params()["update"].clone(); + update["sessionUpdate"] = json!("other"); + record_update(sid, update); + Ok(Handled::Yes) + } + other => Ok(Handled::No { message: other, retry: false }), + } + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_with(transport, async move |cx: ConnectionTo| { + let _ = cx_tx.send(cx.clone()); + let _ = close_rx.await; + Ok(()) + }) + .await +} + +// ------------------------------------------------------------------ helpers + +trait Ctx { + fn ctx(self, what: &str) -> Result; +} + +impl Ctx for Result { + fn ctx(self, what: &str) -> Result { + self.map_err(|e| format!("{what}: {}", describe(&e))) + } +} + +fn ensure(cond: bool, msg: impl Into) -> Result<(), String> { + if cond { Ok(()) } else { Err(msg.into()) } +} + +static MAIN: Mutex> = Mutex::new(None); +static INIT: Mutex> = Mutex::new(None); + +fn main_cx() -> Result, String> { + MAIN.lock().unwrap().as_ref().map(|c| c.cx.clone()).ok_or_else(|| "no main connection (init.initialize failed)".into()) +} + +fn init() -> Result { + INIT.lock().unwrap().clone().ok_or_else(|| "no initialize response".into()) +} + +fn init_request() -> InitializeRequest { + typed(json!({ + "protocolVersion": 1, + "clientCapabilities": client_capabilities(), + "clientInfo": { "name": "interop-rust-client", "version": "1" } + })) +} + +async fn initialize(cx: &ConnectionTo) -> Result { + let r = cx.send_request(init_request()).block_task().await.ctx("initialize")?; + let _ = ProtocolVersion::V1; + Ok(to_json(&r)) +} + +async fn new_session_in(cx: &ConnectionTo, cwd: &str) -> Result<(String, Value), String> { + let r = cx + .send_request::(typed(json!({ "cwd": cwd, "mcpServers": [] }))) + .block_task() + .await + .ctx("session/new")?; + let sid = r.session_id.to_string(); + ensure(!sid.is_empty(), "empty sessionId")?; + Ok((sid, to_json(&r))) +} + +async fn new_session(cx: &ConnectionTo) -> Result { + Ok(new_session_in(cx, &dir()).await?.0) +} + +fn prompt_request(sid: &str, text: &str, with_meta: bool) -> PromptRequest { + let mut p = json!({ "sessionId": sid, "prompt": [ { "type": "text", "text": text } ] }); + if with_meta { + p["_meta"] = Value::Object(meta()); + } + typed(p) +} + +async fn prompt(cx: &ConnectionTo, sid: &str, text: &str) -> Result { + let r = cx.send_request(prompt_request(sid, text, false)).block_task().await.ctx("session/prompt")?; + Ok(to_json(&r)) +} + +fn stop_reason(r: &Value) -> String { + r["stopReason"].as_str().unwrap_or("?").to_string() +} + +async fn prompt_end_turn(cx: &ConnectionTo, sid: &str, text: &str) -> Result { + let r = prompt(cx, sid, text).await?; + ensure(stop_reason(&r) == "end_turn", format!("stopReason {}", stop_reason(&r)))?; + Ok(r) +} + +fn updates(sid: &str) -> Vec { + S.updates.lock().unwrap().iter().filter(|(s, _)| s == sid).map(|(_, u)| u.clone()).collect() +} + +fn mark() -> usize { + S.updates.lock().unwrap().len() +} + +fn updates_since(sid: &str, from: usize) -> Vec { + S.updates.lock().unwrap().iter().skip(from).filter(|(s, _)| s == sid).map(|(_, u)| u.clone()).collect() +} + +fn texts(updates: &[Value], kind: &str) -> Vec { + updates + .iter() + .filter(|u| u["sessionUpdate"] == kind) + .filter_map(|u| u["content"]["text"].as_str().map(String::from)) + .collect() +} + +fn agent_text(sid: &str) -> String { + texts(&updates(sid), "agent_message_chunk").concat() +} + +/// Polls `cond` until it holds or `within` elapses. +async fn wait_for(within: Duration, cond: impl Fn() -> bool) -> bool { + let t0 = Instant::now(); + loop { + if cond() { + return true; + } + if t0.elapsed() >= within { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn wait_chunk(sid: &str, within: Duration, pred: impl Fn(&str) -> bool) -> Option { + let t0 = Instant::now(); + loop { + if let Some(t) = texts(&updates(sid), "agent_message_chunk").into_iter().find(|t| pred(t)) { + return Some(t); + } + if t0.elapsed() >= within { + return None; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + + +fn path_present(v: &Value, path: &[&str]) -> bool { + let mut v = v; + for p in path { + match v.get(p) { + Some(x) => v = x, + None => return false, + } + } + !v.is_null() +} + +async fn send_cancel(cx: &ConnectionTo, sid: &str) -> Instant { + let _ = cx.send_notification::(typed(json!({ "sessionId": sid }))); + Instant::now() +} + +// ------------------------------------------------------------------ steps + +async fn init_initialize() -> Result { + let conn = Conn::open().await?; + let r = initialize(&conn.cx).await; + *MAIN.lock().unwrap() = Some(conn); + let r = r?; + *INIT.lock().unwrap() = Some(r.clone()); + ensure(r["protocolVersion"] == 1, format!("protocolVersion {}", r["protocolVersion"]))?; + Ok(format!("protocolVersion=1 agentInfo={}", r["agentInfo"])) +} + +async fn init_agent_capabilities() -> Result { + let c = &init()?["agentCapabilities"]; + ensure(c["loadSession"] == true, format!("loadSession {}", c["loadSession"]))?; + for k in ["list", "resume", "close", "delete"] { + ensure(path_present(c, &["sessionCapabilities", k]), format!("sessionCapabilities.{k} missing: {c}"))?; + } + Ok(one_line(&c.to_string())) +} + +async fn echo_caps() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "#echo-caps").await?; + let text = wait_chunk(&sid, UPDATE_GRACE, |t| t.starts_with('{')).await.ok_or("no capabilities chunk")?; + serde_json::from_str(&text).map_err(|e| format!("chunk is not JSON ({e}): {text}")) +} + +async fn init_client_capabilities() -> Result { + let caps = echo_caps().await?; + ensure(caps["fs"]["readTextFile"] == true && caps["fs"]["writeTextFile"] == true && caps["terminal"] == true, + format!("echoed {caps}"))?; + Ok(one_line(&caps.to_string())) +} + +async fn init_config_boolean() -> Result { + let caps = echo_caps().await?; + ensure(path_present(&caps, &["session", "configOptions", "boolean"]), format!("echoed {caps}"))?; + Ok(one_line(&caps.to_string())) +} + +async fn init_auth_methods() -> Result { + let m = init()?["authMethods"].clone(); + ensure(m.as_array().is_some_and(|a| a.iter().any(|x| x["id"] == "interop-auth")), format!("authMethods {m}"))?; + Ok(one_line(&m.to_string())) +} + +async fn init_agent_info() -> Result { + let i = init()?["agentInfo"].clone(); + ensure(i["name"].as_str().is_some_and(|n| n.starts_with("interop-")), format!("agentInfo {i}"))?; + Ok(i.to_string()) +} + +async fn auth_authenticate() -> Result { + let cx = main_cx()?; + let r = cx + .send_request::(typed(json!({ "methodId": "interop-auth" }))) + .block_task() + .await + .ctx("authenticate")?; + Ok(format!("result {}", to_json(&r))) +} + +async fn auth_logout() -> Result { + let cx = main_cx()?; + let r = cx.send_request::(typed(json!({}))).block_task().await.ctx("logout")?; + Ok(format!("result {}", to_json(&r))) +} + +async fn auth_logout_capability() -> Result { + let c = &init()?["agentCapabilities"]; + ensure(path_present(c, &["auth", "logout"]), format!("agentCapabilities {c}"))?; + Ok("auth.logout present".into()) +} + +async fn auth_terminal() -> Result { + let m = init()?["authMethods"].clone(); + ensure( + m.as_array().is_some_and(|a| a.iter().any(|x| x["type"] == "terminal" && x["id"] == "interop-terminal-auth")), + format!("authMethods {m}"), + )?; + Ok(one_line(&m.to_string())) +} + +async fn session_new() -> Result { + let sid = new_session(&main_cx()?).await?; + Ok(format!("sessionId={sid}")) +} + +async fn load(cx: &ConnectionTo, sid: &str) -> Result { + let r = cx + .send_request::(typed(json!({ "sessionId": sid, "cwd": dir(), "mcpServers": [] }))) + .block_task() + .await + .ctx("session/load")?; + Ok(to_json(&r)) +} + +async fn session_load() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "hello load").await?; + load(&cx, &sid).await?; + let from = mark(); + prompt_end_turn(&cx, &sid, "after load").await?; + let ok = wait_for(UPDATE_GRACE, || { + texts(&updates_since(&sid, from), "agent_message_chunk").iter().any(|t| t.contains("after load")) + }) + .await; + ensure(ok, "no agent_message_chunk for the prompt after the load")?; + Ok(format!("loaded {sid}")) +} + +async fn session_load_replay() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "replay me").await?; + wait_for(UPDATE_GRACE, || agent_text(&sid) == "echo: replay me").await; + let from = mark(); + load(&cx, &sid).await?; + let ok = wait_for(REPLAY_GRACE, || { + let u = updates_since(&sid, from); + texts(&u, "user_message_chunk").iter().any(|t| t == "replay me") + && texts(&u, "agent_message_chunk").iter().any(|t| t.contains("replay me")) + }) + .await; + let kinds: Vec = updates_since(&sid, from).iter().map(|u| u["sessionUpdate"].to_string()).collect(); + ensure(ok, format!("replay incomplete: {kinds:?}"))?; + Ok(format!("replayed {} update(s)", kinds.len())) +} + +async fn session_resume() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "before resume").await?; + wait_for(UPDATE_GRACE, || agent_text(&sid) == "echo: before resume").await; + let from = mark(); + cx.send_request::(typed(json!({ "sessionId": sid, "cwd": dir() }))) + .block_task() + .await + .ctx("session/resume")?; + tokio::time::sleep(UPDATE_GRACE).await; + let n = updates_since(&sid, from).len(); + ensure(n == 0, format!("{n} update(s) arrived after the resume"))?; + prompt_end_turn(&cx, &sid, "after resume").await?; + Ok(format!("resumed {sid}")) +} + +async fn session_list() -> Result { + let cx = main_cx()?; + let cwd = format!("{}/list", dir()); + let _ = std::fs::create_dir_all(&cwd); + let (sid, _) = new_session_in(&cx, &cwd).await?; + let mut cursor: Option = None; + let mut seen = 0; + for page in 1..=10 { + let mut req = json!({ "cwd": cwd }); + if let Some(c) = &cursor { + req["cursor"] = json!(c); + } + let r = cx.send_request::(typed(req)).block_task().await.ctx("session/list")?; + let r = to_json(&r); + let sessions = r["sessions"].as_array().cloned().unwrap_or_default(); + seen += sessions.len(); + if let Some(s) = sessions.iter().find(|s| s["sessionId"] == sid.as_str()) { + ensure(s["cwd"] == cwd.as_str(), format!("listed with cwd {}", s["cwd"]))?; + return Ok(format!("found {sid} on page {page}")); + } + match r["nextCursor"].as_str() { + Some(c) => cursor = Some(c.to_string()), + None => break, + } + } + Err(format!("{sid} not listed ({seen} session(s) seen)")) +} + +async fn session_close() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let slow = cx.send_request(prompt_request(&sid, "#slow", false)); + ensure(wait_chunk(&sid, Duration::from_secs(5), |t| t == "tick").await.is_some(), "no tick")?; + let r = cx.send_request::(typed(json!({ "sessionId": sid }))).block_task().await.ctx("session/close")?; + let r = to_json(&r); + ensure(r.as_object().is_some_and(|o| o.keys().all(|k| k == "_meta")), format!("close answered {r}"))?; + let ended = tokio::time::timeout(Duration::from_secs(5), slow.block_task()).await; + let how = match ended { + Err(_) => return Err("TIMEOUT: the #slow prompt did not end within 5 s of the close".into()), + Ok(Ok(p)) if stop_reason(&to_json(&p)) == "cancelled" => "cancelled".to_string(), + Ok(Ok(p)) => return Err(format!("the #slow prompt answered {}", stop_reason(&to_json(&p)))), + Ok(Err(e)) => describe(&e), + }; + match prompt(&cx, &sid, "after close").await { + Ok(r) => Err(format!("the prompt after close answered {}", stop_reason(&r))), + Err(e) => Ok(format!("#slow ended ({how}); prompt after close failed ({e})")), + } +} + +/// On a connection of its own: the Rust HTTP client ends the whole connection when one POST is +/// answered with an HTTP error (an agent that 404s the unknown session id), which must not take +/// the main connection, and every later step, with it. +async fn session_delete() -> Result { + let conn = Conn::open().await?; + let r = delete_twice(&conn.cx).await; + let closed = conn.close().await; + let r = r?; + closed.map_err(|e| format!("both deletes answered, then {e}"))?; + Ok(r) +} + +async fn delete_twice(cx: &ConnectionTo) -> Result { + initialize(cx).await?; + let sid = new_session(cx).await?; + cx.send_request::(typed(json!({ "sessionId": sid }))).block_task().await.ctx("session/delete")?; + cx.send_request::(typed(json!({ "sessionId": "no-such-session" }))) + .block_task() + .await + .ctx("session/delete of an unknown id")?; + Ok("both deletes answered".into()) +} + +async fn session_multi() -> Result { + let cx = main_cx()?; + let a = new_session(&cx).await?; + let b = new_session(&cx).await?; + let (ra, rb) = tokio::join!(prompt(&cx, &a, "multi A"), prompt(&cx, &b, "multi B")); + ensure(stop_reason(&ra?) == "end_turn", "A did not end_turn")?; + ensure(stop_reason(&rb?) == "end_turn", "B did not end_turn")?; + wait_for(UPDATE_GRACE, || agent_text(&a) == "echo: multi A" && agent_text(&b) == "echo: multi B").await; + let (ta, tb) = (agent_text(&a), agent_text(&b)); + ensure(ta == "echo: multi A" && tb == "echo: multi B", format!("A={ta:?} B={tb:?}"))?; + Ok("both sessions answered their own prompt".into()) +} + +async fn session_fork() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "before fork").await?; + let r = cx + .send_request::(typed(json!({ "sessionId": sid, "cwd": dir() }))) + .block_task() + .await + .ctx("session/fork")?; + let fork = r.session_id.to_string(); + ensure(fork != sid && !fork.is_empty(), format!("fork returned {fork}"))?; + prompt_end_turn(&cx, &fork, "in fork").await?; + Ok(format!("{sid} forked to {fork}")) +} + +async fn update_agent_message_chunk() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "hello").await?; + wait_for(UPDATE_GRACE, || agent_text(&sid) == "echo: hello").await; + let t = agent_text(&sid); + ensure(t == "echo: hello", format!("chunks {t:?}"))?; + Ok("echo: hello".into()) +} + +/// session/new; prompt `#emit `; wait for an update of `kind` that satisfies `check`. +async fn emitted(prompt_text: &str, kind: &str, check: impl Fn(&Value) -> bool) -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let r = prompt(&cx, &sid, prompt_text).await?; + let t0 = Instant::now(); + loop { + if let Some(u) = updates(&sid).into_iter().find(|u| u["sessionUpdate"] == kind && check(u)) { + ensure(stop_reason(&r) == "end_turn", format!("stopReason {}", stop_reason(&r)))?; + return Ok(one_line(&u.to_string())); + } + if t0.elapsed() >= UPDATE_GRACE { + let got: Vec = updates(&sid); + return Err(format!("no matching {kind}; got {}", one_line(&Value::Array(got).to_string()))); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } +} + +async fn update_tool_call_update() -> Result { + let r = emitted("#emit tool_call_update", "tool_call_update", |u| { + u["toolCallId"] == "call-1" && u["status"] == "completed" && u["content"][0]["content"]["text"] == "tool output" + }) + .await?; + let sids_ok = S.updates.lock().unwrap().iter().rev().any(|(_, u)| u["sessionUpdate"] == "tool_call" && u["toolCallId"] == "call-1"); + ensure(sids_ok, "no tool_call call-1 before the update")?; + Ok(r) +} + +async fn update_unknown() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "#emit unknown").await?; + ensure(wait_chunk(&sid, UPDATE_GRACE, |t| t == "after-unknown").await.is_some(), "no after-unknown chunk")?; + let other = updates(&sid).iter().filter(|u| u["sessionUpdate"] == "other").count(); + Ok(format!("after-unknown arrived; the unknown update was {}", if other > 0 { "surfaced" } else { "dropped" })) +} + +async fn stop(reason: &str) -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let r = prompt(&cx, &sid, &format!("#stop {reason}")).await?; + ensure(stop_reason(&r) == reason, format!("stopReason {}", stop_reason(&r)))?; + Ok(format!("stopReason {reason}")) +} + +/// enum.tool_call / enum.plan: an update carrying values v1 does not define, through the typed +/// SessionNotification handler; then the chunk "after-enum" and end_turn. +async fn enum_update(what: &str, check: impl Fn(&Value) -> bool) -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let r = prompt(&cx, &sid, &format!("#enum {what}")).await?; + ensure(stop_reason(&r) == "end_turn", format!("stopReason {}", stop_reason(&r)))?; + ensure(wait_chunk(&sid, UPDATE_GRACE, |t| t == "after-enum").await.is_some(), "no after-enum chunk")?; + let found = updates(&sid).into_iter().find(|u| u["sessionUpdate"] == what); + match found { + Some(u) if check(&u) => Ok(format!("{what} kept the unknown values: {}; after-enum; end_turn", one_line(&u.to_string()))), + Some(u) => Err(format!("{what} surfaced without the raw values: {}", one_line(&u.to_string()))), + None => Err(format!("the {what} update was dropped (after-enum and end_turn arrived); updates {}", + one_line(&Value::Array(updates(&sid)).to_string()))), + } +} + +async fn enum_stop() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let r = prompt(&cx, &sid, "#enum stop").await?; + ensure(stop_reason(&r) == "interop_future_stop", format!("stopReason {}", stop_reason(&r)))?; + prompt_end_turn(&cx, &sid, "after enum").await?; + Ok("stopReason interop_future_stop; after enum end_turn".into()) +} + +/// enum.audience: the typed Role is closed, so the prompt goes out untyped. +async fn enum_audience() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let req = UntypedMessage::new("session/prompt", json!({ "sessionId": sid, "prompt": [ { "type": "text", + "text": "#enum audience", "annotations": { "audience": ["user", "interop_future_role"] } } ] })) + .map_err(|e| describe(&e))?; + let r = cx.send_request(req).block_task().await.ctx("session/prompt")?; + ensure(stop_reason(&r) == "end_turn", format!("stopReason {}", stop_reason(&r)))?; + let c = wait_chunk(&sid, UPDATE_GRACE, |t| t.starts_with("audience: ")).await; + ensure(c.as_deref() == Some("audience: user,interop_future_role"), format!("chunk {c:?}"))?; + Ok("audience: user,interop_future_role; end_turn".into()) +} + +async fn config_grouped() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "#config grouped").await?; + ensure(wait_for(UPDATE_GRACE, || updates(&sid).iter().any(|u| u["sessionUpdate"] == "config_option_update")).await, + "no config_option_update")?; + let u = updates(&sid).into_iter().find(|u| u["sessionUpdate"] == "config_option_update").unwrap(); + let effort = option(&u["configOptions"], "effort").ok_or(format!("no effort in {}", u["configOptions"]))?.clone(); + let groups: Vec = effort["options"].as_array().cloned().unwrap_or_default(); + let names: Vec<&str> = groups.iter().filter_map(|g| g["group"].as_str()).collect(); + ensure(names == ["fast", "deep"], format!("effort options {}", effort["options"]))?; + let values: Vec = groups + .iter() + .flat_map(|g| g["options"].as_array().cloned().unwrap_or_default()) + .filter_map(|o| o["value"].as_str().map(String::from)) + .collect(); + ensure(values == ["effort-low", "effort-medium", "effort-high"], format!("effort values {values:?}"))?; + let r = cx + .send_request::(typed(json!({ "sessionId": sid, "configId": "effort", "value": "effort-high" }))) + .block_task() + .await + .ctx("session/set_config_option")?; + let list = to_json(&r)["configOptions"].clone(); + let e = option(&list, "effort").ok_or(format!("configOptions {list}"))?; + ensure(e["currentValue"] == "effort-high" && option(&list, "model").is_some(), format!("configOptions {list}"))?; + Ok(format!("effort grouped fast/deep, values {values:?}; set to effort-high")) +} + +async fn mode_set() -> Result { + let cx = main_cx()?; + let (sid, r) = new_session_in(&cx, &dir()).await?; + let modes = &r["modes"]["availableModes"]; + ensure(modes.as_array().is_some_and(|a| a.iter().any(|m| m["id"] == "interop-mode-b")), format!("modes {}", r["modes"]))?; + cx.send_request::(typed(json!({ "sessionId": sid, "modeId": "interop-mode-b" }))) + .block_task() + .await + .ctx("session/set_mode")?; + Ok("interop-mode-b set".into()) +} + +fn option<'a>(list: &'a Value, id: &str) -> Option<&'a Value> { + list.as_array()?.iter().find(|o| o["id"] == id) +} + +async fn config_on_new() -> Result { + let (_, r) = new_session_in(&main_cx()?, &dir()).await?; + let model = option(&r["configOptions"], "model").ok_or(format!("configOptions {}", r["configOptions"]))?; + ensure(model["type"] == "select" && model["currentValue"] == "model-a", format!("model {model}"))?; + Ok(one_line(&r["configOptions"].to_string())) +} + +async fn config_select() -> Result { + let cx = main_cx()?; + let (sid, new) = new_session_in(&cx, &dir()).await?; + let r = cx + .send_request::(typed(json!({ "sessionId": sid, "configId": "model", "value": "model-b" }))) + .block_task() + .await + .ctx("session/set_config_option")?; + let list = to_json(&r)["configOptions"].clone(); + let model = option(&list, "model").ok_or(format!("configOptions {list}"))?; + ensure(model["currentValue"] == "model-b", format!("model {model}"))?; + if let Some(before) = new["configOptions"].as_array() { + let ids = |l: &Vec| l.iter().map(|o| o["id"].to_string()).collect::>(); + let after = list.as_array().cloned().unwrap_or_default(); + ensure(ids(before) == ids(&after), format!("not the complete list: {list}"))?; + } + Ok(one_line(&list.to_string())) +} + +async fn config_boolean() -> Result { + let cx = main_cx()?; + let (sid, _) = new_session_in(&cx, &dir()).await?; + let r = cx + .send_request::(typed( + json!({ "sessionId": sid, "configId": "verbose", "type": "boolean", "value": true }), + )) + .block_task() + .await + .ctx("session/set_config_option")?; + let list = to_json(&r)["configOptions"].clone(); + let v = option(&list, "verbose").ok_or(format!("configOptions {list}"))?; + ensure(v["currentValue"] == true, format!("verbose {v}"))?; + Ok(one_line(&list.to_string())) +} + +async fn perm_selected() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "#permission allow").await?; + let reqs: Vec = S.perms.lock().unwrap().iter().filter(|(s, _)| *s == sid).map(|(_, r)| r.clone()).collect(); + ensure(reqs.len() == 1, format!("{} permission request(s)", reqs.len()))?; + ensure(reqs[0]["options"].as_array().is_some_and(|o| !o.is_empty()), "no options")?; + ensure(wait_chunk(&sid, UPDATE_GRACE, |t| t == "permission: selected allow").await.is_some(), + format!("chunks {:?}", agent_text(&sid)))?; + Ok("permission: selected allow".into()) +} + +async fn perm_cancelled() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + S.cancel_on_perm.lock().unwrap().insert(sid.clone()); + let r = prompt(&cx, &sid, "#permission hold").await; + S.cancel_on_perm.lock().unwrap().remove(&sid); + let r = r?; + let sent = S.cancel_sent.lock().unwrap().get(&sid).copied().ok_or("no permission request arrived")?; + let after = sent.elapsed(); + ensure(stop_reason(&r) == "cancelled", format!("stopReason {}", stop_reason(&r)))?; + ensure(after <= Duration::from_secs(5), format!("cancelled {} ms after the cancel", after.as_millis()))?; + Ok("stopReason cancelled".into()) +} + +async fn fs_write() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let path = format!("{}/fs-write.txt", dir()); + prompt_end_turn(&cx, &sid, &format!("#fs write {path} interop write")).await?; + ensure(wait_chunk(&sid, UPDATE_GRACE, |t| t == "fs write ok").await.is_some(), format!("chunks {:?}", agent_text(&sid)))?; + let content = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?; + ensure(content == "interop write", format!("file contains {content:?}"))?; + Ok("fs write ok".into()) +} + +async fn fs_read(op: &str, args: &str, check: impl Fn(&str) -> bool) -> Result { + let cx = main_cx()?; + let path = format!("{}/fs-read.txt", dir()); + std::fs::write(&path, FS_READ_CONTENT).map_err(|e| e.to_string())?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, &format!("#fs {op} {path}{args}")).await?; + let t = wait_chunk(&sid, UPDATE_GRACE, &check).await; + ensure(t.is_some(), format!("chunks {:?}", agent_text(&sid)))?; + Ok(format!("chunk {:?}", t.unwrap_or_default())) +} + +async fn fs_read_missing() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, &format!("#fs read-missing {}/no-such-file.txt", dir())).await?; + let t = wait_chunk(&sid, UPDATE_GRACE, |t| t.starts_with("fs read error")).await; + ensure(t.is_some(), format!("chunks {:?}", agent_text(&sid)))?; + Ok(t.unwrap_or_default()) +} + +async fn chunk_step(text: &str, within: Duration, check: impl Fn(&str) -> bool) -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let t0 = Instant::now(); + let r = tokio::time::timeout(within, prompt(&cx, &sid, text)).await.map_err(|_| format!("TIMEOUT after {} ms", within.as_millis()))??; + let _ = t0; + let t = wait_chunk(&sid, UPDATE_GRACE, &check).await; + ensure(t.is_some(), format!("stopReason {}; chunks {:?}", stop_reason(&r), one_line(&agent_text(&sid))))?; + Ok(format!("chunk {}", one_line(&t.unwrap_or_default()))) +} + +async fn elicit_form() -> Result { + chunk_step("#elicit form", cfg().timeout, |t| { + t.strip_prefix("elicit: accept ") + .and_then(|j| serde_json::from_str::(j).ok()) + .is_some_and(|v| v == json!({ "name": "interop" })) + }) + .await +} + +async fn elicit_complete() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt(&cx, &sid, "#elicit url").await?; + let ok = wait_for(UPDATE_GRACE, || S.elicit_complete.lock().unwrap().iter().any(|e| e == "elic-1")).await; + ensure(ok, "no elicitation/complete for elic-1")?; + Ok("elicitation/complete elic-1".into()) +} + +async fn cancel_prompt() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let slow = cx.send_request(prompt_request(&sid, "#slow", false)); + ensure(wait_chunk(&sid, Duration::from_secs(5), |t| t == "tick").await.is_some(), "no tick")?; + let t = send_cancel(&cx, &sid).await; + let r = tokio::time::timeout(Duration::from_secs(5), slow.block_task()) + .await + .map_err(|_| "TIMEOUT: no answer within 5 s of the cancel".to_string())? + .ctx("session/prompt")?; + let r = to_json(&r); + ensure(stop_reason(&r) == "cancelled", format!("stopReason {}", stop_reason(&r)))?; + Ok(format!("cancelled {} ms after the cancel", t.elapsed().as_millis())) +} + +async fn cancel_prompt_while_cancelling() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let slow = cx.send_request(prompt_request(&sid, "#slow grace=1000", false)); + ensure(wait_chunk(&sid, Duration::from_secs(5), |t| t == "tick").await.is_some(), "no tick")?; + send_cancel(&cx, &sid).await; + let during = prompt(&cx, &sid, "during cancel").await; + let first = slow.block_task().await.ctx("first prompt")?; + ensure(stop_reason(&to_json(&first)) == "cancelled", format!("first prompt answered {}", stop_reason(&to_json(&first))))?; + match during { + Ok(r) => return Err(format!("\"during cancel\" answered {}", stop_reason(&r))), + Err(e) => ensure(e.contains("error -32600"), format!("\"during cancel\" failed with {e}"))?, + } + prompt_end_turn(&cx, &sid, "after cancel").await?; + Ok("cancelled; during cancel -32600; after cancel end_turn".into()) +} + +async fn cancel_grace() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let hang = cx.send_request(prompt_request(&sid, "#hang", false)); + tokio::time::sleep(Duration::from_millis(200)).await; + let t = send_cancel(&cx, &sid).await; + let r = tokio::time::timeout(Duration::from_secs(6), hang.block_task()) + .await + .map_err(|_| "TIMEOUT: no answer within 6 s of the cancel".to_string())? + .ctx("session/prompt")?; + let r = to_json(&r); + ensure(stop_reason(&r) == "cancelled", format!("stopReason {}", stop_reason(&r)))?; + Ok(format!("cancelled {} ms after the cancel", t.elapsed().as_millis())) +} + +/// cancel.max-duration: #hang, never cancelled; the Java agent answers -32800 at 8 s. +async fn cancel_max_duration() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let t0 = Instant::now(); + let hang = cx.send_request(prompt_request(&sid, "#hang", false)); + let r = tokio::time::timeout(Duration::from_secs(11), hang.block_task()) + .await + .map_err(|_| "TIMEOUT: no answer within 11 s".to_string())?; + let ms = t0.elapsed().as_millis(); + match r { + Ok(r) => Err(format!("the prompt answered {}, expected error -32800", to_json(&r))), + Err(e) => { + ensure(code_of(&e) == -32800, format!("the prompt failed with {}, expected -32800", describe(&e)))?; + ensure(ms >= 7000, format!("-32800 after {ms} ms, before the 8 s maxPromptDuration"))?; + prompt_end_turn(&cx, &sid, "after max-duration").await?; + Ok(format!("-32800 after {ms} ms; after max-duration end_turn")) + } + } +} + +async fn cancel_request_client() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let slow = cx.send_request(prompt_request(&sid, "#slow", false)); + ensure(wait_chunk(&sid, Duration::from_secs(5), |t| t == "tick").await.is_some(), "no tick")?; + slow.cancel().ctx("$/cancel_request")?; + let t = Instant::now(); + match tokio::time::timeout(Duration::from_secs(5), slow.block_task()).await { + Err(_) => Err("TIMEOUT: the prompt did not end within 5 s of $/cancel_request".into()), + Ok(Err(e)) if code_of(&e) == -32800 => Ok(format!("-32800 after {} ms", t.elapsed().as_millis())), + Ok(Err(e)) => Err(describe(&e)), + Ok(Ok(r)) if stop_reason(&to_json(&r)) == "cancelled" => Ok(format!("cancelled after {} ms", t.elapsed().as_millis())), + Ok(Ok(r)) => Err(format!("stopReason {}", stop_reason(&to_json(&r)))), + } +} + +async fn cancel_request_unknown() -> Result { + let cx = main_cx()?; + cx.send_cancel_request(999_999i64).ctx("$/cancel_request 999999")?; + let done = cx.send_request::(typed(json!({ "cwd": dir(), "mcpServers": [] }))); + let id = done.id().clone(); + let r = done.block_task().await.ctx("session/new")?; + cx.send_cancel_request(id.clone()).ctx("$/cancel_request of a completed request")?; + let _ = r; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, "after cancel-request").await?; + Ok(format!("ignored: 999999 and completed id {id}")) +} + +async fn ext_agent_notification() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt(&cx, &sid, "#ext notify _interop/note").await?; + let ok = wait_for(UPDATE_GRACE, || { + S.ext_notifications.lock().unwrap().iter().any(|(m, p)| m == EXT_NOTIFICATION && *p == json!({ "n": 1 })) + }) + .await; + ensure(ok, format!("notifications {:?}", S.ext_notifications.lock().unwrap()))?; + Ok("_interop/note {\"n\":1}".into()) +} + +async fn ext_client_request() -> Result { + let cx = main_cx()?; + let req = UntypedMessage::new(EXT_METHOD, json!({ "n": 1 })).map_err(|e| describe(&e))?; + let r = cx.send_request(req).block_task().await.ctx(EXT_METHOD)?; + ensure(r == json!({ "pong": 1 }), format!("result {r}"))?; + Ok(format!("result {r}")) +} + +async fn ext_client_notification() -> Result { + let cx = main_cx()?; + let n = UntypedMessage::new(EXT_NOTIFICATION, json!({ "n": 1 })).map_err(|e| describe(&e))?; + cx.send_notification(n).ctx(EXT_NOTIFICATION)?; + let sid = new_session(&cx).await?; + prompt(&cx, &sid, "#ext last-notification").await?; + let t = wait_chunk(&sid, UPDATE_GRACE, |t| t == "ext last: _interop/note").await; + ensure(t.is_some(), format!("chunks {:?}", agent_text(&sid)))?; + Ok("ext last: _interop/note".into()) +} + +async fn meta_prompt() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + let r = cx.send_request(prompt_request(&sid, "#meta", true)).block_task().await.ctx("session/prompt")?; + let r = to_json(&r); + let ok = wait_for(UPDATE_GRACE, || { + updates(&sid).iter().any(|u| u["sessionUpdate"] == "agent_message_chunk" && u["content"]["text"] == "meta" && has_meta(u)) + }) + .await; + ensure(ok, format!("updates {}", one_line(&Value::Array(updates(&sid)).to_string())))?; + ensure(has_meta(&r), format!("PromptResponse {r}"))?; + Ok("update and response carry _meta interop=m1".into()) +} + +async fn meta_permission() -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt(&cx, &sid, "#permission allow meta").await?; + let req = S.perms.lock().unwrap().iter().find(|(s, _)| *s == sid).map(|(_, r)| r.clone()).ok_or("no permission request")?; + ensure(has_meta(&req), format!("request _meta {}", req.get("_meta").unwrap_or(&Value::Null)))?; + Ok("request carried _meta interop=m1".into()) +} + +async fn error_method_not_found() -> Result { + let cx = main_cx()?; + let req = UntypedMessage::new("interop/no_such_method", json!({})).map_err(|e| describe(&e))?; + let r = cx.send_request(req).block_task().await; + let code = match &r { + Ok(v) => return Err(format!("answered {v}")), + Err(e) => code_of(e), + }; + ensure(code == -32601, format!("code {code}"))?; + new_session(&cx).await?; + Ok("-32601, then session/new succeeded".into()) +} + +async fn big_prompt(n: usize) -> Result { + let text = format!("#len {}", "x".repeat(n)); + let want = format!("len={n}"); + chunk_step(&text, cfg().timeout, |t| t == want).await +} + +async fn big_update(n: usize) -> Result { + let cx = main_cx()?; + let sid = new_session(&cx).await?; + prompt_end_turn(&cx, &sid, &format!("#big {n}")).await?; + let ok = wait_for(UPDATE_GRACE, || texts(&updates(&sid), "agent_message_chunk").iter().any(|t| t.len() == n)).await; + let lens: Vec = texts(&updates(&sid), "agent_message_chunk").iter().map(String::len).collect(); + ensure(ok, format!("chunk lengths {lens:?}"))?; + Ok(format!("one chunk of {n} characters")) +} + +async fn http_reconnect() -> Result { + let second = Conn::open().await?; + initialize(&second.cx).await?; + let sid = new_session(&second.cx).await?; + prompt_end_turn(&second.cx, &sid, "before reconnect").await?; + second.close().await?; + let third = Conn::open().await?; + initialize(&third.cx).await?; + load(&third.cx, &sid).await?; + prompt_end_turn(&third.cx, &sid, "after reconnect").await?; + third.close().await?; + Ok(format!("reloaded {sid} on a new connection")) +} + +async fn stdio_eof_exit() -> Result { + let cmd = cfg().agent_cmd.clone().unwrap_or_default(); + let agent = AcpAgent::new(AcpAgentConfig::new("bash").arg("-c").arg(format!("exec {cmd}"))); + let (stdin, stdout, stderr, mut child) = agent.spawn_process().map_err(|e| describe(&e))?; + tokio::spawn(async move { + use futures_lite_lines::lines; + lines(stderr, |l| relay(&l, LineDirection::Stderr)).await; + }); + let streams = agent_client_protocol::ByteStreams::new(stdin, stdout); + let r = agent_client_protocol::Client + .builder() + .name("interop-rust-eof-client") + .connect_with(streams, async |cx: ConnectionTo| cx.send_request(init_request()).block_task().await) + .await; + r.ctx("initialize of the second agent")?; + // connect_with has returned: the transport, and with it the child's stdin, is dropped (EOF). + let t = Instant::now(); + match tokio::time::timeout(Duration::from_secs(5), child.status()).await { + Ok(Ok(s)) => Ok(format!("exited {s} {} ms after EOF", t.elapsed().as_millis())), + Ok(Err(e)) => Err(format!("wait failed: {e}")), + Err(_) => { + let _ = child.kill(); + Err("no exit on EOF".into()) + } + } +} + +/// Reads an async_process pipe line by line (the SDK's AcpAgent exposes futures-io streams). +mod futures_lite_lines { + pub async fn lines(pipe: impl futures::io::AsyncRead + Unpin, f: impl Fn(String)) { + use futures::io::AsyncBufReadExt; + use futures::stream::StreamExt; + let mut l = futures::io::BufReader::new(pipe).lines(); + while let Some(Ok(line)) = l.next().await { + f(line); + } + } +} + +async fn conn_close() -> Result { + let conn = MAIN.lock().unwrap().take().ok_or("no main connection")?; + conn.close().await +} + +// ------------------------------------------------------------------ main + +static PASS: AtomicUsize = AtomicUsize::new(0); +static FAIL: AtomicUsize = AtomicUsize::new(0); + +async fn run>>(id: &str, f: F) { + let t0 = Instant::now(); + let r = match tokio::time::timeout(cfg().timeout, f).await { + Ok(r) => r, + Err(_) => Err(format!("TIMEOUT after {} ms", cfg().timeout.as_millis())), + }; + match r { + Ok(d) => { + PASS.fetch_add(1, SeqCst); + println!("STEP {id} PASS ({} ms) -> {}", ms(t0), one_line(&d)); + } + Err(e) => { + FAIL.fetch_add(1, SeqCst); + println!("STEP {id} FAIL ({} ms) -> {}", ms(t0), one_line(&e)); + } + } +} + +async fn step(id: &str) { + let to = cfg().timeout; + match id { + "init.initialize" => run(id, init_initialize()).await, + "init.agent-capabilities" => run(id, init_agent_capabilities()).await, + "init.client-capabilities" => run(id, init_client_capabilities()).await, + "init.auth-methods" => run(id, init_auth_methods()).await, + "init.agent-info" => run(id, init_agent_info()).await, + "init.config-boolean" => run(id, init_config_boolean()).await, + "auth.authenticate" => run(id, auth_authenticate()).await, + "auth.logout" => run(id, auth_logout()).await, + "auth.logout-capability" => run(id, auth_logout_capability()).await, + "auth.terminal" => run(id, auth_terminal()).await, + "session.new" => run(id, session_new()).await, + "session.load" => run(id, session_load()).await, + "session.load-replay" => run(id, session_load_replay()).await, + "session.resume" => run(id, session_resume()).await, + "session.list" => run(id, session_list()).await, + "session.close" => run(id, session_close()).await, + "session.delete" => run(id, session_delete()).await, + "session.multi" => run(id, session_multi()).await, + "session.fork" => run(id, session_fork()).await, + "update.agent_message_chunk" => run(id, update_agent_message_chunk()).await, + "update.user_message_chunk" => { + run(id, emitted("#emit user_message_chunk", "user_message_chunk", |u| u["content"]["text"] == "user-chunk")).await + } + "update.agent_thought_chunk" => { + run(id, emitted("#emit agent_thought_chunk", "agent_thought_chunk", |u| u["content"]["text"] == "thinking")).await + } + "update.tool_call" => { + run(id, emitted("#emit tool_call", "tool_call", |u| { + // The SDK omits a default status when it re-serializes; absent means pending. + u["toolCallId"] == "call-1" && u["title"] == "interop tool" && u["kind"] == "read" + && (u["status"] == "pending" || u.get("status").is_none()) + })) + .await + } + "update.tool_call_update" => run(id, update_tool_call_update()).await, + "update.tool_call-name" => run(id, emitted("#emit tool_call name=read_file", "tool_call", |u| u["name"] == "read_file")).await, + "update.plan" => { + run(id, emitted("#emit plan", "plan", |u| { + u["entries"] + == json!([ + { "content": "step one", "priority": "high", "status": "pending" }, + { "content": "step two", "priority": "low", "status": "completed" } + ]) + })) + .await + } + "update.available_commands_update" => { + run(id, emitted("#emit available_commands_update", "available_commands_update", |u| { + let c = &u["availableCommands"]; + c.as_array().is_some_and(|a| a.len() == 1) && c[0]["name"] == "interop" && c[0]["input"]["hint"] == "args" + })) + .await + } + "update.current_mode_update" => { + run(id, emitted("#emit current_mode_update", "current_mode_update", |u| u["currentModeId"] == "interop-mode-b")).await + } + "update.config_option_update" => { + run(id, emitted("#emit config_option_update", "config_option_update", |u| { + option(&u["configOptions"], "model").is_some_and(|m| m["currentValue"] == "model-b") + })) + .await + } + "update.session_info_update" => { + run(id, emitted("#emit session_info_update", "session_info_update", |u| u["title"] == "interop title")).await + } + "update.usage_update" => { + run(id, emitted("#emit usage_update", "usage_update", |u| { + u["used"] == 100 && u["size"] == 1000 && u["cost"]["amount"] == 0.01 && u["cost"]["currency"] == "USD" + })) + .await + } + "update.unknown" => run(id, update_unknown()).await, + "stop.max_tokens" => run(id, stop("max_tokens")).await, + "stop.refusal" => run(id, stop("refusal")).await, + "stop.max_turn_requests" => run(id, stop("max_turn_requests")).await, + "enum.tool_call" => { + run(id, enum_update("tool_call", |u| { + u["toolCallId"] == "call-enum" && u["status"] == "interop_future_status" + && (u["kind"] == "interop_future_kind" || u["kind"] == "other") + })) + .await + } + "enum.plan" => { + run(id, enum_update("plan", |u| { + u["entries"] == json!([ { "content": "future entry", "priority": "interop_future_priority", "status": "interop_future_status" } ]) + })) + .await + } + "enum.stop" => run(id, enum_stop()).await, + "enum.audience" => run(id, enum_audience()).await, + "mode.set" => run(id, mode_set()).await, + "config.on-new" => run(id, config_on_new()).await, + "config.select" => run(id, config_select()).await, + "config.boolean" => run(id, config_boolean()).await, + "config.grouped" => run(id, config_grouped()).await, + "perm.selected" => run(id, perm_selected()).await, + "perm.cancelled" => run(id, perm_cancelled()).await, + "fs.write" => run(id, fs_write()).await, + "fs.read" => run(id, fs_read("read", "", |t| t == FS_READ_CONTENT)).await, + "fs.read-range" => run(id, fs_read("read-range", " line=2 limit=1", |t| t.trim() == "line2")).await, + "fs.read-missing" => run(id, fs_read_missing()).await, + "term.run" => run(id, chunk_step("#terminal run echo hi", to, |t| t == "terminal: hi exit=0")).await, + "term.kill" => run(id, chunk_step("#terminal kill sleep 30", Duration::from_secs(5), |t| t == "terminal killed")).await, + "elicit.form" => run(id, elicit_form()).await, + "elicit.complete" => run(id, elicit_complete()).await, + "cancel.prompt" => run(id, cancel_prompt()).await, + "cancel.prompt-while-cancelling" => run(id, cancel_prompt_while_cancelling()).await, + "cancel.grace" => run(id, cancel_grace()).await, + "cancel.max-duration" => run(id, cancel_max_duration()).await, + "cancel-request.client" => run(id, cancel_request_client()).await, + "cancel-request.agent" => { + run(id, chunk_step(&format!("#fs read-slow {}/slow.txt", dir()), Duration::from_secs(5), |t| t == "cancel-request sent")) + .await + } + "cancel-request.unknown" => run(id, cancel_request_unknown()).await, + "ext.agent-request" => { + run(id, chunk_step("#ext request _interop/ping", to, |t| { + t.strip_prefix("ext: ").and_then(|j| serde_json::from_str::(j).ok()) == Some(json!({ "pong": 1 })) + })) + .await + } + "ext.agent-notification" => run(id, ext_agent_notification()).await, + "ext.client-request" => run(id, ext_client_request()).await, + "ext.client-notification" => run(id, ext_client_notification()).await, + "meta.prompt" => run(id, meta_prompt()).await, + "meta.permission" => run(id, meta_permission()).await, + "error.method-not-found" => run(id, error_method_not_found()).await, + "big.prompt-1m" => run(id, big_prompt(1_048_576)).await, + "big.update-1m" => run(id, big_update(1_048_576)).await, + "big.prompt-8m" => run(id, big_prompt(8_388_608)).await, + "big.update-8m" => run(id, big_update(8_388_608)).await, + "http.reconnect" => run(id, http_reconnect()).await, + "stdio.eof-exit" => run(id, stdio_eof_exit()).await, + "conn.close" => run(id, conn_close()).await, + _ => { + FAIL.fetch_add(1, SeqCst); + println!("STEP {id} FAIL (0 ms) -> unknown-step"); + } + } +} + +fn usage(problem: &str) -> ! { + eprintln!("client: {problem}"); + eprintln!("usage: STEPS= interop-client --transport stdio (with AGENT_CMD) | --transport http|ws --url "); + std::process::exit(2); +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let mut transport = None; + let mut url = None; + let mut args = std::env::args().skip(1); + while let Some(a) = args.next() { + match a.as_str() { + "--transport" => transport = Some(args.next().unwrap_or_else(|| usage("--transport needs a value"))), + "--url" => url = Some(args.next().unwrap_or_else(|| usage("--url needs a value"))), + other => usage(&format!("unknown argument {other}")), + } + } + let transport = match transport.as_deref() { + Some(t @ ("stdio" | "http" | "ws")) => t.to_string(), + _ => usage("--transport stdio|http|ws is required"), + }; + if transport != "stdio" && url.is_none() { + usage(&format!("--url is required for {transport}")); + } + let agent_cmd = std::env::var("AGENT_CMD").ok(); + if transport == "stdio" && agent_cmd.is_none() { + usage("AGENT_CMD is required for stdio"); + } + let steps = std::env::var("STEPS").unwrap_or_default(); + if steps.trim().is_empty() { + usage("STEPS is required"); + } + let timeout = Duration::from_millis(std::env::var("STEP_TIMEOUT_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(15_000)); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let dir = std::env::temp_dir().join(format!("acp-interop-rs-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&dir).expect("scratch directory"); + let _ = CFG.set(Config { transport, url, agent_cmd, timeout, dir }); + for id in steps.split(',').map(str::trim).filter(|s| !s.is_empty()) { + step(id).await; + } + // A run without conn.close still ends its connection. + let left = MAIN.lock().unwrap().take(); + if let Some(c) = left { + let _ = c.close().await; + } + let kinds: Vec = S.kinds.lock().unwrap().iter().map(|(k, v)| format!("upd_{k}={v}")).collect(); + println!( + "RESULT pass={} fail={} updates_total={} {}", + PASS.load(SeqCst), + FAIL.load(SeqCst), + S.total.load(SeqCst), + kinds.join(" ") + ); + use std::io::Write; + let _ = std::io::stdout().flush(); + std::process::exit(0); +} diff --git a/integration-testing/programs/rust/src/rust_agent.rs b/integration-testing/programs/rust/src/rust_agent.rs deleted file mode 100644 index 424fdcba..00000000 --- a/integration-testing/programs/rust/src/rust_agent.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Interop agent on the Rust SDK's Streamable HTTP server (axum). Each prompt streams two updates; -//! a prompt containing "permission" first asks the client. Env: PORT. Prints "READY " on -//! stdout once bound, and one [http] line per request. -use agent_client_protocol::schema::v1::*; -use agent_client_protocol::{Agent, Client, ConnectTo, ConnectionTo}; -use agent_client_protocol_http::AcpHttpServer; - -#[derive(Clone)] -struct Echo; - -impl ConnectTo for Echo { - async fn connect_to(self, client: impl ConnectTo) -> Result<(), agent_client_protocol::Error> { - Agent.builder().name("rust-interop-agent") - .on_receive_request(async move |req: InitializeRequest, responder, _cx| { - eprintln!("[agent] initialize"); - responder.respond(InitializeResponse::new(req.protocol_version) - .agent_capabilities(AgentCapabilities::new().load_session(true))) - }, agent_client_protocol::on_receive_request!()) - .on_receive_request(async move |_req: NewSessionRequest, responder, _cx| { - eprintln!("[agent] session/new"); - responder.respond(NewSessionResponse::new(SessionId::new("rust-session-1"))) - }, agent_client_protocol::on_receive_request!()) - .on_receive_request(async move |req: LoadSessionRequest, responder, cx: ConnectionTo| { - eprintln!("[agent] session/load {}", req.session_id); - cx.send_notification(SessionNotification::new(req.session_id.clone(), - SessionUpdate::AgentMessageChunk(ContentChunk::new("replayed history".to_string().into()))))?; - responder.respond(LoadSessionResponse::new()) - }, agent_client_protocol::on_receive_request!()) - .on_receive_request(async move |req: PromptRequest, responder, cx: ConnectionTo| { - eprintln!("[agent] session/prompt"); - let cx2 = cx.clone(); - cx.spawn(async move { - let sid = req.session_id.clone(); - let text = req.prompt.iter().filter_map(|b| if let ContentBlock::Text(t) = b { Some(t.text.clone()) } else { None }).collect::>().join(" "); - if text.contains("permission") { - let perm = RequestPermissionRequest::new(sid.clone(), - ToolCallUpdate::new("tc-1", ToolCallUpdateFields::new().title("Run thing").kind(ToolKind::Execute).status(ToolCallStatus::Pending)), - vec![PermissionOption::new("allow_once", "Allow once", PermissionOptionKind::AllowOnce), - PermissionOption::new("reject_once", "Reject once", PermissionOptionKind::RejectOnce)]); - let r = cx2.send_request(perm).block_task().await; - eprintln!("[agent] permission result: {r:?}"); - let out = match r { Ok(resp) => format!("permission outcome: {:?}", resp.outcome), Err(e) => format!("permission error: {e:?}") }; - cx2.send_notification(SessionNotification::new(sid.clone(), SessionUpdate::AgentMessageChunk(ContentChunk::new(out.into()))))?; - } - cx2.send_notification(SessionNotification::new(sid.clone(), SessionUpdate::AgentMessageChunk(ContentChunk::new("echo: ".to_string().into()))))?; - cx2.send_notification(SessionNotification::new(sid.clone(), SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into()))))?; - responder.respond(PromptResponse::new(StopReason::EndTurn)) - })?; - Ok(()) - }, agent_client_protocol::on_receive_request!()) - .on_receive_notification(async move |_n: CancelNotification, _cx| Ok(()), agent_client_protocol::on_receive_notification!()) - .connect_to(client).await - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).with_writer(std::io::stderr).init(); - let port: u16 = std::env::var("PORT").unwrap_or("0".into()).parse()?; - let router = AcpHttpServer::new(|| Echo).into_router() - .layer(axum::middleware::from_fn(|req: axum::extract::Request, next: axum::middleware::Next| async move { - let line = format!("[http] {} {} {:?} ct={:?} accept={:?} conn={:?} sess={:?}", req.method(), req.uri(), req.version(), - req.headers().get("content-type"), req.headers().get("accept"), req.headers().get("acp-connection-id"), req.headers().get("acp-session-id")); - let resp = next.run(req).await; - eprintln!("{line} -> {} ct={:?}", resp.status(), resp.headers().get("content-type")); - resp - })); - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; - eprintln!("listening http://127.0.0.1:{port}/acp"); - println!("READY {port}"); - axum::serve(listener, router).await?; - Ok(()) -} diff --git a/integration-testing/programs/rust/src/rust_client.rs b/integration-testing/programs/rust/src/rust_client.rs deleted file mode 100644 index 60d61d4d..00000000 --- a/integration-testing/programs/rust/src/rust_client.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Interop client on the Rust SDK's Streamable HTTP client. Usage: rust-client . Prints one -//! "STEP PASS|FAIL" line per step and a RESULT line with pass/fail counts and the number -//! of session updates each step received and in total (per-step counts are indicative only). -//! Dropping the connection sends the DELETE. -use agent_client_protocol::schema::ProtocolVersion; -use agent_client_protocol::schema::v1::*; -use agent_client_protocol::{Agent, ConnectionTo}; -use agent_client_protocol_http::HttpClient; -use std::sync::Mutex; -use std::sync::atomic::{AtomicUsize, Ordering::SeqCst}; -use std::time::Instant; - -static UPDATES: AtomicUsize = AtomicUsize::new(0); -static TOTAL: AtomicUsize = AtomicUsize::new(0); -static PASS: AtomicUsize = AtomicUsize::new(0); -static FAIL: AtomicUsize = AtomicUsize::new(0); -static PER_STEP: Mutex> = Mutex::new(Vec::new()); - -fn begin() -> Instant { - UPDATES.store(0, SeqCst); - Instant::now() -} - -fn record(name: &str, t: Instant, r: Result) { - let ms = t.elapsed().as_millis(); - match r { - Ok(d) => { - PASS.fetch_add(1, SeqCst); - println!("STEP {name} PASS ({ms} ms) -> {d}"); - } - Err(e) => { - FAIL.fetch_add(1, SeqCst); - println!("STEP {name} FAIL ({ms} ms) -> {e}"); - } - } - let key: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); - PER_STEP.lock().unwrap().push((format!("{key}_updates"), UPDATES.load(SeqCst))); -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::from_default_env()).with_writer(std::io::stderr).init(); - let url = std::env::args().nth(1).expect("usage: rust-client "); - let http = HttpClient::with_endpoint(&url)?; - let t0 = Instant::now(); - let res = agent_client_protocol::Client.builder() - .on_receive_notification(async move |n: SessionNotification, _cx| { - UPDATES.fetch_add(1, SeqCst); - TOTAL.fetch_add(1, SeqCst); - println!(" update: {:?}", n.update); - Ok(()) - }, agent_client_protocol::on_receive_notification!()) - .on_receive_request(async move |req: RequestPermissionRequest, responder, _cx| { - println!(" permission request received, options={}", req.options.len()); - responder.respond(RequestPermissionResponse::new(RequestPermissionOutcome::Selected( - SelectedPermissionOutcome::new(req.options[0].option_id.clone())))) - }, agent_client_protocol::on_receive_request!()) - .connect_with(http, |c: ConnectionTo| async move { - let t = begin(); - let r = c.send_request(InitializeRequest::new(ProtocolVersion::V1)).block_task().await; - record("initialize", t, r.map(|i| format!("loadSession={}", i.agent_capabilities.load_session)).map_err(|e| format!("{e:?}"))); - - let t = begin(); - let r = c.send_request(NewSessionRequest::new(std::path::PathBuf::from("/tmp"))).block_task().await; - let sid = r.as_ref().map(|s| s.session_id.clone()).unwrap_or_else(|_| SessionId::new("missing")); - record("session/new", t, r.map(|s| format!("{}", s.session_id)).map_err(|e| format!("{e:?}"))); - - for (name, text) in [("prompt1", "hello one"), ("prompt2", "hello two"), ("prompt3-permission", "please ask permission")] { - let t = begin(); - let r = c.send_request(PromptRequest::new(sid.clone(), vec![ContentBlock::Text(TextContent::new(text.to_string()))])).block_task().await; - record(name, t, r.map(|p| format!("stop={:?}", p.stop_reason)).map_err(|e| format!("{e:?}"))); - } - - let t = begin(); - let r = c.send_request(LoadSessionRequest::new(sid.clone(), std::path::PathBuf::from("/tmp"))).block_task().await; - record("session/load", t, r.map(|_| "loaded".to_string()).map_err(|e| format!("{e:?}"))); - - let t = begin(); - let r = c.send_request(PromptRequest::new(sid.clone(), vec![ContentBlock::Text(TextContent::new("after load".to_string()))])).block_task().await; - record("prompt-after-load", t, r.map(|p| format!("stop={:?}", p.stop_reason)).map_err(|e| format!("{e:?}"))); - Ok(()) - }).await; - println!("connect_with result: {res:?} total {} ms", t0.elapsed().as_millis()); - let per: Vec = PER_STEP.lock().unwrap().iter().map(|(k, v)| format!("{k}={v}")).collect(); - println!("RESULT pass={} fail={} updates_total={} {}", PASS.load(SeqCst), FAIL.load(SeqCst), TOTAL.load(SeqCst), per.join(" ")); - // Give the transport time to send its DELETE before the runtime shuts down. - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; - Ok(()) -} diff --git a/integration-testing/programs/smoke/README.md b/integration-testing/programs/smoke/README.md new file mode 100644 index 00000000..9e87d1b9 --- /dev/null +++ b/integration-testing/programs/smoke/README.md @@ -0,0 +1,126 @@ +# Real-agent smoke + +The Java client from this checkout (`StdioAcpClientTransport`) against the ACP agents people +actually run, spawned over stdio from their published npm packages. It catches Java client +breakage that the SDK-to-SDK suite cannot: real capability sets, real auth errors, update kinds the +SDK does not model yet. + +It is optional and never part of the default run: `run-all.sh` and the nightly `cross-sdk.yml` +skip it. It runs locally with `run-smoke.sh` and in CI through `.github/workflows/cross-sdk-smoke.yml` +(manual dispatch only). + +## What it checks + +| Step | Runs | PASS means | +|---|---|---| +| `initialize` | always | a `protocolVersion` comes back; prints the agent's `agentInfo`, `agentCapabilities` and `authMethods` | +| `session/new` | always | a session id, or, **without credentials only**, the auth-required error `-32000` | +| `prompt` | with credentials | one short prompt ("Reply with exactly one word: pong") ends with a stop reason after at least one `session/update`; prints the update kinds, the reply and the reported cost | +| `close` | always | `closeGracefully` returns within 15 s and no agent process is left | + +Each step has a hard timeout, and a watchdog kills the agent's whole process tree if the run +exceeds `SMOKE_TOTAL_TIMEOUT` (300 s). Output follows the suite's format: `STEP PASS|FAIL|SKIP` +and a `RESULT` line. A `SKIP` (no credentials) is not a failure. + +The client never calls `authenticate`. On these agents it persists credentials into the user's +configuration (Gemini CLI clears its cached Google login, codex-acp writes `~/.codex/auth.json`). +Agents read API keys from their environment instead. + +## Agents + +| Agent | Package (pinned in `smoke.sh`) | Command | API key | Stored login | Cheap default | +|---|---|---|---|---|---| +| `gemini` | `@google/gemini-cli@0.62.0` | `gemini --acp` | `GEMINI_API_KEY` | `~/.gemini/oauth_creds.json` | `--model gemini-3.1-flash-lite` (`SMOKE_GEMINI_MODEL`) | +| `claude` | `@agentclientprotocol/claude-agent-acp@0.85.0` (needs Node 22) | `claude-agent-acp` | `ANTHROPIC_API_KEY` | `~/.claude/.credentials.json` | `ANTHROPIC_MODEL=haiku` (`SMOKE_CLAUDE_MODEL`) | +| `codex` | `@agentclientprotocol/codex-acp@2.1.1` | `codex-acp` | `OPENAI_API_KEY` (`CODEX_API_KEY` wins if set) | `~/.codex/auth.json` | `gpt-6-luna`, reasoning effort `low` via `CODEX_CONFIG` (`SMOKE_CODEX_MODEL`) | + +The `@zed-industries/claude-code-acp`, `@zed-industries/claude-agent-acp` and +`@zed-industries/codex-acp` packages are deprecated in favour of the `@agentclientprotocol/` ones. +`--experimental-acp` is Gemini CLI's deprecated alias for `--acp`. + +To bump an agent, change its version in `smoke.sh`, run it locally with credentials, then commit. + +## Running locally + +```bash +integration-testing/programs/smoke/run-smoke.sh # all three +integration-testing/programs/smoke/run-smoke.sh codex --skip-sdk-install +integration-testing/programs/smoke/run-smoke.sh all --auth none +``` + +It installs the SDK from this checkout, builds the client, and installs each pinned agent into +`integration-testing/.cache/smoke/npm-` (never globally). Logs go to +`integration-testing/logs/smoke/`. Prerequisites: JDK 17+, Node 22 (Node 20 works for Gemini and +Codex), npm. + +`--auth` (or `SMOKE_AUTH_MODE`) chooses the credentials: + +- `login`: the CLI's own stored login (subscription or OAuth), under your real `$HOME`. API keys are + removed from the agent's environment, so the login pays. +- `key`: the API key from your environment, under a throwaway `$HOME`, so no stored login is read + and nothing is written into your configuration. This is what CI runs. +- `none`: a throwaway `$HOME` and no keys. Only `initialize` and `session/new` run. +- `auto` (default): `login` when the CLI's login file exists (never under `CI`), else `key` when the + key is set, else `none`. Gemini prefers the key. As observed on 2026-10-01, Google refuses the + personal Google login for this client ("no longer supported for Gemini Code Assist for + individuals"), so that login fails `session/new`. + +Login mode writes a session into the CLI's own history (`~/.codex/sessions`, `~/.claude/projects`) +like any other use. + +**From inside a Claude Code session**, launch the `claude` cell through `~/scripts/claude-run.sh` +(the adapter starts Claude Code, and nested launches are blocked): +`~/scripts/claude-run.sh 'integration-testing/programs/smoke/run-smoke.sh claude --skip-sdk-install'`. + +`configs/smoke-.json` run the same cell through `RunScenario` +(`jbang RunScenario.java smoke-codex`). They carry the `smoke` tag, so `run-all.sh` runs them only +when asked. + +## Secrets: an org-admin act + +Repository secrets on `agentclientprotocol/java-sdk` need admin rights, which maintainers do not +have. An org admin adds them under Settings > Secrets and variables > Actions > Repository secrets: + +| Secret | Where to get the key | Recommended cap | +|---|---|---| +| `GEMINI_API_KEY` | Google AI Studio, , in a dedicated project | A free-tier key costs nothing (rate-limited). On a paid project, set a Cloud Billing budget alert. Budgets alert but do not cap. | +| `ANTHROPIC_API_KEY` | Anthropic Console, , in a dedicated workspace | Workspace spend limit of $5/month | +| `OPENAI_API_KEY` | OpenAI Platform, , in a dedicated project | Project budget of $5/month | + +Use dedicated, smoke-only keys, so they can be capped, rotated and revoked on their own. Until a +secret exists, its job prints `SKIPPED: secret X not configured` and succeeds. Nothing in the +workflow needs editing when the keys are added. + +**Optional hardening.** An environment named `smoke` with required reviewers gates every run on an +approval, and secrets scoped to it reach only these jobs. Creating environments is also an admin +act. To use it, create the environment, add the three secrets there instead of at repository +level, and uncomment `environment: smoke` in the workflow. + +## CI + +`cross-sdk-smoke.yml` is `workflow_dispatch` only. It has one matrix job per agent (`fail-fast: false`), +and each job receives only its own agent's key. Inputs: + +- `agent`: `all` or one agent. +- `keyless`: without a secret, run the keyless steps (`initialize`, `session/new`) instead of + skipping. This is useful to check the plumbing before keys exist. + +**Enabling the nightly.** Once the secrets exist, uncomment the `schedule:` block in +`cross-sdk-smoke.yml`. It runs at 05:41 UTC, after the 04:17 cross-SDK run. + +## Cost per run + +These figures assume one prompt per agent with the cheap defaults. Most of the input is the agent's own system +prompt and tool definitions, not the prompt: + +- **Claude** (Haiku): the adapter reports its cost. Measured locally on 2026-10-01: about 23k + context tokens and **$0.021** per run. A CI run, with a fresh `$HOME` and no user `CLAUDE.md`, is + a little less. +- **Codex** (`gpt-6-luna`, low effort): measured locally at about 15.5k context tokens per run. The + adapter reports no cost; at small-model API rates that is around a cent. Confirm it on the first + keyed run. +- **Gemini** (`gemini-3.1-flash-lite`): not measured, because no key was available locally. A + similar-sized context on Flash-Lite should cost well under a cent, and nothing on a free-tier + key. + +That is about **$0.05 per full run**, or about $1.50 a month as a nightly. diff --git a/integration-testing/programs/smoke/pom.xml b/integration-testing/programs/smoke/pom.xml new file mode 100644 index 00000000..a25df2b8 --- /dev/null +++ b/integration-testing/programs/smoke/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + + com.agentclientprotocol.integration + acp-smoke-client + 1 + + + + set-by-caller + 1.5.18 + 17 + UTF-8 + + + + + com.agentclientprotocol + acp-core + ${acp.version} + + + com.agentclientprotocol + acp-json-jackson2 + ${acp.version} + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + + diff --git a/integration-testing/programs/smoke/run-smoke.sh b/integration-testing/programs/smoke/run-smoke.sh new file mode 100755 index 00000000..e04a1898 --- /dev/null +++ b/integration-testing/programs/smoke/run-smoke.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Real-agent smoke tier: the Java client from this checkout against published ACP agent CLIs. +# +# integration-testing/programs/smoke/run-smoke.sh [gemini|claude|codex|all] [options] +# +# --auth auto|login|key|none how each agent is authenticated (default auto; see smoke.sh) +# --skip-sdk-install reuse the SDK already installed in ~/.m2 from this checkout +# +# Installs the SDK from this checkout, builds the smoke client, installs each pinned agent into +# .cache/smoke (never globally) and runs it. Logs go to integration-testing/logs/smoke/.log. +# Exits non-zero if any STEP failed. A SKIP (no credentials) is not a failure. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +REPO_DIR="$(cd "$IT_DIR/.." && pwd)" + +WHICH="all" +SKIP_INSTALL="" +while [ $# -gt 0 ]; do + case "$1" in + gemini|claude|codex|all) WHICH="$1"; shift ;; + --auth) export SMOKE_AUTH_MODE="$2"; shift 2 ;; + --auth=*) export SMOKE_AUTH_MODE="${1#--auth=}"; shift ;; + --skip-sdk-install) SKIP_INSTALL=1; shift ;; + -h|--help) sed -n '2,13p' "$0"; exit 0 ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done +AGENTS=(gemini claude codex) +[ "$WHICH" = "all" ] || AGENTS=("$WHICH") + +for tool in java node npm timeout; do + command -v "$tool" > /dev/null 2>&1 || { echo "Missing prerequisite: $tool" >&2; exit 2; } +done + +LOGS="$IT_DIR/logs/smoke" +mkdir -p "$LOGS" +ACP_VERSION="$(sed -n '//,/<\/parent>/d; s#^ \(.*\)#\1#p' "$REPO_DIR/pom.xml" | head -1)" + +if [ -z "$SKIP_INSTALL" ]; then + echo "Installing the SDK $ACP_VERSION from $REPO_DIR" + (cd "$REPO_DIR" && timeout 900 "$IT_DIR/scripts/mvnw.sh" -q -B -DskipTests install) > "$LOGS/sdk-install.log" 2>&1 || { + echo "SDK install failed; see $LOGS/sdk-install.log" >&2; tail -40 "$LOGS/sdk-install.log" >&2; exit 1; } +fi +echo "Building the smoke client against acp $ACP_VERSION" +(cd "$SCRIPT_DIR" && timeout 600 "$IT_DIR/scripts/mvnw.sh" -q -B -Dacp.version="$ACP_VERSION" compile \ + dependency:build-classpath -Dmdep.outputFile=target/classpath.txt) > "$LOGS/build.log" 2>&1 || { + echo "Smoke client build failed; see $LOGS/build.log" >&2; tail -40 "$LOGS/build.log" >&2; exit 1; } + +declare -A STATUS DETAIL +FAILED=0 +for a in "${AGENTS[@]}"; do + echo + echo "################ smoke $a" + "$SCRIPT_DIR/smoke.sh" "$a" 2>&1 | tee "$LOGS/$a.log" + code=${PIPESTATUS[0]} + auth="$(sed -n 's/^SMOKE agent=.* auth=\(.*\)$/\1/p' "$LOGS/$a.log" | head -1)" + steps="$(grep -E '^STEP ' "$LOGS/$a.log" | awk '{printf "%s%s=%s", (NR>1?" ":""), $2, $3}')" + if [ "$code" -eq 0 ] && ! grep -qE '^STEP [^ ]+ FAIL' "$LOGS/$a.log"; then + STATUS[$a]="PASS" + else + STATUS[$a]="FAIL (exit $code)" + FAILED=$((FAILED + 1)) + fi + DETAIL[$a]="auth=${auth:-?} ${steps}" +done + +echo +echo "========================================================================" +for a in "${AGENTS[@]}"; do + printf '%-8s %-14s %s\n' "$a" "${STATUS[$a]}" "${DETAIL[$a]}" +done | tee "$LOGS/summary.txt" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### Real-agent smoke" + echo + echo "| Agent | Result | Steps |" + echo "|---|---|---|" + for a in "${AGENTS[@]}"; do echo "| $a | ${STATUS[$a]} | ${DETAIL[$a]} |"; done + } >> "$GITHUB_STEP_SUMMARY" +fi +[ "$FAILED" -eq 0 ] diff --git a/integration-testing/programs/smoke/smoke.sh b/integration-testing/programs/smoke/smoke.sh new file mode 100755 index 00000000..7a5d3c62 --- /dev/null +++ b/integration-testing/programs/smoke/smoke.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# +# Run the smoke client against ONE real ACP agent CLI. The client must already be built +# (run-smoke.sh does that; so does the "build" of configs/smoke-*.json). +# +# programs/smoke/smoke.sh gemini|claude|codex +# +# Environment: +# SMOKE_AUTH_MODE auto (default) | login | key | none +# login the CLI's own stored login (subscription/OAuth) under the real $HOME; API keys +# are removed from the agent's environment so the login is what pays. +# key the agent's API key from the environment, under a fresh throwaway $HOME, so no +# stored login is used and none is written. This is what CI runs. +# none a fresh $HOME and no keys: initialize and session/new only; the prompt SKIPs. +# auto login when the CLI's login file exists (never in CI), else key when the key is +# set, else none. Gemini prefers the key: as observed 2026-10-01, Google refuses +# the personal Google login ("no longer supported for Gemini Code Assist for +# individuals"), so that login fails session/new. +# SMOKE_GEMINI_MODEL / SMOKE_CLAUDE_MODEL / SMOKE_CODEX_MODEL override the cheap defaults +# SMOKE_CACHE where the pinned agent packages are installed (default .cache/smoke) +# SMOKE_TOTAL_TIMEOUT seconds for the whole run (default 300) +# SMOKE_JAVA_OPTS extra JVM options, e.g. -Dacp.level=DEBUG for the SDK's protocol log +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +IT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +CACHE="${SMOKE_CACHE:-$IT_DIR/.cache/smoke}" +AGENT="${1:-}" +MODE="${SMOKE_AUTH_MODE:-auto}" +TOTAL="${SMOKE_TOTAL_TIMEOUT:-300}" + +# Every key any of the agents reads. Each run removes the ones its mode must not see. +ALL_KEYS=(GEMINI_API_KEY GOOGLE_API_KEY ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN OPENAI_API_KEY CODEX_API_KEY) + +# Pinned agents. Bump a version here, run the smoke locally, then commit. +AGENT_ENV=() +PREFER=login +case "$AGENT" in + gemini) + # https://github.com/google-gemini/gemini-cli : `gemini --acp` (--experimental-acp is the + # deprecated alias). API key: GEMINI_API_KEY (Gemini Developer API). Login: Google OAuth + # cached in ~/.gemini/oauth_creds.json. + PKG="@google/gemini-cli@0.62.0" + BIN="gemini" + ARGS="--acp --model ${SMOKE_GEMINI_MODEL:-gemini-3.1-flash-lite}" + KEYS=(GEMINI_API_KEY) + LOGIN_FILE="$HOME/.gemini/oauth_creds.json" + PREFER=key + ;; + claude) + # https://github.com/agentclientprotocol/claude-agent-acp (formerly + # @zed-industries/claude-code-acp, deprecated). API key: ANTHROPIC_API_KEY. Login: Claude + # Code's stored login (~/.claude/.credentials.json). Model from ANTHROPIC_MODEL. + PKG="@agentclientprotocol/claude-agent-acp@0.85.0" + BIN="claude-agent-acp" + ARGS="" + KEYS=(ANTHROPIC_API_KEY) + LOGIN_FILE="$HOME/.claude/.credentials.json" + AGENT_ENV+=("ANTHROPIC_MODEL=${SMOKE_CLAUDE_MODEL:-haiku}") + ;; + codex) + # https://github.com/agentclientprotocol/codex-acp (formerly @zed-industries/codex-acp, + # deprecated). API key: OPENAI_API_KEY (or CODEX_API_KEY, which wins). Login: ChatGPT, + # stored in ~/.codex/auth.json. Model and effort from CODEX_CONFIG. + PKG="@agentclientprotocol/codex-acp@2.1.1" + BIN="codex-acp" + ARGS="" + KEYS=(OPENAI_API_KEY CODEX_API_KEY) + LOGIN_FILE="${CODEX_HOME:-$HOME/.codex}/auth.json" + AGENT_ENV+=("CODEX_CONFIG={\"model\":\"${SMOKE_CODEX_MODEL:-gpt-6-luna}\",\"model_reasoning_effort\":\"low\"}" + "NO_BROWSER=1") + ;; + *) + echo "usage: $0 gemini|claude|codex" >&2 + exit 2 + ;; +esac + +key_set="" +for k in "${KEYS[@]}"; do + [ -n "${!k:-}" ] && key_set="$k" +done + +if [ "$MODE" = "auto" ]; then + if [ "$PREFER" = "key" ] && [ -n "$key_set" ]; then + MODE=key + elif [ -z "${CI:-}" ] && [ -s "$LOGIN_FILE" ]; then + MODE=login + elif [ -n "$key_set" ]; then + MODE=key + else + MODE=none + fi +fi +case "$MODE" in + login) [ -s "$LOGIN_FILE" ] || { echo "SMOKE_AUTH_MODE=login but $LOGIN_FILE does not exist" >&2; exit 2; } ;; + key) [ -n "$key_set" ] || { echo "SMOKE_AUTH_MODE=key but none of ${KEYS[*]} is set" >&2; exit 2; } ;; + none) ;; + *) echo "unknown SMOKE_AUTH_MODE=$MODE" >&2; exit 2 ;; +esac + +# Install the pinned agent into its own npm prefix (never global). +PREFIX="$CACHE/npm-$AGENT" +if [ "$(cat "$PREFIX/.pinned" 2>/dev/null)" != "$PKG" ]; then + echo "Installing $PKG into $PREFIX" + rm -rf "$PREFIX" + mkdir -p "$PREFIX" + timeout 600 npm install --prefix "$PREFIX" --no-fund --no-audit --loglevel=error "$PKG" >&2 + echo "$PKG" > "$PREFIX/.pinned" +fi +AGENT_BIN="$PREFIX/node_modules/.bin/$BIN" +[ -x "$AGENT_BIN" ] || { echo "$AGENT_BIN missing after install" >&2; exit 2; } + +if [ "$AGENT" = "claude" ]; then + node_major="$(node -p 'process.versions.node.split(".")[0]')" + [ "$node_major" -ge 22 ] || echo "WARN: $PKG declares node >=22; running on node $(node --version)" +fi + +# The agent's environment for this mode. Keys are never printed. env(1) takes every -u before +# the first NAME=VALUE, hence two lists. +UNSETS=() +SETS=() +drop_keys() { # $1: keep-mine | drop-all + local k mine keep + for k in "${ALL_KEYS[@]}"; do + keep="" + if [ "$1" = "keep-mine" ]; then + for mine in "${KEYS[@]}"; do [ "$k" = "$mine" ] && keep=1; done + fi + [ -n "$keep" ] || UNSETS+=(-u "$k") + done +} +if [ "$MODE" = "login" ]; then + drop_keys drop-all +else + # A fresh home: no stored login is read, and an agent that persists credentials writes + # them here, not into the developer's configuration. + ISO="$CACHE/home-$AGENT-$MODE" + rm -rf "$ISO" + mkdir -p "$ISO"/{.config,.cache,.local/share,.local/state,.codex} + SETS+=("HOME=$ISO" "XDG_CONFIG_HOME=$ISO/.config" "XDG_CACHE_HOME=$ISO/.cache" + "XDG_DATA_HOME=$ISO/.local/share" "XDG_STATE_HOME=$ISO/.local/state" "CODEX_HOME=$ISO/.codex") + if [ "$MODE" = "key" ]; then + drop_keys keep-mine + # codex-acp logs in with the env key only when told to (in the throwaway CODEX_HOME). + [ "$AGENT" = "codex" ] && SETS+=('DEFAULT_AUTH_REQUEST={"methodId":"api-key"}') + else + drop_keys drop-all + fi +fi + +CP="$SCRIPT_DIR/target/classes:$(cat "$SCRIPT_DIR/target/classpath.txt")" +echo "SMOKE agent=$AGENT package=$PKG auth=$MODE" +exec timeout -k 10 "$((TOTAL + 30))" env "${UNSETS[@]}" "${SETS[@]}" "${AGENT_ENV[@]}" \ + SMOKE_AGENT="$AGENT" SMOKE_AUTH="$MODE" SMOKE_TOTAL_TIMEOUT="$TOTAL" \ + AGENT_CMD="$AGENT_BIN${ARGS:+ $ARGS}" \ + java ${SMOKE_JAVA_OPTS:-} -cp "$CP" smoke.SmokeClient diff --git a/integration-testing/programs/smoke/src/main/java/smoke/SmokeClient.java b/integration-testing/programs/smoke/src/main/java/smoke/SmokeClient.java new file mode 100644 index 00000000..287c5054 --- /dev/null +++ b/integration-testing/programs/smoke/src/main/java/smoke/SmokeClient.java @@ -0,0 +1,385 @@ +package smoke; + +import java.io.ByteArrayOutputStream; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.client.AcpClient; +import com.agentclientprotocol.sdk.client.transport.AgentParameters; +import com.agentclientprotocol.sdk.client.transport.StdioAcpClientTransport; +import com.agentclientprotocol.sdk.json.AcpJsonMapper; +import com.agentclientprotocol.sdk.spec.AcpError; +import com.agentclientprotocol.sdk.spec.AcpSchema; +import reactor.core.publisher.Mono; + +/** + * Real-agent smoke client. Spawns a published ACP agent CLI over stdio with + * {@link StdioAcpClientTransport} and runs {@code initialize}, {@code session/new} and, when the + * agent has credentials, one short prompt. Prints one {@code STEP PASS|FAIL|SKIP} line per + * step and a final {@code RESULT} line, like the rest of the suite. + * + *

    + * Configured from the environment (set by {@code smoke.sh}): + *

      + *
    • {@code AGENT_CMD}: the agent command line, run as {@code bash -c "exec $AGENT_CMD"}.
    • + *
    • {@code SMOKE_AGENT}: a display name.
    • + *
    • {@code SMOKE_AUTH}: {@code key}, {@code login} or {@code none}: whether the agent has + * credentials. Only with credentials does the prompt step run; without them {@code session/new} + * may answer -32000 (auth required) and still pass.
    • + *
    • {@code SMOKE_PROMPT}: the prompt text (default: a one-word reply).
    • + *
    • {@code SMOKE_TOTAL_TIMEOUT}: seconds before the watchdog kills everything (default 300).
    • + *
    + * + *

    + * The client never calls {@code authenticate}: on these agents it persists credentials in the + * user's configuration (Gemini clears its cached Google login, Codex writes its auth file), so a + * smoke run must not do it. Agents pick up API keys from their environment instead. + */ +public final class SmokeClient { + + static final int AUTH_REQUIRED = -32000; + + static final Duration INIT_TIMEOUT = seconds("SMOKE_INIT_TIMEOUT", 120); + + static final Duration NEW_SESSION_TIMEOUT = seconds("SMOKE_SESSION_TIMEOUT", 90); + + static final Duration PROMPT_TIMEOUT = seconds("SMOKE_PROMPT_TIMEOUT", 180); + + static final Duration CLOSE_TIMEOUT = Duration.ofSeconds(15); + + /** Credential shapes redacted from relayed agent output, on top of the key values themselves. */ + static final Pattern SECRET_SHAPES = Pattern + .compile("(sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_.-]{20,}|eyJ[0-9A-Za-z_-]{20,}\\.[0-9A-Za-z_.-]+)"); + + static final List KEY_VARS = List.of("GEMINI_API_KEY", "GOOGLE_API_KEY", "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", "CODEX_API_KEY"); + + static final AtomicInteger updatesTotal = new AtomicInteger(); + + static final Map updateKinds = new TreeMap<>(); + + static final StringBuilder agentText = new StringBuilder(); + + static int pass; + + static int fail; + + static int skip; + + static final Map result = new TreeMap<>(); + + interface Step { + + String run() throws Exception; + + } + + /** A step outcome other than PASS that is not an exception. */ + static final class Skip extends RuntimeException { + + Skip(String reason) { + super(reason); + } + + } + + public static void main(String[] args) throws Exception { + // Before anything logs: every line this process prints, SDK log lines included, goes + // through redact(), so no key reaches a log file or a CI artifact. + System.setOut(new PrintStream(new RedactingStream(new FileOutputStream(FileDescriptor.out)), true, + StandardCharsets.UTF_8)); + System.setErr(new PrintStream(new RedactingStream(new FileOutputStream(FileDescriptor.err)), true, + StandardCharsets.UTF_8)); + String agent = env("SMOKE_AGENT", "agent"); + String cmd = System.getenv("AGENT_CMD"); + if (cmd == null || cmd.isBlank()) { + System.out.println("STEP setup FAIL -> AGENT_CMD is not set"); + System.exit(2); + } + String auth = env("SMOKE_AUTH", "none"); + String promptText = env("SMOKE_PROMPT", "Reply with exactly one word: pong"); + long totalSec = seconds("SMOKE_TOTAL_TIMEOUT", 300).toSeconds(); + + Runtime.getRuntime().addShutdownHook(new Thread(SmokeClient::killAgentTree, "smoke-kill")); + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(totalSec * 1000); + } + catch (InterruptedException e) { + return; + } + System.out.println("STEP watchdog FAIL -> TIMEOUT: the smoke run exceeded " + totalSec + " s"); + System.out.flush(); + killAgentTree(); + Runtime.getRuntime().halt(3); + }, "smoke-watchdog"); + watchdog.setDaemon(true); + watchdog.start(); + + System.out.println("INFO agent=" + agent + " auth=" + auth + " cmd=" + cmd); + Path cwd = Files.createTempDirectory("acp-smoke-"); + + StdioAcpClientTransport transport = new StdioAcpClientTransport( + AgentParameters.builder("bash").args("-c", "exec " + cmd).build(), AcpJsonMapper.createDefault()); + transport.setStdErrorHandler(line -> System.out.println("agent| " + line)); + + AtomicReference currentSession = new AtomicReference<>(); + AcpAsyncClient client = AcpClient.async(transport) + .requestTimeout(PROMPT_TIMEOUT) + .clientCapabilities(new AcpSchema.ClientCapabilities(new AcpSchema.FileSystemCapability(false, false), false)) + .clientInfo(new AcpSchema.Implementation("acp-java-smoke", "1")) + .sessionUpdateHandler(n -> { + onUpdate(n); + return Mono.empty(); + }) + // The smoke prompt needs no tools; refuse anything the agent asks for. + .requestPermissionHandler(req -> { + System.out.println(" permission request (cancelled): " + req.toolCall()); + return Mono.just(new AcpSchema.RequestPermissionResponse(new AcpSchema.PermissionCancelled())); + }) + .build(); + + AtomicReference init = new AtomicReference<>(); + step("initialize", () -> { + AcpSchema.InitializeResponse r = client + .initialize() + .block(INIT_TIMEOUT); + if (r == null || r.protocolVersion() == null) { + throw new IllegalStateException("no protocolVersion in the initialize response"); + } + init.set(r); + System.out.println("INFO protocolVersion=" + r.protocolVersion()); + System.out.println("INFO agentInfo=" + r.agentInfo()); + System.out.println("INFO agentCapabilities=" + r.agentCapabilities()); + List ids = new ArrayList<>(); + if (r.authMethods() != null) { + r.authMethods().forEach(m -> ids.add(m.id())); + } + System.out.println("INFO authMethods=" + ids); + result.put("protocol_version", String.valueOf(r.protocolVersion())); + result.put("auth_methods", String.valueOf(ids.size())); + return "protocolVersion=" + r.protocolVersion() + " authMethods=" + ids; + }); + + step("session/new", () -> { + if (init.get() == null) { + throw new Skip("initialize failed"); + } + try { + AcpSchema.NewSessionResponse r = client + .newSession(new AcpSchema.NewSessionRequest(cwd.toString(), List.of())) + .block(NEW_SESSION_TIMEOUT); + if (r == null || r.sessionId() == null) { + throw new IllegalStateException("no sessionId"); + } + currentSession.set(r.sessionId()); + result.put("session_new", "ok"); + return "sessionId=" + r.sessionId() + (r.modes() == null ? "" : " modes=" + modeIds(r.modes())); + } + catch (AcpError e) { + if (e.getCode() != AUTH_REQUIRED) { + throw e; + } + result.put("session_new", "auth_required"); + if (!"none".equals(auth)) { + throw new IllegalStateException("auth required (-32000) although credentials are present (" + auth + + "): " + e.getMessage()); + } + return "auth required (-32000), as expected without credentials: " + e.getMessage(); + } + }); + + step("prompt", () -> { + if ("none".equals(auth)) { + throw new Skip("no credentials (set the agent's API key or log in to its CLI)"); + } + String sid = currentSession.get(); + if (sid == null) { + throw new Skip("no session"); + } + updatesTotal.set(0); + AcpSchema.PromptResponse r = client + .prompt(new AcpSchema.PromptRequest(sid, List.of(new AcpSchema.TextContent(promptText)))) + .block(PROMPT_TIMEOUT); + if (r == null || r.stopReason() == null) { + throw new IllegalStateException("no stopReason"); + } + result.put("stop_reason", r.stopReason().value()); + int n = updatesTotal.get(); + String text = agentText.length() > 200 ? agentText.substring(0, 200) + "..." : agentText.toString(); + String detail = "stopReason=" + r.stopReason() + " updates=" + n + " kinds=" + updateKinds + " text=" + + text.replace('\n', ' ').trim(); + if (n < 1) { + throw new IllegalStateException("no session/update before the response; " + detail); + } + return detail; + }); + + step("close", () -> { + try { + client.closeGracefully().block(CLOSE_TIMEOUT); + } + finally { + killAgentTree(); + } + long left = ProcessHandle.current().descendants().filter(ProcessHandle::isAlive).count(); + if (left > 0) { + throw new IllegalStateException(left + " agent processes still alive after kill"); + } + return "closed; agent process tree gone"; + }); + + StringBuilder sb = new StringBuilder("RESULT pass=" + pass + " fail=" + fail + " skip=" + skip + + " updates_total=" + updatesTotal.get()); + result.forEach((k, v) -> sb.append(' ').append(k).append('=').append(v)); + updateKinds.forEach((k, v) -> sb.append(" upd_").append(k).append('=').append(v)); + System.out.println(sb); + System.out.flush(); + System.exit(fail == 0 ? 0 : 1); + } + + static void step(String name, Step s) { + long t0 = System.nanoTime(); + String ms; + try { + String detail = s.run(); + ms = (System.nanoTime() - t0) / 1_000_000 + " ms"; + pass++; + System.out.println("STEP " + name + " PASS (" + ms + ") -> " + detail); + } + catch (Skip e) { + skip++; + System.out.println("STEP " + name + " SKIP -> " + e.getMessage()); + } + catch (Throwable e) { + ms = (System.nanoTime() - t0) / 1_000_000 + " ms"; + fail++; + String why = String.valueOf(e); + if (e instanceof IllegalStateException && String.valueOf(e.getMessage()).startsWith("Timeout")) { + why = "TIMEOUT " + why; + } + System.out.println("STEP " + name + " FAIL (" + ms + ") -> " + why); + } + System.out.flush(); + } + + static synchronized void onUpdate(AcpSchema.SessionNotification n) { + updatesTotal.incrementAndGet(); + String kind = n.update() == null ? "null" : n.update().getClass().getSimpleName(); + updateKinds.merge(kind, 1, Integer::sum); + if (n.update() instanceof AcpSchema.UsageUpdate u) { + // Context tokens used and, when the agent reports it, the session cost so far. + System.out.println(" usage: used=" + u.used() + " size=" + u.size() + " cost=" + u.cost()); + if (u.cost() != null && u.cost().amount() != null) { + result.put("cost_" + String.valueOf(u.cost().currency()).toLowerCase(), String.valueOf(u.cost().amount())); + } + } + if (n.update() instanceof AcpSchema.AgentMessageChunk c && c.content() instanceof AcpSchema.TextContent t) { + agentText.append(t.text()); + } + } + + static String modeIds(AcpSchema.SessionModeState modes) { + List ids = new ArrayList<>(); + if (modes.availableModes() != null) { + modes.availableModes().forEach(m -> ids.add(m.id())); + } + return ids + " current=" + modes.currentModeId(); + } + + /** Kill every process this JVM started (the agent and its own children), newest first. */ + static void killAgentTree() { + List tree = new ArrayList<>(ProcessHandle.current().descendants().toList()); + if (tree.isEmpty()) { + return; + } + tree.forEach(ProcessHandle::destroy); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + for (ProcessHandle h : tree) { + long left = deadline - System.nanoTime(); + try { + h.onExit().get(Math.max(left, 1), TimeUnit.NANOSECONDS); + } + catch (Exception e) { + h.destroyForcibly(); + } + } + } + + static String redact(String s) { + if (s == null) { + return null; + } + String out = s; + for (String var : KEY_VARS) { + String v = System.getenv(var); + if (v != null && v.length() >= 8) { + out = out.replace(v, "***"); + } + } + return SECRET_SHAPES.matcher(out).replaceAll("***"); + } + + /** Buffers a line, then writes it redacted. */ + static final class RedactingStream extends OutputStream { + + private final OutputStream out; + + private final ByteArrayOutputStream line = new ByteArrayOutputStream(); + + RedactingStream(OutputStream out) { + this.out = out; + } + + @Override + public synchronized void write(int b) throws IOException { + line.write(b); + if (b == '\n') { + flushLine(); + } + } + + @Override + public synchronized void flush() throws IOException { + flushLine(); + out.flush(); + } + + private void flushLine() throws IOException { + if (line.size() > 0) { + out.write(redact(line.toString(StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8)); + line.reset(); + } + } + + } + + static String env(String name, String dflt) { + String v = System.getenv(name); + return v == null || v.isBlank() ? dflt : v; + } + + static Duration seconds(String name, int dflt) { + return Duration.ofSeconds(Integer.parseInt(env(name, String.valueOf(dflt)))); + } + + private SmokeClient() { + } + +} diff --git a/integration-testing/programs/smoke/src/main/resources/logback.xml b/integration-testing/programs/smoke/src/main/resources/logback.xml new file mode 100644 index 00000000..56993aab --- /dev/null +++ b/integration-testing/programs/smoke/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + System.out + %d{HH:mm:ss.SSS} %-5level [%thread] %logger{0} - %msg%n + + + + diff --git a/integration-testing/programs/spring/launch/agent.sh b/integration-testing/programs/spring/launch/agent.sh new file mode 100755 index 00000000..3bc58b8e --- /dev/null +++ b/integration-testing/programs/spring/launch/agent.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Spring Boot smoke agent: [--web servlet|reactive] --transport stdio | --transport http|ws --port . +# Launcher contract: integration-testing/README.md, "Contracts". Prints nothing itself: on stdio, +# stdout is the protocol stream (the application logs to stderr). JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.spring.agent.SpringAgentApp "$@" diff --git a/integration-testing/programs/spring/launch/build.sh b/integration-testing/programs/spring/launch/build.sh new file mode 100755 index 00000000..4f3cff70 --- /dev/null +++ b/integration-testing/programs/spring/launch/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Builds the Spring Boot smoke programs against the SDK installed from this checkout, with the +# Spring Boot version of acp-spring-boot-starter. Launcher contract: integration-testing/README.md, +# "Contracts". Env: MVNW, ACP_VERSION, IT_ROOT. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +boot_version="$(sed -n 's:.*\(.*\).*:\1:p' "${IT_ROOT:?IT_ROOT is not set}/../acp-spring-boot-starter/pom.xml" | head -1)" +[ -n "$boot_version" ] || { echo "build.sh: no in acp-spring-boot-starter/pom.xml" >&2; exit 1; } +exec "${MVNW:?MVNW is not set}" -q -B -Dacp.version="${ACP_VERSION:?ACP_VERSION is not set}" \ + -Dspring-boot.version="$boot_version" compile dependency:build-classpath -Dmdep.outputFile=target/classpath.txt diff --git a/integration-testing/programs/spring/launch/client.sh b/integration-testing/programs/spring/launch/client.sh new file mode 100755 index 00000000..adf4c82b --- /dev/null +++ b/integration-testing/programs/spring/launch/client.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Starts the Spring Boot smoke client: --transport http|ws --url (env STEPS). The ACP client +# is the bean acp-spring-boot-starter builds from spring.acp.client.*. Launcher contract: +# integration-testing/README.md, "Contracts". JAVA_OPTS, if set, is passed on. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC2086 +exec java ${JAVA_OPTS:-} -cp "$here/target/classes:$(cat "$here/target/classpath.txt")" interop.spring.client.SpringClientApp "$@" diff --git a/integration-testing/programs/spring/pom.xml b/integration-testing/programs/spring/pom.xml new file mode 100644 index 00000000..c79e2d96 --- /dev/null +++ b/integration-testing/programs/spring/pom.xml @@ -0,0 +1,105 @@ + + + + 4.0.0 + + com.agentclientprotocol.integration + acp-integration-spring + 1 + + + + set-by-build.sh + set-by-build.sh + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + + + com.agentclientprotocol + acp-spring-boot-starter + ${acp.version} + + + + org.springframework.boot + spring-boot-starter-webmvc + + + com.agentclientprotocol + acp-http-servlet + ${acp.version} + + + + org.springframework.boot + spring-boot-starter-webflux + + + com.agentclientprotocol + acp-http-webflux + ${acp.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + framework-sources + generate-sources + + add-source + + + + ${project.basedir}/../framework/src/main/java + + + + + + + + diff --git a/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringAgentApp.java b/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringAgentApp.java new file mode 100644 index 00000000..baf7e75e --- /dev/null +++ b/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringAgentApp.java @@ -0,0 +1,86 @@ +package interop.spring.agent; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * The launcher contract (integration-testing/README.md, "Contracts") over a Spring Boot 4 + * application whose {@code @AcpAgent} bean ({@link SpringSmokeAgent}) acp-spring-boot-starter + * serves: {@code --transport stdio}, or {@code --transport http|ws --port }, which runs a + * web application on {@code server.port} with the endpoint at {@code /acp} (Streamable HTTP and + * the WebSocket upgrade on that one path) and prints {@code READY } once it accepts + * connections. {@code --web servlet} (the default) is a servlet web application with the SDK's + * servlet ({@code acp-http-servlet}) on Tomcat; {@code --web reactive} a WebFlux application with + * the SDK's WebFlux host ({@code acp-http-webflux}) on Reactor Netty. This class only maps the + * flags onto Spring properties. Over stdio the application is not a web application, is kept + * alive by {@code spring.main.keep-alive} and exits when its input ends + * ({@code spring.acp.agent.shutdown-on-transport-end}); over HTTP it runs until SIGTERM. + */ +@SpringBootApplication +public class SpringAgentApp { + + public static void main(String[] args) { + String transport = null; + String port = "0"; + String web = "servlet"; + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--transport" -> transport = value(args, ++i); + case "--port" -> port = value(args, ++i); + case "--web" -> web = value(args, ++i); + default -> usage("unknown flag " + args[i]); + } + } + if (transport == null) { + usage("--transport is required"); + } + if (!"servlet".equals(web) && !"reactive".equals(web)) { + usage("unsupported --web " + web + " (servlet or reactive)"); + } + boolean stdio = "stdio".equals(transport); + if (!stdio && !"http".equals(transport) && !"ws".equals(transport)) { + usage("unsupported transport " + transport + " (the Spring agent serves stdio, http and ws)"); + } + Map properties = new HashMap<>(); + properties.put("spring.main.banner-mode", "off"); + if (stdio) { + properties.put("spring.main.web-application-type", "none"); + properties.put("spring.main.keep-alive", "true"); + properties.put("spring.acp.agent.transport.type", "stdio"); + } + else { + properties.put("spring.main.web-application-type", web); + properties.put("server.address", "127.0.0.1"); + properties.put("server.port", port); + // http and ws are one endpoint: the host takes the WebSocket upgrade on its path. + properties.put("spring.acp.agent.transport.type", "ws".equals(transport) ? "websocket" : "http"); + properties.put("spring.acp.agent.transport.http.path", "/acp"); + } + SpringApplication application = new SpringApplication(SpringAgentApp.class); + application.setDefaultProperties(properties); + application.setLogStartupInfo(false); + ConfigurableApplicationContext context = application.run(); + if (!stdio) { + System.out.println("READY " + context.getEnvironment().getProperty("local.server.port")); + System.out.flush(); + } + } + + private static String value(String[] args, int i) { + if (i >= args.length) { + usage(args[i - 1] + " needs a value"); + } + return args[i]; + } + + private static void usage(String problem) { + System.err.println(problem); + System.err.println("usage: agent.sh [--web servlet|reactive] --transport stdio | --transport http|ws --port "); + System.exit(2); + } + +} diff --git a/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringSmokeAgent.java b/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringSmokeAgent.java new file mode 100644 index 00000000..5a34c172 --- /dev/null +++ b/integration-testing/programs/spring/src/main/java/interop/spring/agent/SpringSmokeAgent.java @@ -0,0 +1,12 @@ +package interop.spring.agent; + +import com.agentclientprotocol.sdk.annotation.AcpAgent; +import interop.framework.SmokeAgent; +import org.springframework.stereotype.Component; + +/** The shared smoke agent as a Spring bean: acp-spring-boot-starter finds it by its annotation. */ +@Component +@AcpAgent(name = "interop-spring-agent", version = "1") +public class SpringSmokeAgent extends SmokeAgent { + +} diff --git a/integration-testing/programs/spring/src/main/java/interop/spring/client/SpringClientApp.java b/integration-testing/programs/spring/src/main/java/interop/spring/client/SpringClientApp.java new file mode 100644 index 00000000..5b14e9ee --- /dev/null +++ b/integration-testing/programs/spring/src/main/java/interop/spring/client/SpringClientApp.java @@ -0,0 +1,51 @@ +package interop.spring.client; + +import java.util.HashMap; +import java.util.Map; + +import com.agentclientprotocol.sdk.client.AcpAsyncClient; +import com.agentclientprotocol.sdk.integration.AcpClientCustomizer; +import interop.framework.SmokeClient; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; + +/** + * The client launcher contract over a Spring Boot 4 application: acp-spring-boot-starter builds the + * ACP client bean from {@code spring.acp.client.*} (the transport URI and the capabilities it + * advertises), the {@link SmokeClient} customizer bean registers its handlers, and the steps in + * {@code STEPS} run on that bean. The context is closed once the RESULT line is printed. + */ +@SpringBootApplication +public class SpringClientApp { + + @Bean + SmokeClient smokeClient() { + return new SmokeClient(); + } + + @Bean + AcpClientCustomizer smokeClientHandlers(SmokeClient smokeClient) { + return smokeClient::customize; + } + + public static void main(String[] args) throws Exception { + String[] parsed = SmokeClient.args(args); + Map properties = new HashMap<>(); + properties.put("spring.main.banner-mode", "off"); + properties.put("spring.main.web-application-type", "none"); + properties.put("ws".equals(parsed[0]) ? "spring.acp.client.transport.websocket.uri" + : "spring.acp.client.transport.http.uri", parsed[1]); + properties.put("spring.acp.client.capabilities.read-text-file", "true"); + properties.put("spring.acp.client.capabilities.elicitation-form", "true"); + SpringApplication application = new SpringApplication(SpringClientApp.class); + application.setDefaultProperties(properties); + application.setLogStartupInfo(false); + try (ConfigurableApplicationContext context = application.run()) { + context.getBean(SmokeClient.class).run(context.getBean(AcpAsyncClient.class), SmokeClient.steps()); + } + System.exit(0); + } + +} diff --git a/integration-testing/programs/spring/src/main/resources/logback.xml b/integration-testing/programs/spring/src/main/resources/logback.xml new file mode 100644 index 00000000..f0e5fff4 --- /dev/null +++ b/integration-testing/programs/spring/src/main/resources/logback.xml @@ -0,0 +1,13 @@ + + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/integration-testing/scripts/m2-repo.sh b/integration-testing/scripts/m2-repo.sh new file mode 100644 index 00000000..3f48748b --- /dev/null +++ b/integration-testing/scripts/m2-repo.sh @@ -0,0 +1,21 @@ +# shellcheck shell=bash +# Sourced, not run. Resolves the one Maven local repository the suite uses and exports it: +# IT_M2_REPO if set, used as is (made absolute); +# else ~/.m2/repository when CI=true (keeps the CI cache); +# else integration-testing/.cache/m2 (per checkout, git-ignored). +# JBANG_REPO = IT_M2_REPO, so JBang resolves into the same repository. +# Every Maven call of the suite goes through scripts/mvnw.sh, which passes +# -Dmaven.repo.local="$IT_M2_REPO". See README.md, Contracts section 7. +_it_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ -z "${IT_M2_REPO:-}" ]; then + if [ "${CI:-}" = "true" ]; then + IT_M2_REPO="$HOME/.m2/repository" + else + IT_M2_REPO="$_it_dir/.cache/m2" + fi +fi +mkdir -p "$IT_M2_REPO" +IT_M2_REPO="$(cd "$IT_M2_REPO" && pwd)" +export IT_M2_REPO +export JBANG_REPO="$IT_M2_REPO" +unset _it_dir diff --git a/integration-testing/scripts/mvnw.sh b/integration-testing/scripts/mvnw.sh new file mode 100755 index 00000000..73ae6477 --- /dev/null +++ b/integration-testing/scripts/mvnw.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# The Maven wrapper of this checkout, pinned to the suite's local repository (IT_M2_REPO, resolved +# by m2-repo.sh). Every Maven call of the suite (SDK install, program builds) goes through this, +# so a SNAPSHOT is installed into and resolved from one repository only. Runs in the caller's +# directory, like ./mvnw. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=m2-repo.sh +. "$here/m2-repo.sh" +exec "$here/../../mvnw" -Dmaven.repo.local="$IT_M2_REPO" "$@" diff --git a/integration-testing/scripts/peer-sha.sh b/integration-testing/scripts/peer-sha.sh new file mode 100755 index 00000000..1366cbf5 --- /dev/null +++ b/integration-testing/scripts/peer-sha.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# +# Print "sha=" for a peer SDK at the ref a run will test, for CI cache keys: +# +# integration-testing/scripts/peer-sha.sh "" +# +# is the space-separated "=" list run-all.sh takes as --peer; the +# peer's entry wins over , and an empty means the ref in peers.json. +# A ref that git ls-remote cannot resolve (a SHA, say) is +# printed as given. +set -euo pipefail +peer="$1" ref="$2" overrides="${3:-}" +for o in $overrides; do + case "$o" in "$peer="*) ref="${o#*=}" ;; esac +done +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +url="$(jq -r --arg n "$peer" '.[$n].url // empty' "$here/peers.json")" +[ -n "$url" ] || { echo "unknown peer $peer (see peers.json)" >&2; exit 2; } +[ -n "$ref" ] || ref="$(jq -r --arg n "$peer" '.[$n].ref // "main"' "$here/peers.json")" +sha="$(git ls-remote "$url" "refs/heads/$ref" "refs/tags/$ref" | head -1 | cut -f1)" +echo "peer $peer at $ref: ${sha:-$ref}" >&2 +echo "sha=${sha:-$ref}" diff --git a/integration-testing/scripts/run-all.sh b/integration-testing/scripts/run-all.sh index eac9eb98..0ce26ecc 100755 --- a/integration-testing/scripts/run-all.sh +++ b/integration-testing/scripts/run-all.sh @@ -1,15 +1,35 @@ #!/usr/bin/env bash # -# Run every cross-SDK and load scenario and print a pass/fail table. +# Run cross-SDK and load scenarios and print a pass/fail table. # -# integration-testing/scripts/run-all.sh # peers at main +# integration-testing/scripts/run-all.sh # the default set, peers at main # integration-testing/scripts/run-all.sh --peers-ref v1.0.0 # every peer at a tag # integration-testing/scripts/run-all.sh --peer typescript-sdk=v0.5.0 # one peer at a tag -# integration-testing/scripts/run-all.sh --only interop-ts-server,load-50 +# integration-testing/scripts/run-all.sh --only x-java-typescript-http,load-50 # names +# integration-testing/scripts/run-all.sh --only 'x-*-python-*,x-python-*' # globs +# integration-testing/scripts/run-all.sh --tag python,stdio # dash-separated name tokens +# integration-testing/scripts/run-all.sh --exclude 'load-*' --list # print the selection only +# integration-testing/scripts/run-all.sh --jobs 1 # one scenario at a time +# integration-testing/scripts/run-all.sh --tag rust --prepare-only # install, fetch and build only # -# Installs the SDK from this checkout once (./mvnw -DskipTests install), then runs each scenario -# with --skip-sdk-install. Exits non-zero if any scenario fails; a declared expected failure -# (for example the Java client -> Python server reconnect-then-load 404) does not fail the run. +# Selection: with no --only/--tag, every configs/*.json. --only takes comma-separated names or +# globs; --tag takes comma-separated tags, where a scenario's tags are the dash-separated tokens of +# its name (x-java-python-stdio has x, java, python, stdio). --only and --tag together select +# the union; --exclude (globs) removes from it. self-*, smoke-* and *-unstable scenarios are left +# out unless a pattern or tag targets them (--only 'self-*', --only '*-unstable', --tag self, +# --tag smoke, --tag unstable) or names one exactly. An empty selection fails unless --allow-empty. +# +# Installs the SDK from this checkout once (./mvnw -DskipTests install), prepares every peer and +# build the selection needs once (RunScenario --prepare), then runs the scenarios with --prepared, +# --jobs at a time (default: half the CPUs, at most 8), each with its own log directory, temp +# directory and port range; load-* scenarios run afterwards, alone and serially. Exits non-zero +# if any scenario fails; a declared expected failure (for example the Java client -> Python server +# reconnect-then-load 404) does not fail the run. +# +# Concurrent run-alls (several checkouts on one host): each takes an exclusive 10,000-port block +# (IT_PORT_BASE, else the first free of 20000/30000/40000/50000, locked with flock for the whole +# run), and every Maven and JBang call uses one local repository, IT_M2_REPO (default +# integration-testing/.cache/m2; ~/.m2/repository when CI=true). See README.md, Contracts 7. set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -18,20 +38,122 @@ REPO_DIR="$(cd "$IT_DIR/.." && pwd)" cd "$IT_DIR" ONLY="" +TAGS="" +EXCLUDE="" +LIST=0 +ALLOW_EMPTY=0 +PREPARE_ONLY=0 +CPUS="$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)" +JOBS=$((CPUS / 2)) +[ "$JOBS" -gt 8 ] && JOBS=8 +[ "$JOBS" -lt 1 ] && JOBS=1 PASS_ARGS=() while [ $# -gt 0 ]; do case "$1" in - --only) ONLY="$2"; shift 2 ;; - --only=*) ONLY="${1#--only=}"; shift ;; + --only) ONLY="${ONLY:+$ONLY,}$2"; shift 2 ;; + --only=*) ONLY="${ONLY:+$ONLY,}${1#--only=}"; shift ;; + --tag) TAGS="${TAGS:+$TAGS,}$2"; shift 2 ;; + --tag=*) TAGS="${TAGS:+$TAGS,}${1#--tag=}"; shift ;; + --exclude) EXCLUDE="${EXCLUDE:+$EXCLUDE,}$2"; shift 2 ;; + --exclude=*) EXCLUDE="${EXCLUDE:+$EXCLUDE,}${1#--exclude=}"; shift ;; + --list) LIST=1; shift ;; + --prepare-only) PREPARE_ONLY=1; shift ;; + --jobs|-j) JOBS="$2"; shift 2 ;; + --jobs=*) JOBS="${1#--jobs=}"; shift ;; + --allow-empty) ALLOW_EMPTY=1; shift ;; --peer|--peers-ref) PASS_ARGS+=("$1" "$2"); shift 2 ;; --peer=*|--peers-ref=*) PASS_ARGS+=("$1"); shift ;; - -h|--help) sed -n '2,13p' "$0"; exit 0 ;; + -h|--help) sed -n '2,32p' "$0"; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done +# Order: the generated cells and conformance scenarios, then load, then anything else. Load +# scenarios keep their historical order; the rest of each group is alphabetical. +ORDERED=() +add() { [ -e "configs/$1.json" ] && [[ " ${ORDERED[*]} " != *" $1 "* ]] && ORDERED+=("$1"); return 0; } +for group in 'x-*' 'conf-*' load-50 load-300 load-1000 load-shared-300 'load-*' '*'; do + for f in configs/$group.json; do add "$(basename "$f" .json)"; done +done + +# Default-excluded classes and whether a pattern or tag targets them. +excluded_class() { + case "$1" in self-*) echo self ;; smoke-*) echo smoke ;; *-unstable) echo unstable ;; *) echo "" ;; esac +} +targets_class() { # + case "$1" in + self) [[ "$2" == self-* ]] ;; + smoke) [[ "$2" == smoke-* ]] ;; + unstable) [[ "$2" == *-unstable ]] ;; + esac +} + +IFS=',' read -r -a ONLY_PATS <<< "$ONLY" +IFS=',' read -r -a TAG_LIST <<< "$TAGS" +IFS=',' read -r -a EXCLUDE_PATS <<< "$EXCLUDE" +SCENARIOS=() +for s in "${ORDERED[@]}"; do + cls="$(excluded_class "$s")" + picked=0 + if [ -z "$ONLY" ] && [ -z "$TAGS" ]; then + [ -z "$cls" ] && picked=1 + else + for p in "${ONLY_PATS[@]}"; do + [ -n "$p" ] || continue + # shellcheck disable=SC2053 + if [[ "$s" == $p ]]; then + if [ -z "$cls" ] || [ "$s" = "$p" ] || targets_class "$cls" "$p"; then picked=1; fi + fi + done + IFS='-' read -r -a tokens <<< "$s" + for t in "${TAG_LIST[@]}"; do + [ -n "$t" ] || continue + if [[ " ${tokens[*]} " == *" $t "* ]]; then + if [ -z "$cls" ] || [ "$t" = "$cls" ]; then picked=1; fi + fi + done + fi + for p in "${EXCLUDE_PATS[@]}"; do + # shellcheck disable=SC2053 + [ -n "$p" ] && [[ "$s" == $p ]] && picked=0 + done + [ "$picked" -eq 1 ] && SCENARIOS+=("$s") +done +# A name given exactly that has no config is an error, not an empty match. +for p in "${ONLY_PATS[@]}"; do + if [ -n "$p" ] && [[ "$p" != *[\*\?\[]* ]] && [ ! -e "configs/$p.json" ]; then + echo "No such scenario: $p (see configs/)" >&2 + exit 2 + fi +done +case "$JOBS" in ''|*[!0-9]*|0) echo "--jobs needs a positive number, got '$JOBS'" >&2; exit 2 ;; esac +if [ ${#SCENARIOS[@]} -eq 0 ]; then + if [ "$ALLOW_EMPTY" -eq 1 ]; then + echo "No scenario matches the selection (--allow-empty): nothing to run" + exit 0 + fi + echo "No scenario matches the selection (only='$ONLY' tag='$TAGS' exclude='$EXCLUDE')" >&2 + exit 2 +fi +if [ "$LIST" -eq 1 ]; then + printf '%s\n' "${SCENARIOS[@]}" + exit 0 +fi + +# Prerequisites of the selected scenarios only (each CI job installs one peer's toolchain). +need=(jbang java jcmd git) +for s in "${SCENARIOS[@]}"; do + langs="$(grep -o '"language": *"[a-z]*"' "configs/$s.json" | sed 's/.*"\([a-z]*\)"$/\1/' | sort -u)" + for l in $langs; do + case "$l" in + node|typescript) need+=(node npm) ;; + rust) need+=(cargo) ;; + python) need+=(python3) ;; + esac + done +done missing=() -for tool in jbang java jcmd node npm cargo python3 git; do +for tool in $(printf '%s\n' "${need[@]}" | sort -u); do command -v "$tool" > /dev/null 2>&1 || missing+=("$tool") done if [ ${#missing[@]} -gt 0 ]; then @@ -39,24 +161,12 @@ if [ ${#missing[@]} -gt 0 ]; then exit 2 fi -# Interop first (fast, and a broken transport shows there first), then load. -SCENARIOS=( - interop-java-java - interop-ts-server interop-rust-server interop-python-server - interop-ts-client interop-rust-client interop-python-client - load-50 load-300 load-1000 load-shared-300 -) -for f in configs/*.json; do - s="$(basename "$f" .json)" - [[ " ${SCENARIOS[*]} " == *" $s "* ]] || SCENARIOS+=("$s") -done -if [ -n "$ONLY" ]; then - IFS=',' read -r -a SCENARIOS <<< "$ONLY" -fi - mkdir -p logs/run-all -echo "Installing the SDK from $REPO_DIR" -if ! (cd "$REPO_DIR" && timeout 900 ./mvnw -q -B -DskipTests install) > logs/run-all/sdk-install.log 2>&1; then +# One Maven local repository for the SDK install, every program build and JBang (IT_M2_REPO). +# shellcheck source=m2-repo.sh +. "$SCRIPT_DIR/m2-repo.sh" +echo "Installing the SDK from $REPO_DIR into $IT_M2_REPO" +if ! (cd "$REPO_DIR" && timeout 900 "$SCRIPT_DIR/mvnw.sh" -q -B -DskipTests install) > logs/run-all/sdk-install.log 2>&1; then echo "SDK install failed; see $IT_DIR/logs/run-all/sdk-install.log" >&2 tail -40 logs/run-all/sdk-install.log >&2 exit 1 @@ -67,21 +177,112 @@ timeout 600 jbang build RunScenario.java > logs/run-all/jbang-build.log 2>&1 || START=$(date +%s) declare -A STATUS SECS NOTES FAILED=0 + +# Phase 1, serial: clone, fetch and build every peer and program the selection needs, once. +# The scenarios then run with --prepared, which touches no shared state (no git, no build). +echo +echo "Preparing peers and builds for ${#SCENARIOS[@]} scenario(s)" +if ! timeout 3600 jbang RunScenario.java --prepare "${SCENARIOS[@]}" "${PASS_ARGS[@]}" \ + > logs/run-all/prepare.console.log 2>&1; then + echo "WARN preparation failed for some scenarios (they will fail); see logs/run-all/prepare.console.log" >&2 + grep -E 'FAIL|Exception' logs/run-all/prepare.console.log >&2 || true + [ "$PREPARE_ONLY" -eq 1 ] && exit 1 +fi +# A scenario whose preparation failed is not run: it would use whatever an earlier build left. +declare -A UNPREPARED +while read -r s; do UNPREPARED[$s]=1; done \ + < <(sed -n 's/^ FAIL preparing \([^:]*\):.*/\1/p' logs/run-all/prepare.console.log) +if [ "$PREPARE_ONLY" -eq 1 ]; then + tail -1 logs/run-all/prepare.console.log + exit 0 +fi + +# This run's port block, 10,000 ports no other run-all on this host uses at the same time: +# IT_PORT_BASE if set, else the first of 20000/30000/40000/50000 whose lock file we can flock. +# The lock is held by file descriptor PORT_LOCK_FD for the life of this shell (children inherit +# it, so a block stays taken while anything this run started is alive) and released when the +# last holder exits, however it exits. All four taken: wait for one. +PORT_BASE="${IT_PORT_BASE:-}" +if [ -n "$PORT_BASE" ]; then + case "$PORT_BASE" in *[!0-9]*) echo "IT_PORT_BASE needs a number, got '$PORT_BASE'" >&2; exit 2 ;; esac +elif ! command -v flock > /dev/null 2>&1; then + PORT_BASE=20000 + echo "WARN flock not found: using port block 20000 unlocked (set IT_PORT_BASE for concurrent runs)" >&2 +else + lock_dir="${XDG_RUNTIME_DIR:-/tmp}" + waited=0 + while [ -z "$PORT_BASE" ]; do + for base in 20000 30000 40000 50000; do + exec {PORT_LOCK_FD}>> "$lock_dir/acp-it-ports-$base.lock" || continue + if flock -n "$PORT_LOCK_FD"; then PORT_BASE=$base; break; fi + exec {PORT_LOCK_FD}>&- + done + if [ -z "$PORT_BASE" ]; then + [ "$waited" -eq 0 ] && echo "All port blocks are in use by other runs; waiting for one" + waited=1 + sleep 5 + fi + done +fi +echo "Port block $PORT_BASE-$((PORT_BASE + 9999))" + +# One scenario: its own log directory (logs//), its own ${TMP}, and a port range no other +# scenario of this run uses (PORT_BASE + 100 * (index mod 100), 100 ports wide). +run_one() { # + local s="$1" idx="$2" from=$((PORT_BASE + ($2 % 100) * 100)) + rm -f "logs/$s/result.txt" "logs/run-all/$s.exit" + if [ -n "${UNPREPARED[$s]:-}" ]; then + echo " FAIL $s (preparation failed; see logs/run-all/prepare.console.log)" + echo "prepare" > "logs/run-all/$s.exit" + return + fi + local cmd=(env IT_PORT_RANGE="$from-$((from + 99))" + timeout 1800 jbang RunScenario.java "$s" --prepared "${PASS_ARGS[@]}") + if [ "$3" -eq 1 ]; then + echo + echo "################ $s" + "${cmd[@]}" 2>&1 | tee "logs/run-all/$s.console.log" + echo "${PIPESTATUS[0]}" > "logs/run-all/$s.exit" + else + "${cmd[@]}" > "logs/run-all/$s.console.log" 2>&1 + local code=$? + echo "$code" > "logs/run-all/$s.exit" + echo " $(sed -n 's/^status=//p' "logs/$s/result.txt" 2>/dev/null || echo "exit $code") $s" \ + "($(sed -n 's/^seconds=//p' "logs/$s/result.txt" 2>/dev/null || echo '?') s)" + fi +} + +# Phase 2: everything except load-*, JOBS at a time. Phase 3: load-* alone, serially, since they +# measure throughput and thread and heap use. +CONCURRENT=() +SERIAL=() +for i in "${!SCENARIOS[@]}"; do + case "${SCENARIOS[$i]}" in load-*) SERIAL+=("$i") ;; *) CONCURRENT+=("$i") ;; esac +done +if [ "$JOBS" -gt 1 ] && [ ${#CONCURRENT[@]} -gt 1 ]; then + echo "Running ${#CONCURRENT[@]} scenario(s), $JOBS at a time; consoles in logs/run-all/.console.log" + for i in "${CONCURRENT[@]}"; do + while [ "$(jobs -rp | wc -l)" -ge "$JOBS" ]; do wait -n; done + run_one "${SCENARIOS[$i]}" "$i" 0 & + done + wait +else + for i in "${CONCURRENT[@]}"; do run_one "${SCENARIOS[$i]}" "$i" 1; done +fi +for i in "${SERIAL[@]}"; do run_one "${SCENARIOS[$i]}" "$i" 1; done + for s in "${SCENARIOS[@]}"; do - echo - echo "################ $s" - rm -f "logs/$s/result.txt" - timeout 1800 jbang RunScenario.java "$s" --skip-sdk-install "${PASS_ARGS[@]}" 2>&1 | tee "logs/run-all/$s.console.log" - code=${PIPESTATUS[0]} + code="$(cat "logs/run-all/$s.exit" 2>/dev/null || echo '?')" if [ -f "logs/$s/result.txt" ]; then STATUS[$s]="$(sed -n 's/^status=//p' "logs/$s/result.txt")" xfail="$(sed -n 's/^xfail=//p' "logs/$s/result.txt")" [ "${STATUS[$s]}" = "PASS" ] && [ "${xfail:-0}" != "0" ] && STATUS[$s]="PASS ($xfail xfail)" SECS[$s]="$(sed -n 's/^seconds=//p' "logs/$s/result.txt")" NOTES[$s]="$(sed -n 's/^notes=//p' "logs/$s/result.txt")" - [ "$code" -ne 0 ] && [ "${STATUS[$s]}" != "FAIL" ] && STATUS[$s]="FAIL (exit $code)" + [ "$code" != "0" ] && [ "${STATUS[$s]}" != "FAIL" ] && STATUS[$s]="FAIL (exit $code)" else STATUS[$s]="FAIL (exit $code, no result)" + [ "$code" = "prepare" ] && STATUS[$s]="FAIL (prepare)" SECS[$s]="-" NOTES[$s]="" fi @@ -90,13 +291,13 @@ done TOTAL=$(( $(date +%s) - START )) table() { - printf '%-24s %-16s %8s %s\n' "Scenario" "Result" "Time (s)" "Notes" - printf '%-24s %-16s %8s %s\n' "------------------------" "----------------" "--------" "-----" + printf '%-32s %-16s %8s %s\n' "Scenario" "Result" "Time (s)" "Notes" + printf '%-32s %-16s %8s %s\n' "--------------------------------" "----------------" "--------" "-----" for s in "${SCENARIOS[@]}"; do - printf '%-24s %-16s %8s %s\n' "$s" "${STATUS[$s]}" "${SECS[$s]}" "${NOTES[$s]}" + printf '%-32s %-16s %8s %s\n' "$s" "${STATUS[$s]}" "${SECS[$s]}" "${NOTES[$s]}" done echo - echo "${#SCENARIOS[@]} scenarios, $FAILED failed, ${TOTAL} s total" + echo "${#SCENARIOS[@]} scenarios, $FAILED failed, ${TOTAL} s total (--jobs $JOBS)" } echo @@ -104,7 +305,7 @@ echo "========================================================================" table | tee logs/run-all/summary.txt if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { - echo "### Cross-SDK and load scenarios" + echo "### Cross-SDK and load scenarios${RUN_LABEL:+ ($RUN_LABEL)}" echo echo "| Scenario | Result | Time (s) | Notes |" echo "|---|---|---|---|" diff --git a/integration-testing/smoke.json b/integration-testing/smoke.json new file mode 100644 index 00000000..8d37b1f2 --- /dev/null +++ b/integration-testing/smoke.json @@ -0,0 +1,96 @@ +{ + "comment": "The framework smoke matrix: a release gate that runs the SDK hosted by Spring Boot (servlet and WebFlux), Micronaut and Quarkus against the TypeScript SDK. GenConfigs.java writes configs/fw-*.json and configs/load-10-*.json from this file and steps.json (README.md, 'Framework smoke matrix'). agentSteps run against each framework-hosted agent, clientSteps on each framework-built client, in steps.json order and filtered by each step's transports; init.initialize and conn.close are always included.", + "peer": "typescript", + "agentSteps": [ + "init.initialize", + "init.agent-capabilities", + "init.agent-info", + "session.new", + "session.load", + "session.multi", + "update.agent_message_chunk", + "perm.selected", + "elicit.form", + "cancel.prompt", + "ext.client-request", + "error.method-not-found", + "http.reconnect", + "stdio.eof-exit", + "conn.close" + ], + "clientSteps": [ + "init.initialize", + "init.agent-info", + "session.new", + "update.agent_message_chunk", + "perm.selected", + "fs.read", + "elicit.form", + "cancel.prompt", + "ext.client-request", + "conn.close" + ], + "frameworks": { + "spring": { + "dir": "programs/spring", + "agentTransports": [ + "stdio", + "http", + "ws" + ], + "clientTransports": [ + "http" + ], + "readyTimeoutSec": 90, + "comment": "http and ws are the SDK servlet (acp-http-servlet) mounted on server.port in a servlet web application: Streamable HTTP and the WebSocket upgrade on one path." + }, + "spring-webflux": { + "dir": "programs/spring", + "agentArgs": "--web reactive", + "agentName": "spring", + "agentTransports": [ + "http", + "ws" + ], + "clientTransports": [], + "readyTimeoutSec": 90, + "comment": "The same Spring program as a reactive web application (--web reactive): the SDK's WebFlux host (acp-http-webflux) routed on server.port by Reactor Netty, Streamable HTTP and the WebSocket upgrade on one path. agentArgs go to the program's agent.sh before --transport; agentName is the @AcpAgent name the initialize step expects (interop--agent)." + }, + "micronaut": { + "dir": "programs/micronaut", + "agentTransports": [ + "stdio", + "http", + "ws" + ], + "clientTransports": [ + "http" + ], + "readyTimeoutSec": 90 + }, + "quarkus": { + "dir": "programs/quarkus", + "agentTransports": [ + "stdio", + "http", + "ws" + ], + "clientTransports": [ + "http" + ], + "readyTimeoutSec": 90 + } + }, + "load": { + "clients": 10, + "prompts": 10, + "serverJavaOpts": "-Xmx512m", + "checks": [ + "clients RESULT errors == 0", + "clients RESULT ok == expected", + "clients RESULT expected == 100", + "clients RESULT updates == 2*ok", + "clients RESULT close_errors == 0" + ] + } +} diff --git a/integration-testing/steps.json b/integration-testing/steps.json new file mode 100644 index 00000000..c64f8781 --- /dev/null +++ b/integration-testing/steps.json @@ -0,0 +1,569 @@ +{ + "comment": "The step catalogue: the contract every interop agent and client program implements. README.md (Contracts) explains the conventions; GenConfigs.java reads this file. Step ids are stable: never rename one, add a new id instead. Order matters: the generator runs steps in this order.", + "version": 1, + "conventions": { + "stepTimeoutMs": 15000, + "replayGraceMs": 2000, + "updateGraceMs": 1000, + "placeholders": { + "{dir}": "An absolute scratch directory the client creates once per run (no spaces). Agent and client run on the same host, so both can read and write it." + }, + "first": "init.initialize", + "last": "conn.close" + }, + "fixtures": { + "client": { + "protocolVersion": 1, + "clientCapabilities": { + "fs": { "readTextFile": true, "writeTextFile": true }, + "terminal": true, + "elicitation": { "form": {}, "url": {} }, + "auth": { "terminal": true }, + "session": { "configOptions": { "boolean": {} } } + }, + "clientInfo": { "name": "interop--client", "version": "1" } + }, + "agent": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + "sessionCapabilities": { "list": {}, "resume": {}, "close": {}, "delete": {} }, + "auth": { "logout": {} } + }, + "authMethods": [ + { "id": "interop-auth", "name": "Interop auth", "description": "Accepts any authenticate call" } + ], + "terminalAuthMethod": { "type": "terminal", "id": "interop-terminal-auth", "name": "Interop terminal auth", "args": ["--login"] }, + "agentInfo": { "name": "interop--agent", "version": "1" } + }, + "modes": { + "currentModeId": "interop-mode-a", + "availableModes": [ + { "id": "interop-mode-a", "name": "Mode A" }, + { "id": "interop-mode-b", "name": "Mode B" } + ] + }, + "configOptions": [ + { "id": "model", "name": "Model", "type": "select", "currentValue": "model-a", + "options": [ { "value": "model-a", "name": "Model A" }, { "value": "model-b", "name": "Model B" } ] }, + { "id": "verbose", "name": "Verbose", "type": "boolean", "currentValue": false, "requires": "client.session.configOptions.boolean" } + ], + "permission": { + "toolCall": { "toolCallId": "perm-1", "title": "interop permission", "kind": "edit", "status": "pending" }, + "options": [ + { "optionId": "allow", "name": "Allow", "kind": "allow_once" }, + { "optionId": "reject", "name": "Reject", "kind": "reject_once" } + ], + "clientRule": "Answer `cancelled` if this session has a cancel in flight (perm.cancelled); otherwise select the first option whose kind is allow_once, else options[0]." + }, + "fsReadContent": "line1\nline2\nline3\n", + "emit": { + "user_message_chunk": { "content": { "type": "text", "text": "user-chunk" } }, + "agent_thought_chunk": { "content": { "type": "text", "text": "thinking" } }, + "tool_call": { "toolCallId": "call-1", "title": "interop tool", "kind": "read", "status": "pending" }, + "tool_call_update": { "toolCallId": "call-1", "status": "completed", "content": [ { "type": "content", "content": { "type": "text", "text": "tool output" } } ] }, + "plan": { "entries": [ { "content": "step one", "priority": "high", "status": "pending" }, { "content": "step two", "priority": "low", "status": "completed" } ] }, + "available_commands_update": { "availableCommands": [ { "name": "interop", "description": "interop command", "input": { "hint": "args" } } ] }, + "current_mode_update": { "currentModeId": "interop-mode-b" }, + "config_option_update": { "configOptions": "fixtures.configOptions with model currentValue model-b" }, + "session_info_update": { "title": "interop title" }, + "usage_update": { "used": 100, "size": 1000, "cost": { "amount": 0.01, "currency": "USD" } }, + "unknown": { "sessionUpdate": "interop_future_update", "payload": { "x": 1 } } + }, + "ext": { "method": "_interop/ping", "notification": "_interop/note", "params": { "n": 1 }, "result": { "pong": 1 } }, + "meta": { "key": "interop", "value": "m1" }, + "unknownEnums": { + "toolCall": { "sessionUpdate": "tool_call", "toolCallId": "call-enum", "title": "interop enum tool", "kind": "interop_future_kind", "status": "interop_future_status" }, + "plan": { "sessionUpdate": "plan", "entries": [ { "content": "future entry", "priority": "interop_future_priority", "status": "interop_future_status" } ] }, + "stopReason": "interop_future_stop", + "audience": ["user", "interop_future_role"] + }, + "groupedConfigOption": { "id": "effort", "name": "Effort", "type": "select", "currentValue": "effort-low", + "options": [ + { "group": "fast", "name": "Fast", "options": [ { "value": "effort-low", "name": "Low" } ] }, + { "group": "deep", "name": "Deep", "options": [ { "value": "effort-medium", "name": "Medium" }, { "value": "effort-high", "name": "High" } ] } + ] }, + "elicitation": { + "form": { "message": "interop form", "requestedSchema": { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } }, + "formAnswer": { "action": "accept", "content": { "name": "interop" } }, + "url": { "elicitationId": "elic-1", "url": "https://example.invalid/interop", "message": "interop url" } + } + }, + "directives": [ + { "syntax": "", "agent": "Send two agent_message_chunk updates, \"echo: \" then the prompt text, and answer end_turn. Record the turn in the session's history (for session.load-replay)." }, + { "syntax": "#permission allow", "agent": "session/request_permission with fixtures.permission. Then send the chunk \"permission: selected \" (or \"permission: cancelled\") and answer end_turn. Print STEP agent.perm.selected PASS iff the outcome is selected/allow." }, + { "syntax": "#permission allow meta", "agent": "As allow, with _meta {\"interop\": \"m1\"} (fixtures.meta) on the permission request. Print STEP agent.meta.permission PASS iff the response carried _meta interop == m1 (and no agent.perm.selected line)." }, + { "syntax": "#permission hold", "agent": "As allow, but the client cancels the turn while the request is pending. Print STEP agent.perm.cancelled PASS iff the outcome is cancelled, and answer stopReason cancelled." }, + { "syntax": "#fs write ", "agent": "Require client.fs.writeTextFile. fs/write_text_file {path, content}; accept {} or a null result. Send the chunk \"fs write ok\" (or \"fs write error \") and answer end_turn." }, + { "syntax": "#fs read ", "agent": "Require client.fs.readTextFile. fs/read_text_file; send the returned content as one chunk (or \"fs read error \") and answer end_turn. Print STEP agent.fs.read." }, + { "syntax": "#fs read-range [line=] [limit=]", "agent": "As read, passing line and limit. Print STEP agent.fs.read-range." }, + { "syntax": "#fs read-missing ", "agent": "As read, for a file that does not exist. Print STEP agent.fs.read-missing." }, + { "syntax": "#fs read-slow ", "agent": "fs/read_text_file, then after 200 ms send $/cancel_request for that request. Chunk \"cancel-request sent\"; answer end_turn." }, + { "syntax": "#emit [name=]", "agent": "Send one session/update of from fixtures.emit (tool_call_update is preceded by its tool_call; unknown is followed by the chunk \"after-unknown\"; name= sets the tool call name), then answer end_turn." }, + { "syntax": "#stop ", "agent": "Send the chunk \"stop\" and answer with stopReason ." }, + { "syntax": "#slow [grace=]", "agent": "Send the chunk \"tick\" every 100 ms. On session/cancel keep ticking for grace ms (default 0), then answer cancelled. Without a cancel, answer end_turn after 10 s." }, + { "syntax": "#hang", "agent": "Never answer and ignore session/cancel (the SDK's own cancel grace, or its maxPromptDuration, must answer)." }, + { "syntax": "#terminal run [args...]", "agent": "Require client.terminal. terminal/create, terminal/wait_for_exit, terminal/output, terminal/release. Chunk \"terminal: exit=\"." }, + { "syntax": "#terminal kill [args...]", "agent": "terminal/create, wait 200 ms, terminal/kill, terminal/wait_for_exit, terminal/release. Chunk \"terminal killed\"." }, + { "syntax": "#elicit form", "agent": "elicitation/create with fixtures.elicitation.form. Chunk \"elicit: \"." }, + { "syntax": "#elicit url", "agent": "elicitation/create (url mode, fixtures.elicitation.url), then elicitation/complete {elicitationId}. Chunk \"elicit url done\"." }, + { "syntax": "#ext request ", "agent": "Send the extension request with fixtures.ext.params to the client; chunk \"ext: \"." }, + { "syntax": "#ext notify ", "agent": "Send the extension notification with fixtures.ext.params; chunk \"ext notified\"." }, + { "syntax": "#ext last-notification", "agent": "Chunk \"ext last: \"." }, + { "syntax": "#meta", "agent": "Copy the prompt request's _meta into the _meta of one agent_message_chunk \"meta\" and of the PromptResponse." }, + { "syntax": "#echo-caps", "agent": "Send the clientCapabilities received at initialize, as compact JSON, in one chunk." }, + { "syntax": "#len ", "agent": "Chunk \"len=\" (the payload is ASCII: everything after \"#len \")." }, + { "syntax": "#big ", "agent": "Send one agent_message_chunk of ASCII 'x' characters." }, + { "syntax": "#enum tool_call", "agent": "Send fixtures.unknownEnums.toolCall, values the v1 schema does not define (through an untyped/raw send if the typed API cannot carry them), then the chunk \"after-enum\"; answer end_turn." }, + { "syntax": "#enum plan", "agent": "Send fixtures.unknownEnums.plan (untyped if needed), then the chunk \"after-enum\"; answer end_turn." }, + { "syntax": "#enum stop", "agent": "Send the chunk \"stop\" and answer with stopReason fixtures.unknownEnums.stopReason (untyped if needed)." }, + { "syntax": "#enum audience", "agent": "Read annotations.audience of the prompt's first content block, as wire strings. Chunk \"audience: \" (\"audience: none\" when absent) and answer end_turn. Print STEP agent.enum.audience PASS iff they equal fixtures.unknownEnums.audience." }, + { "syntax": "#config grouped", "agent": "Add fixtures.groupedConfigOption to the session's config options (it stays there for session/load, session/resume and set_config_option, which accepts effort with any value of its groups), send a config_option_update with the session's complete list, and answer end_turn." }, + { "syntax": "#", "agent": "Answer the prompt with a JSON-RPC error, code -32602, message \"unknown directive: #\"." } + ], + "steps": [ + { + "id": "init.initialize", "group": "init", "seed": true, + "client": "Connect, then initialize with fixtures.client (advertise only capabilities the program really implements). This step is always first; the result is kept for the other init.* steps.", + "pass": { "client": "The response arrives within the step timeout and protocolVersion == 1." } + }, + { + "id": "init.agent-capabilities", "group": "init", + "client": "Check the stored initialize response.", + "pass": { "client": "agentCapabilities has loadSession true and sessionCapabilities list, resume, close and delete present (fixtures.agent)." } + }, + { + "id": "init.client-capabilities", "group": "init", "prompt": "#echo-caps", + "client": "session/new, then the prompt.", + "agent": "#echo-caps", + "pass": { "client": "The chunk parses as JSON and has fs.readTextFile and fs.writeTextFile true and terminal true.", "agent": "STEP agent.init.client-capabilities PASS when the initialize request carried clientCapabilities (any value) and the agent could serialize them." } + }, + { + "id": "init.auth-methods", "group": "init", + "client": "Check the stored initialize response.", + "pass": { "client": "authMethods contains an entry with id interop-auth." } + }, + { + "id": "init.agent-info", "group": "init", + "client": "Check the stored initialize response.", + "pass": { "client": "agentInfo.name starts with \"interop-\"." } + }, + { + "id": "init.config-boolean", "group": "init", "prompt": "#echo-caps", + "requires": ["client.session.configOptions.boolean"], + "client": "session/new, then the prompt.", + "agent": "#echo-caps", + "pass": { "client": "The echoed capabilities contain session.configOptions.boolean." } + }, + { + "id": "auth.authenticate", "group": "auth", + "client": "authenticate {methodId: interop-auth}.", + "agent": "Accept and answer {}.", + "pass": { "client": "A non-error response.", "agent": "STEP agent.auth.authenticate PASS when methodId == interop-auth." } + }, + { + "id": "auth.logout", "group": "auth", + "client": "logout {}.", + "agent": "Answer {}.", + "pass": { "client": "A non-error response." } + }, + { + "id": "auth.logout-capability", "group": "auth", + "client": "Check the stored initialize response.", + "pass": { "client": "agentCapabilities.auth.logout is present." } + }, + { + "id": "auth.terminal", "group": "auth", + "requires": ["client.auth.terminal"], + "client": "Check the stored initialize response.", + "agent": "List fixtures.agent.terminalAuthMethod in authMethods iff the client advertised auth.terminal.", + "pass": { "client": "authMethods contains an entry with type terminal and id interop-terminal-auth." } + }, + { + "id": "session.new", "group": "session", "seed": true, + "client": "session/new {cwd: {dir}, mcpServers: []}.", + "pass": { "client": "A non-empty sessionId." } + }, + { + "id": "session.load", "group": "session", "seed": true, + "requires": ["agent.loadSession"], + "client": "session/new; prompt \"hello load\"; session/load {sessionId, cwd: {dir}, mcpServers: []}; prompt \"after load\".", + "agent": "Accept the load of a session it created.", + "pass": { "client": "The load answers without error, and the prompt after it answers end_turn with at least one agent_message_chunk for the session." } + }, + { + "id": "session.load-replay", "group": "session", + "requires": ["agent.loadSession"], + "client": "session/new; prompt \"replay me\"; session/load. Count updates for the session from the load request until replayGraceMs after its response (replay may arrive on either side of the response: RFD gaps A/C).", + "agent": "Before answering session/load, replay the history: per earlier turn one user_message_chunk with the prompt text, then the agent_message_chunk updates it sent.", + "pass": { "client": "A user_message_chunk \"replay me\" and an agent_message_chunk containing \"replay me\" arrived." } + }, + { + "id": "session.resume", "group": "session", + "requires": ["agent.sessionCapabilities.resume"], + "client": "session/new; prompt \"before resume\"; session/resume {sessionId, cwd: {dir}}; prompt \"after resume\".", + "agent": "Resume without replay.", + "pass": { "client": "The resume answers without error, no update for the session arrives between the resume request and updateGraceMs after its response, and the next prompt answers end_turn." } + }, + { + "id": "session.list", "group": "session", + "requires": ["agent.sessionCapabilities.list"], + "client": "session/new {cwd: {dir}/list}; session/list {cwd: {dir}/list}, following nextCursor (at most 10 pages).", + "agent": "List the sessions it created, filtered by cwd; any page size.", + "pass": { "client": "The new sessionId is listed, with cwd {dir}/list." } + }, + { + "id": "session.close", "group": "session", + "requires": ["agent.sessionCapabilities.close"], + "client": "session/new; start prompt \"#slow\"; after the first tick, session/close {sessionId}; then prompt \"after close\" on the closed session.", + "agent": "Cancel the session's running work and answer {}. Reject later requests for the session.", + "pass": { "client": "close answers {}, the #slow prompt ends (cancelled or an error) within 5 s of the close, and the prompt after close fails with an error.", "agent": "STEP agent.session.close PASS when the close named a known session and its running turn was cancelled." } + }, + { + "id": "session.delete", "group": "session", + "requires": ["agent.sessionCapabilities.delete"], + "client": "session/new; session/delete {sessionId}; session/delete {sessionId: \"no-such-session\"}.", + "pass": { "client": "Both deletes answer without error (deleting an unknown id succeeds silently)." } + }, + { + "id": "session.multi", "group": "session", + "client": "Two session/new on the main connection; send prompt \"multi A\" to the first and \"multi B\" to the second concurrently.", + "pass": { "client": "Both answer end_turn; the first session's chunks spell \"echo: multi A\" and the second's \"echo: multi B\", with no update routed to the wrong session." } + }, + { + "id": "session.fork", "group": "session", "profile": "unstable", + "client": "session/new; prompt \"before fork\"; session/fork {sessionId, cwd: {dir}}; prompt \"in fork\" on the new id.", + "pass": { "client": "The fork returns a sessionId different from the original, and the prompt on it answers end_turn." } + }, + { + "id": "update.agent_message_chunk", "group": "update", "seed": true, "prompt": "hello", + "client": "session/new; the prompt.", + "agent": "Plain text.", + "pass": { "client": "stopReason end_turn, and the agent_message_chunk texts for the session concatenate to \"echo: hello\" (updates may trail the response by updateGraceMs)." } + }, + { + "id": "update.user_message_chunk", "group": "update", "prompt": "#emit user_message_chunk", + "client": "session/new; the prompt.", + "pass": { "client": "A user_message_chunk with text \"user-chunk\", and end_turn." } + }, + { + "id": "update.agent_thought_chunk", "group": "update", "prompt": "#emit agent_thought_chunk", + "client": "session/new; the prompt.", + "pass": { "client": "An agent_thought_chunk with text \"thinking\", and end_turn." } + }, + { + "id": "update.tool_call", "group": "update", "prompt": "#emit tool_call", + "client": "session/new; the prompt.", + "pass": { "client": "A tool_call with toolCallId call-1, title \"interop tool\", kind read, status pending." } + }, + { + "id": "update.tool_call_update", "group": "update", "prompt": "#emit tool_call_update", + "client": "session/new; the prompt.", + "pass": { "client": "tool_call call-1, then tool_call_update call-1 with status completed and text content \"tool output\"." } + }, + { + "id": "update.tool_call-name", "group": "update", "prompt": "#emit tool_call name=read_file", + "client": "session/new; the prompt.", + "pass": { "client": "The received tool_call has name \"read_file\"." } + }, + { + "id": "update.plan", "group": "update", "prompt": "#emit plan", + "client": "session/new; the prompt.", + "pass": { "client": "A plan with the two fixture entries, in order, with their priority and status." } + }, + { + "id": "update.available_commands_update", "group": "update", "prompt": "#emit available_commands_update", + "client": "session/new; the prompt.", + "pass": { "client": "An available_commands_update with one command named \"interop\" whose input hint is \"args\"." } + }, + { + "id": "update.current_mode_update", "group": "update", "prompt": "#emit current_mode_update", + "client": "session/new; the prompt.", + "pass": { "client": "A current_mode_update with currentModeId interop-mode-b." } + }, + { + "id": "update.config_option_update", "group": "update", "prompt": "#emit config_option_update", + "client": "session/new; the prompt.", + "pass": { "client": "A config_option_update whose model option has currentValue model-b." } + }, + { + "id": "update.session_info_update", "group": "update", "prompt": "#emit session_info_update", + "client": "session/new; the prompt.", + "pass": { "client": "A session_info_update with title \"interop title\"." } + }, + { + "id": "update.usage_update", "group": "update", "prompt": "#emit usage_update", + "client": "session/new; the prompt.", + "pass": { "client": "A usage_update with used 100, size 1000 and cost 0.01 USD." } + }, + { + "id": "update.unknown", "group": "update", "prompt": "#emit unknown", + "client": "session/new; the prompt.", + "agent": "Send the unknown variant through an untyped/raw notification send if the typed API refuses it.", + "pass": { "client": "The chunk \"after-unknown\" arrives and the prompt answers end_turn: the unknown update was dropped or surfaced, never fatal." } + }, + { + "id": "stop.max_tokens", "group": "update", "prompt": "#stop max_tokens", + "client": "session/new; the prompt.", + "pass": { "client": "stopReason max_tokens." } + }, + { + "id": "stop.refusal", "group": "update", "prompt": "#stop refusal", + "client": "session/new; the prompt.", + "pass": { "client": "stopReason refusal." } + }, + { + "id": "stop.max_turn_requests", "group": "update", "prompt": "#stop max_turn_requests", + "client": "session/new; the prompt.", + "pass": { "client": "stopReason max_turn_requests." } + }, + { + "id": "enum.tool_call", "group": "enum", "prompt": "#enum tool_call", + "client": "session/new; the prompt.", + "agent": "#enum tool_call", + "pass": { "client": "A tool_call call-enum arrives whose status is kept as the raw value interop_future_status (an open value: Java isKnown() false, value() \"interop_future_status\") and whose kind is interop_future_kind or other (ToolKind's catch-all, which Java reads as OTHER); then the chunk \"after-enum\" and end_turn: an unknown enum value never fails the message or the turn." } + }, + { + "id": "enum.plan", "group": "enum", "prompt": "#enum plan", + "client": "session/new; the prompt.", + "agent": "#enum plan", + "pass": { "client": "A plan with one entry \"future entry\" whose priority and status are kept as the raw values interop_future_priority and interop_future_status (open values); then the chunk \"after-enum\" and end_turn." } + }, + { + "id": "enum.stop", "group": "enum", "prompt": "#enum stop", + "client": "session/new; the prompt; then prompt \"after enum\" on the same session.", + "agent": "#enum stop", + "pass": { "client": "The prompt answers (no error) with stopReason kept as the raw value interop_future_stop, and \"after enum\" answers end_turn." } + }, + { + "id": "enum.audience", "group": "enum", "prompt": "#enum audience", + "client": "session/new; the prompt, whose text block carries annotations {audience: fixtures.unknownEnums.audience} (one Role the v1 schema does not define; untyped if the typed API cannot carry it).", + "agent": "#enum audience", + "pass": { "client": "The chunk \"audience: user,interop_future_role\" and end_turn.", "agent": "STEP agent.enum.audience PASS when the audience it read is exactly user, interop_future_role: the unknown Role kept as its raw value, the request not rejected." } + }, + { + "id": "mode.set", "group": "config", + "client": "session/new (the response carries fixtures.modes); session/set_mode {modeId: interop-mode-b}.", + "agent": "Return fixtures.modes on session/new; accept set_mode for a known mode.", + "pass": { "client": "session/new returned availableModes with interop-mode-b, and set_mode answers without error.", "agent": "STEP agent.mode.set PASS when set_mode named a known session and interop-mode-b." } + }, + { + "id": "config.on-new", "group": "config", + "client": "session/new.", + "agent": "Return fixtures.configOptions (verbose only if the client advertised configOptions.boolean) on session/new, session/load and session/resume.", + "pass": { "client": "The response has a select option model with currentValue model-a." } + }, + { + "id": "config.select", "group": "config", + "client": "session/new; session/set_config_option {configId: model, value: model-b}.", + "pass": { "client": "The response is the complete list of options, with model at currentValue model-b." } + }, + { + "id": "config.boolean", "group": "config", + "requires": ["client.session.configOptions.boolean"], + "client": "session/new; session/set_config_option {configId: verbose, type: boolean, value: true}.", + "pass": { "client": "The response has verbose at currentValue true." } + }, + { + "id": "config.grouped", "group": "config", "prompt": "#config grouped", + "client": "session/new; the prompt; read the effort option from the config_option_update; session/set_config_option {configId: effort, value: effort-high}.", + "agent": "#config grouped", + "pass": { "client": "The config_option_update has the select effort with options in the groups fast and deep, which flattened in order are effort-low, effort-medium, effort-high (Java: allOptions()); set_config_option answers the complete list with effort at currentValue effort-high.", "agent": "STEP agent.config.grouped PASS when set_config_option set effort to effort-high, a value of its group deep." } + }, + { + "id": "perm.selected", "group": "perm", "seed": true, "prompt": "#permission allow", + "client": "session/new; the prompt; answer the permission request per fixtures.permission.clientRule.", + "agent": "#permission allow", + "pass": { "client": "Exactly one session/request_permission arrived during the turn, with at least one option; the chunk \"permission: selected allow\" arrived; end_turn.", "agent": "STEP agent.perm.selected PASS when the outcome is selected with optionId allow." } + }, + { + "id": "perm.cancelled", "group": "perm", "prompt": "#permission hold", + "client": "session/new; the prompt; when the permission request arrives, send session/cancel, then answer the request with outcome cancelled.", + "agent": "#permission hold", + "pass": { "client": "stopReason cancelled within 5 s of the cancel.", "agent": "STEP agent.perm.cancelled PASS when the outcome is cancelled." } + }, + { + "id": "fs.write", "group": "fs", "seed": true, "prompt": "#fs write {dir}/fs-write.txt interop write", + "requires": ["client.fs.writeTextFile"], + "client": "session/new; the prompt. The fs/write_text_file handler writes the file. A client whose SDK answers \"result\": null (Python) is the null-result case, which the agent must accept.", + "agent": "#fs write", + "pass": { "client": "end_turn, the chunk \"fs write ok\" arrived, and {dir}/fs-write.txt contains exactly \"interop write\".", "agent": "STEP agent.fs.write PASS when fs/write_text_file answered without error ({} or null)." } + }, + { + "id": "fs.read", "group": "fs", "prompt": "#fs read {dir}/fs-read.txt", + "requires": ["client.fs.readTextFile"], + "client": "Write fixtures.fsReadContent to {dir}/fs-read.txt; session/new; the prompt. The fs/read_text_file handler reads the file.", + "agent": "#fs read", + "pass": { "client": "A chunk equal to fixtures.fsReadContent.", "agent": "STEP agent.fs.read PASS when the content equals fixtures.fsReadContent." } + }, + { + "id": "fs.read-range", "group": "fs", "prompt": "#fs read-range {dir}/fs-read.txt line=2 limit=1", + "requires": ["client.fs.readTextFile"], + "client": "As fs.read; the handler honours line (1-based) and limit.", + "agent": "#fs read-range", + "pass": { "client": "A chunk that, trimmed, equals \"line2\".", "agent": "STEP agent.fs.read-range PASS when the content, trimmed, equals \"line2\"." } + }, + { + "id": "fs.read-missing", "group": "fs", "prompt": "#fs read-missing {dir}/no-such-file.txt", + "requires": ["client.fs.readTextFile"], + "client": "session/new; the prompt. The handler answers a JSON-RPC error for a missing file.", + "agent": "#fs read-missing", + "pass": { "client": "A chunk starting \"fs read error\" and end_turn: the agent got an error, not a hang.", "agent": "STEP agent.fs.read-missing PASS when fs/read_text_file failed with a JSON-RPC error." } + }, + { + "id": "term.run", "group": "term", "prompt": "#terminal run echo hi", + "requires": ["client.terminal"], + "client": "session/new; the prompt. The terminal handlers run real processes.", + "agent": "#terminal run", + "pass": { "client": "The chunk \"terminal: hi exit=0\".", "agent": "STEP agent.term.run PASS when the output contains \"hi\" and exitCode is 0." } + }, + { + "id": "term.kill", "group": "term", "prompt": "#terminal kill sleep 30", + "requires": ["client.terminal"], + "client": "session/new; the prompt.", + "agent": "#terminal kill", + "pass": { "client": "The chunk \"terminal killed\" within 5 s.", "agent": "STEP agent.term.kill PASS when wait_for_exit returned within 5 s of the kill." } + }, + { + "id": "elicit.form", "group": "elicit", "prompt": "#elicit form", + "requires": ["client.elicitation.form"], + "client": "session/new; the prompt; answer elicitation/create with fixtures.elicitation.formAnswer.", + "agent": "#elicit form", + "pass": { "client": "The chunk \"elicit: accept {\\\"name\\\":\\\"interop\\\"}\" (JSON compared semantically).", "agent": "STEP agent.elicit.form PASS when the action is accept with content name == interop." } + }, + { + "id": "elicit.complete", "group": "elicit", "prompt": "#elicit url", + "requires": ["client.elicitation.url"], + "client": "session/new; the prompt; accept the url-mode elicitation.", + "agent": "#elicit url", + "pass": { "client": "An elicitation/complete notification for elic-1 arrived before the response (plus updateGraceMs)." } + }, + { + "id": "cancel.prompt", "group": "cancel", "prompt": "#slow", + "client": "session/new; the prompt; after the first \"tick\", session/cancel.", + "agent": "#slow", + "pass": { "client": "stopReason cancelled within 5 s of the cancel (ticks after the cancel and before the response are allowed).", "agent": "STEP agent.cancel.prompt PASS when session/cancel arrived for the running session." } + }, + { + "id": "cancel.prompt-while-cancelling", "group": "cancel", "prompt": "#slow grace=1000", + "only": { "agent": ["java"] }, + "client": "session/new; the prompt; after the first tick, session/cancel, then at once prompt \"during cancel\" on the same session; after the first prompt answers, prompt \"after cancel\".", + "pass": { "client": "The first prompt answers cancelled, \"during cancel\" fails with -32600 (invalid request), and \"after cancel\" answers end_turn. Java SDK policy (G0e), so only asserted against Java agents." } + }, + { + "id": "cancel.grace", "group": "cancel", "prompt": "#hang", + "only": { "agent": ["java"] }, + "client": "session/new; the prompt; after 200 ms, session/cancel.", + "agent": "#hang. The Java agent program configures its cancel grace to 1 s.", + "pass": { "client": "stopReason cancelled within 6 s of the cancel: the SDK answered for the handler (G0f)." } + }, + { + "id": "cancel.max-duration", "group": "cancel", "prompt": "#hang", "only": { "agent": ["java"] }, + "client": "session/new; the prompt, never cancelled; once it ends, prompt \"after max-duration\" on the same session.", + "agent": "#hang. The Java agent program configures maxPromptDuration 8 s (env INTEROP_MAX_PROMPT_MS).", + "pass": { "client": "The prompt fails with JSON-RPC error -32800 no earlier than 7 s and within 11 s of being sent: the SDK answered for the handler at maxPromptDuration (G0f); \"after max-duration\" answers end_turn." } + }, + { + "id": "cancel-request.client", "group": "cancel-request", "prompt": "#slow", + "client": "session/new; the prompt; after the first tick, $/cancel_request {requestId: }.", + "agent": "#slow; the SDK surfaces the cancel request to the handler.", + "pass": { "client": "The prompt ends within 5 s, with error -32800 or stopReason cancelled.", "agent": "STEP agent.cancel-request.client PASS when the handler observed the cancel request." } + }, + { + "id": "cancel-request.agent", "group": "cancel-request", "prompt": "#fs read-slow {dir}/slow.txt", + "requires": ["client.fs.readTextFile"], + "client": "session/new; the prompt. The fs/read_text_file handler, for a path ending slow.txt, waits up to 10 s or until cancelled.", + "agent": "#fs read-slow", + "pass": { "client": "The chunk \"cancel-request sent\" and end_turn within 5 s.", "agent": "STEP agent.cancel-request.agent PASS when the fs request ended with error -32800 within 5 s." } + }, + { + "id": "cancel-request.unknown", "group": "cancel-request", + "client": "$/cancel_request for requestId 999999, and for the id of a request that already completed; then session/new and prompt \"after cancel-request\".", + "pass": { "client": "No error comes back for either cancel request, and the prompt answers end_turn." } + }, + { + "id": "ext.agent-request", "group": "ext", "prompt": "#ext request _interop/ping", + "client": "Register a handler for _interop/ping that answers fixtures.ext.result; session/new; the prompt.", + "agent": "#ext request", + "pass": { "client": "The chunk \"ext: {\\\"pong\\\":1}\".", "agent": "STEP agent.ext.agent-request PASS when the result is {pong: 1}." } + }, + { + "id": "ext.agent-notification", "group": "ext", "prompt": "#ext notify _interop/note", + "client": "Register a handler for _interop/note; session/new; the prompt.", + "agent": "#ext notify", + "pass": { "client": "The notification arrived with params {n: 1}." } + }, + { + "id": "ext.client-request", "group": "ext", + "client": "Send the request _interop/ping {n: 1} to the agent.", + "agent": "Answer fixtures.ext.result.", + "pass": { "client": "The result is {pong: 1}." } + }, + { + "id": "ext.client-notification", "group": "ext", "prompt": "#ext last-notification", + "client": "Send the notification _interop/note {n: 1}; session/new; the prompt.", + "agent": "Record extension notifications; #ext last-notification.", + "pass": { "client": "The chunk \"ext last: _interop/note\"." } + }, + { + "id": "meta.prompt", "group": "meta", "prompt": "#meta", + "client": "session/new; the prompt with _meta {\"interop\": \"m1\"} on the PromptRequest.", + "agent": "#meta", + "pass": { "client": "The \"meta\" chunk's update _meta and the PromptResponse _meta both have interop == m1." } + }, + { + "id": "meta.permission", "group": "meta", "prompt": "#permission allow meta", + "client": "As perm.selected, and put the request's _meta into the RequestPermissionResponse's _meta.", + "agent": "#permission allow meta", + "pass": { "client": "The request carried _meta interop == m1.", "agent": "STEP agent.meta.permission PASS when the response carried _meta interop == m1." } + }, + { + "id": "error.method-not-found", "group": "error", + "client": "Send a request for a method no interop agent implements: providers/list (an unstable method) through the typed API, or interop/no_such_method through a generic send. Then session/new.", + "pass": { "client": "The request fails with -32601 and the session/new after it succeeds (the connection survived)." } + }, + { + "id": "big.prompt-1m", "group": "big", + "client": "session/new; prompt \"#len \" followed by 1048576 'x'.", + "agent": "#len", + "pass": { "client": "The chunk \"len=1048576\"." } + }, + { + "id": "big.update-1m", "group": "big", "prompt": "#big 1048576", + "client": "session/new; the prompt.", + "agent": "#big", + "pass": { "client": "One agent_message_chunk of exactly 1048576 characters." } + }, + { + "id": "big.prompt-8m", "group": "big", + "client": "session/new; prompt \"#len \" followed by 8388608 'x'.", + "agent": "#len", + "pass": { "client": "The chunk \"len=8388608\"." } + }, + { + "id": "big.update-8m", "group": "big", "prompt": "#big 8388608", + "client": "session/new; the prompt.", + "agent": "#big", + "pass": { "client": "One agent_message_chunk of exactly 8388608 characters." } + }, + { + "id": "http.reconnect", "group": "http", "seed": true, "transports": ["http"], + "requires": ["agent.loadSession"], + "client": "On a second connection: initialize, session/new, prompt \"before reconnect\", close it. On a third: initialize, session/load of that session, prompt \"after reconnect\", close it.", + "pass": { "client": "Every request answers without error and both prompts answer end_turn." } + }, + { + "id": "stdio.eof-exit", "group": "stdio", "transports": ["stdio"], + "client": "Spawn a second agent from AGENT_CMD, initialize it, close its stdin and wait.", + "agent": "Exit (any code) when stdin reaches EOF.", + "pass": { "client": "The child exits within 5 s of the EOF (otherwise kill it and FAIL with \"no exit on EOF\")." } + }, + { + "id": "conn.close", "group": "conn", "seed": true, + "client": "Close the main connection through the SDK's graceful close (HTTP: DELETE; WS: close frame; stdio: end the child). Always last.", + "pass": { "client": "The close completes within the step timeout without an error." } + } + ] +} diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 00000000..94b40981 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,10 @@ +# Accepted vulnerabilities for the scheduled dependency scan (.github/workflows/dependency-scan.yml). +# +# Add an entry only when a HIGH or CRITICAL finding cannot be fixed by a version bump yet. Every +# entry needs an id, an ignoreUntil date and a reason; .github/scripts/osv-scan.sh fails if one is +# missing. When the date passes the finding fails the scan again, so the decision is revisited. +# +# [[IgnoredVulns]] +# id = "GHSA-xxxx-xxxx-xxxx" +# ignoreUntil = 2026-12-31 +# reason = "No fixed release yet; the vulnerable code path is not reachable from the SDK because ..." diff --git a/pom.xml b/pom.xml index 79b3465b..d5fca86e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.agentclientprotocol acp-java-sdk - 0.18.0 + 0.80.0 pom ACP Java SDK @@ -19,9 +19,19 @@ acp-json-jackson2 acp-json-jackson3 acp-agent-support + acp-http-core acp-test + acp-http-servlet + acp-http-webflux acp-streamable-http-jetty - acp-websocket-jetty + acp-integration + acp-spring-boot-autoconfigure + acp-spring-boot-starter + acp-micronaut + acp-micronaut-sample + acp-quarkus + acp-quarkus-deployment + acp-quarkus-integration-tests @@ -58,26 +68,48 @@ 17 - ${java.version} - ${java.version} + + ${java.version} UTF-8 UTF-8 2025.0.7 - 2.22.2 + 2.22.3 - 3.1.5 + 3.1.7 2.0.18 + 1.0.0 12.1.12 + + 6.0.0 + 2.1.1 + + 2.2.0 + 11.0.22 + + 4.10.20 + 4.10.30 5.14.4 3.27.7 + 1.4.1 5.23.0 1.6.3 + 3.7 + + + 3.40.1 3.15.0 @@ -88,8 +120,31 @@ 3.2.8 0.11.0 0.8.15 + + 1.00 + 1.00 + + ${jacoco.minimum.line} + ${jacoco.minimum.branch} + + ${jacoco.minimum.line} + ${jacoco.minimum.branch} 13.0.0 2.9.3 + 3.28.0 + 7.28.0 + 100 + + 4.9.8.5 + 4.10.4 + + + 2.50.0 + 0.13.8 + + false @@ -143,11 +198,54 @@ acp-test ${project.version} + + com.agentclientprotocol + acp-http-core + ${project.version} + + + com.agentclientprotocol + acp-http-servlet + ${project.version} + + + com.agentclientprotocol + acp-http-webflux + ${project.version} + com.agentclientprotocol acp-streamable-http-jetty ${project.version} + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + org.apache.tomcat.embed + tomcat-embed-websocket + ${tomcat.version} + + + org.eclipse.jetty.ee10.websocket + jetty-ee10-websocket-jakarta-server + ${jetty.version} + + + com.agentclientprotocol + acp-integration + ${project.version} + + + + + org.jspecify + jspecify + ${jspecify.version} + @@ -157,16 +255,6 @@ - - org.eclipse.jetty.websocket - jetty-websocket-jetty-server - ${jetty.version} - - - org.eclipse.jetty.websocket - jetty-websocket-jetty-api - ${jetty.version} - org.eclipse.jetty jetty-server @@ -194,6 +282,11 @@ assertj-core ${assertj.version} + + com.tngtech.archunit + archunit-junit5 + ${archunit.version} + org.mockito mockito-core @@ -204,6 +297,13 @@ logback-classic ${logback.version} + + + org.jetbrains.lincheck + lincheck + ${lincheck.version} + @@ -263,8 +363,7 @@ maven-compiler-plugin ${maven-compiler-plugin.version} - ${java.version} - ${java.version} + ${java.version} @@ -314,6 +413,25 @@ none true + + + + apiNote + a + API Note: + + + implSpec + a + Implementation Requirements: + + + implNote + a + Implementation Note: + + @@ -325,7 +443,131 @@ - + + + org.apache.maven.plugins + maven-pmd-plugin + ${maven-pmd-plugin.version} + + + net.sourceforge.pmd + pmd-core + ${pmd.version} + + + net.sourceforge.pmd + pmd-java + ${pmd.version} + + + + + ${maven.multiModuleProjectDirectory}/config/pmd/ruleset.xml + + ${java.version} + false + ${cpd.minimumTokens} + true + true + false + + + + pmd-gate + verify + + check + cpd-check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + + com.github.spotbugs + spotbugs + ${spotbugs.version} + + + + Max + Medium + 20 + ${maven.multiModuleProjectDirectory}/config/spotbugs/spotbugs-include.xml + false + ${maven.multiModuleProjectDirectory}/config/spotbugs/spotbugs-exclude.xml + true + + + + spotbugs-gate + verify + + check + + + + + + org.jacoco jacoco-maven-plugin @@ -337,19 +579,146 @@ prepare-agent + report - test + verify report + + coverage-gate + verify + + check + + + + ${skipTests} + + + BUNDLE + + + LINE + COVEREDRATIO + ${jacoco.gate.line} + + + BRANCH + COVEREDRATIO + ${jacoco.gate.branch} + + + + + CLASS + + com.agentclientprotocol.sdk.util.VirtualThreads + com.agentclientprotocol.sdk.util.AcpSchedulers* + com.agentclientprotocol.sdk.agent.SyncHandlerScheduler + com.agentclientprotocol.sdk.client.SyncHandlerScheduler + com.agentclientprotocol.sdk.quarkus.runtime.AcpExecutors + com.agentclientprotocol.sdk.spring.boot.autoconfigure.agent.HandlerExecutors* + + + + BRANCH + COVEREDRATIO + 1.00 + + + + + + + + + coverage-jdk21 + + [21,) + + + ${jacoco.jdk21.minimum.line} + ${jacoco.jdk21.minimum.branch} + + + + + errorprone + + [21,) + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + + true + + -XDcompilePolicy=simple + --should-stop=ifError=FLOW + -XDaddTypeAnnotationsToSymbol=true + + -Xplugin:ErrorProne -Xep:StatementSwitchToExpressionSwitch:OFF -Xep:SystemOut:ERROR -XepOpt:CheckReturnValue:Packages=reactor.core.publisher -XepOpt:CheckReturnValue:ApiExclusionList=${maven.multiModuleProjectDirectory}/config/errorprone/reactor-ignorable-results.txt -Xep:NullAway:ERROR -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:OnlyNullMarked=true + + + + com.google.errorprone + error_prone_core + ${errorprone.version} + + + com.uber.nullaway + nullaway + ${nullaway.version} + + + + + + + + + release @@ -364,6 +733,13 @@ central true + + + acp-micronaut-sample + acp-quarkus-integration-tests +