diff --git a/.github/workflows/release-sdk-java.yml b/.github/workflows/release-sdk-java.yml
new file mode 100644
index 00000000000..e387aaaa163
--- /dev/null
+++ b/.github/workflows/release-sdk-java.yml
@@ -0,0 +1,212 @@
+name: 'Release Java SDK'
+
+on:
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Verify the release without publishing, tagging, or creating a GitHub Release.'
+ required: true
+ type: 'boolean'
+ default: true
+
+concurrency:
+ group: 'release-sdk-java-0.1.0-alpha'
+ cancel-in-progress: false
+
+jobs:
+ release:
+ if: "${{ github.repository == 'QwenLM/qwen-code' }}"
+ runs-on: 'ubuntu-latest'
+ environment:
+ name: "${{ inputs.dry_run && 'sdk-java-dry-run' || 'production-release' }}"
+ url: '${{ github.server_url }}/${{ github.repository }}/releases'
+ permissions:
+ contents: 'write'
+ env:
+ SDK_DIRECTORY: 'packages/sdk-java/qwencode'
+ SDK_VERSION: '0.1.0-alpha'
+ RELEASE_TAG: 'sdk-java-v0.1.0-alpha'
+ steps:
+ - name: 'Require protected main'
+ env:
+ WORKFLOW_REF: '${{ github.ref }}'
+ run: |-
+ if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
+ echo 'The Java SDK release workflow must run from main.' >&2
+ exit 1
+ fi
+
+ - name: 'Checkout main'
+ uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
+ with:
+ ref: 'main'
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: 'Set up Java'
+ uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
+ with:
+ distribution: 'temurin'
+ java-version: '11'
+ cache: 'maven'
+ cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
+
+ - name: 'Set up Node.js'
+ uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: 'Validate release version and tag'
+ id: 'preflight'
+ env:
+ CURRENT_SHA: '${{ github.sha }}'
+ DRY_RUN: '${{ inputs.dry_run }}'
+ run: |-
+ set -euo pipefail
+ checked_out_sha=$(git rev-parse HEAD)
+ if [[ "${checked_out_sha}" != "${CURRENT_SHA}" ]]; then
+ echo "Checked-out main ${checked_out_sha} does not match workflow SHA ${CURRENT_SHA}." >&2
+ exit 1
+ fi
+
+ tag_state='new'
+ direct_tag=$(git ls-remote --refs --tags origin "refs/tags/${RELEASE_TAG}" | cut -f1)
+ peeled_tag=$(git ls-remote --tags origin "refs/tags/${RELEASE_TAG}^{}" | cut -f1)
+ if [[ -n "${direct_tag}" ]]; then
+ if [[ -z "${peeled_tag}" ]]; then
+ echo "${RELEASE_TAG} is not an annotated release tag." >&2
+ exit 1
+ fi
+ tag_commit=${peeled_tag}
+ if ! git merge-base --is-ancestor "${tag_commit}" "${checked_out_sha}"; then
+ echo "${RELEASE_TAG} does not point to a commit in protected main history." >&2
+ exit 1
+ fi
+ git checkout --detach "${tag_commit}"
+ tag_state='resume'
+ fi
+
+ actual_version=$(mvn --batch-mode --no-transfer-progress -f "${SDK_DIRECTORY}/pom.xml" help:evaluate -Dexpression=project.version -q -DforceStdout)
+ if [[ "${actual_version}" != "${SDK_VERSION}" ]]; then
+ echo "Expected ${SDK_VERSION}, found ${actual_version}." >&2
+ exit 1
+ fi
+ artifact_url="https://repo1.maven.org/maven2/com/alibaba/qwencode-sdk/${SDK_VERSION}/qwencode-sdk-${SDK_VERSION}.pom"
+ probe_artifact() {
+ local status=''
+ for attempt in 1 2 3; do
+ if status=$(curl --silent --show-error --location --connect-timeout 10 --max-time 30 --output /dev/null --write-out '%{http_code}' "${artifact_url}"); then
+ if [[ "${status}" == '200' ]]; then
+ echo 'published'
+ return 0
+ fi
+ if [[ "${status}" == '404' ]]; then
+ echo 'missing'
+ return 0
+ fi
+ fi
+ if [[ "${attempt}" -lt 3 ]]; then
+ sleep 5
+ fi
+ done
+ echo "Maven Central probe was inconclusive (last HTTP status: ${status:-transport_error})." >&2
+ return 1
+ }
+ artifact_state=$(probe_artifact)
+ if [[ "${tag_state}" == 'new' && "${artifact_state}" == 'published' ]]; then
+ echo "${SDK_VERSION} is already published without ${RELEASE_TAG}; manual recovery is required." >&2
+ exit 1
+ fi
+ if [[ "${DRY_RUN}" == 'false' && "${tag_state}" == 'resume' && "${artifact_state}" == 'missing' ]]; then
+ echo 'Matching tag already exists; Maven publication will resume from that immutable source commit.'
+ elif [[ "${DRY_RUN}" == 'false' && "${tag_state}" == 'resume' ]]; then
+ echo 'Matching tag and artifact already exist; GitHub Release creation will resume.'
+ fi
+ echo "tag_state=${tag_state}" >> "${GITHUB_OUTPUT}"
+ echo "artifact_state=${artifact_state}" >> "${GITHUB_OUTPUT}"
+
+ - name: 'Verify release build'
+ working-directory: '${{ env.SDK_DIRECTORY }}'
+ run: 'mvn --batch-mode --no-transfer-progress clean verify -Dgpg.skip=true'
+
+ - name: 'Install Node.js dependencies'
+ run: 'npm ci --prefer-offline --no-audit --progress=false'
+
+ - name: 'Build Qwen Code'
+ run: 'npm run build'
+
+ - name: 'Bundle Qwen Code'
+ run: 'npm run bundle'
+
+ - name: 'Run Java daemon E2E'
+ run: 'npx tsx scripts/run-java-daemon-sdk-e2e.ts'
+
+ - name: 'Require an unchanged source tree'
+ run: |-
+ if [[ -n "$(git status --porcelain)" ]]; then
+ git status --short
+ echo 'Release verification changed or created source files.' >&2
+ exit 1
+ fi
+
+ - name: 'Create and push release tag'
+ if: "${{ !inputs.dry_run && steps.preflight.outputs.tag_state == 'new' }}"
+ env:
+ GH_TOKEN: '${{ github.token }}'
+ run: |-
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git tag -a "${RELEASE_TAG}" -m "Java SDK ${SDK_VERSION}"
+ gh auth setup-git
+ git push origin "refs/tags/${RELEASE_TAG}"
+
+ - name: 'Configure Maven Central publishing'
+ if: "${{ !inputs.dry_run && steps.preflight.outputs.artifact_state == 'missing' }}"
+ uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
+ with:
+ distribution: 'temurin'
+ java-version: '11'
+ cache: 'maven'
+ cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
+ server-id: 'central'
+ server-username: 'MAVEN_USERNAME'
+ server-password: 'MAVEN_PASSWORD'
+ gpg-private-key: '${{ secrets.MAVEN_GPG_PRIVATE_KEY }}'
+ gpg-passphrase: 'MAVEN_GPG_PASSPHRASE'
+
+ - name: 'Publish signed artifacts to Maven Central'
+ if: "${{ !inputs.dry_run && steps.preflight.outputs.artifact_state == 'missing' }}"
+ working-directory: '${{ env.SDK_DIRECTORY }}'
+ env:
+ MAVEN_GPG_PASSPHRASE: '${{ secrets.MAVEN_GPG_PASSPHRASE }}'
+ MAVEN_USERNAME: '${{ secrets.CENTRAL_USERNAME }}'
+ MAVEN_PASSWORD: '${{ secrets.CENTRAL_PASSWORD }}'
+ run: 'mvn --batch-mode --no-transfer-progress -DskipTests deploy'
+
+ - name: 'Verify Maven Central availability'
+ if: '${{ !inputs.dry_run }}'
+ run: |-
+ artifact_url="https://repo1.maven.org/maven2/com/alibaba/qwencode-sdk/${SDK_VERSION}/qwencode-sdk-${SDK_VERSION}.pom"
+ for attempt in {1..30}; do
+ if curl --fail --silent --show-error --location --output /dev/null "${artifact_url}"; then
+ exit 0
+ fi
+ if [[ "${attempt}" -lt 30 ]]; then
+ sleep 10
+ fi
+ done
+ echo "Published artifact is not available at ${artifact_url}." >&2
+ exit 1
+
+ - name: 'Create GitHub Release'
+ if: '${{ !inputs.dry_run }}'
+ env:
+ GH_TOKEN: '${{ github.token }}'
+ run: |-
+ git fetch origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" --force
+ if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
+ echo "GitHub Release ${RELEASE_TAG} already exists."
+ exit 0
+ fi
+ gh release create "${RELEASE_TAG}" --verify-tag --title "Java SDK ${SDK_VERSION}" --generate-notes
diff --git a/.github/workflows/sdk-java.yml b/.github/workflows/sdk-java.yml
new file mode 100644
index 00000000000..ba99ba31134
--- /dev/null
+++ b/.github/workflows/sdk-java.yml
@@ -0,0 +1,119 @@
+name: 'SDK Java'
+
+on:
+ pull_request:
+ branches:
+ - 'main'
+ - 'release/**'
+ paths:
+ - 'packages/sdk-java/**'
+ - 'packages/cli/src/commands/serve.ts'
+ - 'packages/cli/src/serve/**'
+ - 'packages/cli/src/acp-integration/**'
+ - 'packages/acp-bridge/**'
+ - 'integration-tests/fake-openai-server.ts'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'docs/developers/sdk-java.md'
+ - 'docs/design/java-daemon-sdk-alpha.md'
+ - 'scripts/run-java-daemon-sdk-e2e.ts'
+ - '.github/workflows/sdk-java.yml'
+ - '.github/workflows/release-sdk-java.yml'
+ push:
+ branches:
+ - 'main'
+ - 'release/**'
+ paths:
+ - 'packages/sdk-java/**'
+ - 'packages/cli/src/commands/serve.ts'
+ - 'packages/cli/src/serve/**'
+ - 'packages/cli/src/acp-integration/**'
+ - 'packages/acp-bridge/**'
+ - 'integration-tests/fake-openai-server.ts'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'docs/developers/sdk-java.md'
+ - 'docs/design/java-daemon-sdk-alpha.md'
+ - 'scripts/run-java-daemon-sdk-e2e.ts'
+ - '.github/workflows/sdk-java.yml'
+ - '.github/workflows/release-sdk-java.yml'
+
+permissions:
+ contents: 'read'
+
+jobs:
+ test:
+ name: '${{ matrix.os }} / Java ${{ matrix.java }}'
+ runs-on: '${{ matrix.os }}'
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: 'ubuntu-latest'
+ java: '11'
+ - os: 'ubuntu-latest'
+ java: '17'
+ - os: 'ubuntu-latest'
+ java: '21'
+ - os: 'macos-latest'
+ java: '21'
+ - os: 'windows-latest'
+ java: '21'
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
+
+ - name: 'Set up Java'
+ uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
+ with:
+ distribution: 'temurin'
+ java-version: '${{ matrix.java }}'
+ cache: 'maven'
+ cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
+
+ - name: 'Run Java SDK tests'
+ working-directory: 'packages/sdk-java/qwencode'
+ run: 'mvn --batch-mode --no-transfer-progress clean test'
+
+ - name: 'Run Java SDK Checkstyle'
+ if: "${{ matrix.os == 'ubuntu-latest' && matrix.java == '11' }}"
+ working-directory: 'packages/sdk-java/qwencode'
+ run: 'mvn --batch-mode --no-transfer-progress checkstyle:check'
+
+ - name: 'Build release artifacts'
+ if: "${{ matrix.os == 'ubuntu-latest' && matrix.java == '11' }}"
+ working-directory: 'packages/sdk-java/qwencode'
+ run: 'mvn --batch-mode --no-transfer-progress -DskipTests -Dgpg.skip=true package'
+
+ daemon-e2e:
+ name: 'Real daemon E2E / Java 11'
+ runs-on: 'ubuntu-latest'
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
+
+ - name: 'Set up Node.js'
+ uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: 'Set up Java'
+ uses: 'actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654' # v5.2.0
+ with:
+ distribution: 'temurin'
+ java-version: '11'
+ cache: 'maven'
+ cache-dependency-path: 'packages/sdk-java/qwencode/pom.xml'
+
+ - name: 'Install Node.js dependencies'
+ run: 'npm ci --prefer-offline --no-audit --progress=false'
+
+ - name: 'Build Qwen Code'
+ run: 'npm run build'
+
+ - name: 'Bundle Qwen Code'
+ run: 'npm run bundle'
+
+ - name: 'Run Java daemon E2E'
+ run: 'npx tsx scripts/run-java-daemon-sdk-e2e.ts'
diff --git a/docs/design/java-daemon-sdk-alpha.md b/docs/design/java-daemon-sdk-alpha.md
new file mode 100644
index 00000000000..e7370ca25d3
--- /dev/null
+++ b/docs/design/java-daemon-sdk-alpha.md
@@ -0,0 +1,213 @@
+# Java daemon SDK 0.1.0-alpha
+
+## Status
+
+This document defines the first daemon transport in the existing
+`com.alibaba:qwencode-sdk` artifact. It is intentionally independent from the
+legacy stdio implementation under `com.alibaba.qwen.code.cli`.
+
+## Goals
+
+- Add a Java 11 API for `qwen serve` without creating another Maven artifact.
+- Deliver streamed text, thought, tool, usage, permission, and raw events in
+ daemon order.
+- Return prompt text only after a matching reliable terminal event.
+- Resume a prompt stream from the admission watermark without replay gaps or
+ duplicate observer delivery.
+- Make ambiguous mutation outcomes and incomplete prompt outcomes explicit.
+- Keep client-owned threads, streams, sessions, and detach attempts bounded.
+
+## Public surface
+
+`DaemonClient` owns HTTP and worker resources, reads capabilities, and creates
+sessions. Session creation defaults to `sessionScope=thread`. Blocking prompt
+observation uses a configurable bounded worker pool rather than a global or
+unbounded executor.
+The shared timer only dispatches watchdog actions. Potentially blocking SSE
+stream closure runs on a separate bounded pool sized to the prompt concurrency
+limit, so one stalled close cannot delay another session's deadline or idle
+watchdog. Each admitted prompt reserves bounded stream-cleanup capacity until
+its final close task finishes. A stalled close can therefore cause a later
+`startPrompt` call to fail with `DaemonClientCapacityException`, but it cannot
+silently discard a deadline-triggered close or grow cleanup work without bound.
+
+`DaemonSessionClient` owns one daemon session and admits at most one local
+prompt at a time. `startPrompt` returns a `PromptCall` immediately. Its
+admission and terminal futures are independent, so a caller can distinguish
+"the daemon accepted this prompt" from "the turn ended reliably".
+Admission and terminal future continuations are dispatched through a separate,
+client-owned executor so user continuations cannot delay SSE observation, its
+local timeout, or prompt transport capacity. Exceptional completion follows
+the same path. Publication capacity is bounded relative to
+`maximumConcurrentPrompts`; continuations that remain blocked can therefore
+cause a later `startPrompt` call to fail with `DaemonClientCapacityException`
+instead of creating unbounded threads or queued work.
+
+An indeterminate completion is not a session-reuse boundary. After admission
+becomes unknown or an admitted prompt ends indeterminately, the session client
+permanently rejects further prompts even if local stream cleanup succeeds.
+A local observation timeout is published without waiting indefinitely for
+stream closure; cleanup continues asynchronously and retains bounded client
+capacity until it finishes. Callers close or destroy the affected session.
+
+`PromptObserver` receives typed callbacks and the raw event. Callbacks execute
+serially on a client-owned daemon thread. An event cursor advances only after
+all applicable callbacks return successfully. Callbacks must therefore return
+promptly, must not wait on the same `PromptCall`, and must not close or destroy
+the same session from a callback. Responding to a permission from its callback
+is supported; the response method returns `false` when the daemon reports that
+the request was already resolved or is no longer pending.
+
+`promptText` is a convenience over `startPrompt`. It collects only assistant
+text, enforces a UTF-8 byte limit, and returns a `PromptTextResult` only for a
+matching `turn_complete`. A `turn_error` remains a reliable terminal but is
+reported as `PromptTurnException`; any outcome without a reliable terminal is
+reported as `PromptOutcomeIndeterminateException` with explicitly incomplete
+partial text when available.
+
+Fastjson2 encoding and strict Jackson Core decoding are implementation details.
+Decoding rejects non-standard JSON and duplicate object keys. Public raw JSON
+values use Java `Map`, `List`, scalar, and null values.
+
+Creation-time model selection is intentionally not exposed in this alpha.
+The daemon keeps a fresh session alive on the default model when
+`modelServiceId` is rejected and reports the rejection only through an SSE
+event emitted before the create response. The per-prompt subscription starts
+from the later admission watermark, so it cannot prove that the requested
+model was selected without adding a separate session-event lifecycle.
+
+Before session creation, the SDK requires the daemon to advertise REST and
+`session_scope_override`; it refuses to mutate when an older daemon could
+silently ignore the requested scope. While a session remains open, the SDK
+sends a new heartbeat mutation once per configured interval (one minute by
+default) only when the daemon advertises `client_heartbeat`, and stops on
+detach or destroy. Each heartbeat has the normal finite-request deadline and
+is not retried; setting the interval to zero disables automatic keepalive.
+Likewise, a prompt carrying `deadlineMs` is rejected before admission unless
+the daemon advertises `prompt_absolute_deadline`, so a requested server-side
+deadline cannot be silently ignored. The local observation timeout remains
+independent and is always enforced by the SDK.
+
+## Wire flow
+
+1. Send one non-retried `POST /session/:id/prompt`.
+2. Require `202` and validate `{promptId,lastEventId}`.
+3. Open `GET /session/:id/events` with `Last-Event-ID` set to the watermark.
+4. Replay and observe only events correlated with that prompt, while treating
+ session-level failure frames as fatal.
+5. Stop only on matching `turn_complete` or `turn_error`.
+
+This per-prompt subscription covers content and terminal events emitted before
+the `202` response reaches the client. It does not require an unknown-prompt
+cache or a long-lived session pump.
+
+## Transport contract
+
+The JDK `HttpClient` uses HTTP/1.1 and never follows redirects. Every request
+sends JSON or event-stream `Accept` headers, bearer authentication when
+configured, and the daemon-issued `X-Qwen-Client-Id` after session creation.
+SSE additionally sends `Accept-Encoding: identity`, `Cache-Control: no-cache`,
+and `Last-Event-ID`.
+
+Finite JSON and error bodies are consumed by a bounded subscriber and raced
+against the request deadline through `sendAsync`; receiving response headers
+does not end that deadline. Non-success SSE bodies are separately bounded by
+the shorter of the request and prompt-observation budgets.
+
+The SSE parser accepts LF and CRLF framing, comments, and multiple `data:`
+lines. UTF-8 decoding is strict. Frames, event names, envelope version, numeric
+IDs, and SSE/envelope ID consistency are validated. A malformed frame, an ID
+gap, `state_resync_required`, session death, observer failure, idle timeout, or
+reconnect exhaustion fails closed.
+
+IDs at or below the committed cursor are duplicates and are not delivered.
+The next numeric event must be exactly `cursor + 1`. Synthetic ID-less events
+are accepted only for the daemon's documented control frames and do not move
+the cursor; an ID-less content or terminal event fails closed. The
+implementation reconnects only the SSE GET, using bounded exponential
+full-jitter backoff, the SSE `retry` directive after a stream disconnect, and
+`Retry-After` on retryable HTTP responses. Mutations are never retried
+automatically.
+
+## Ambiguous and terminal outcomes
+
+If prompt transport fails after dispatch without a validated `202`, or returns
+HTTP 408 or 5xx, the admission future fails with
+`PromptAdmissionUnknownException`; the SDK never reposts the prompt. Session
+creation applies the same conservative classification through
+`SessionCreationOutcomeUnknownException`. Permission, cancel, heartbeat,
+detach, and delete apply the same classification because an intermediary
+response does not prove that the daemon rejected the mutation. Detach uses the
+more specific `DetachOutcomeUnknownException`. Every mutation is attempted at
+most once per method invocation.
+
+Only matching `turn_complete` and `turn_error` are terminal. Queue and
+`prompt_cancelled` events are advisory. A local timeout stops observation but
+does not automatically cancel the daemon turn. A cooperative daemon
+cancellation completes as `turn_complete` with `stopReason=cancelled`, while an
+agent or provider failure during cancellation can produce `turn_error`.
+`promptText()` returns the complete result and surfaces the error terminal as
+`PromptTurnException`; callers must wait for the terminal in both cases.
+When cancellation, deadline, teardown, or agent settlement race, the daemon's
+exactly-once latch publishes the first formal terminal and suppresses later
+candidates. The SDK therefore treats the received terminal as authoritative
+instead of deriving an outcome from the last control mutation it sent.
+
+`close()` is locally idempotent, stops local observation, and attempts detach
+at most once. A lost detach response is not retried. `destroySession()` is the
+only API that issues `DELETE /session/:id`; it may be called after detach.
+
+## Compatibility and non-goals
+
+The whole artifact now requires Java 11. Java 8 users must remain on
+`0.0.3-alpha`. The stdio API remains source-compatible but now runs on Java 11
+and obtains logging through `slf4j-api`; applications choose their own SLF4J
+provider because Logback is test-only.
+
+The compatible daemon is the qwen-code build released from the same source
+revision as the SDK. It contains the per-client detach ledger from #7386, the
+per-epoch terminal guarantee from #7400, and this release's acknowledged
+admission cancellation plus FIFO cancel-drain fence. The #7400 commit alone can
+still acknowledge cancel before agent dispatch without stopping the admitted
+prompt, or let an unacknowledged session-scoped cancel reach a queued successor.
+The bundled ACP child handles the daemon's internal cancellation request through
+one acknowledged admission-aware handshake. A custom standards-compliant ACP
+child that does not implement that extension receives one standard
+`session/cancel` notification instead. The
+daemon does not advertise a capability that distinguishes these implementations
+with the same REST/SSE feature set, so the SDK cannot negotiate this minimum at
+runtime and fails closed when a formal terminal is absent.
+
+The handshake intentionally waits for the targeted prompt call to settle before
+the FIFO may dispatch its successor. Adding an acknowledgement-only timeout
+would allow a late session-scoped cancel to reach that successor and would break
+the ordering guarantee. Consequently, a provider, tool, or custom integration
+that ignores its `AbortSignal` indefinitely can leave the cancel mutation
+outcome unknown and the session unusable. Reclaiming a wedged shared ACP child
+without terminating sibling sessions requires stronger runtime isolation and is
+outside this alpha.
+
+The alpha does not promise exactly-once execution across daemon restarts,
+automatic epoch recovery, snapshot/resync, persisted cursors, or true
+prompt-ID-targeted cancellation. It also does not expose creation-time model
+selection until the daemon can return a definitive result or the SDK owns a
+session-event lifecycle from `Last-Event-ID: 0`. An ambiguous create can leave
+a daemon session that the caller cannot identify or detach until daemon-side
+reaping. Those cases require stronger daemon contracts.
+
+## Verification
+
+Unit tests use an in-process HTTP server to inject SSE fragmentation, slow
+single-line delivery, replay, duplicates, gaps, conflicting prompt IDs,
+opaque future event data, watermark replay, disconnects, compressed responses,
+stalled finite bodies, resync, observer failures, terminal absence, and
+ambiguous mutation responses. Lifecycle tests cover one-local-prompt admission,
+admission/close serialization, deadline terminal followed by session reuse,
+cancelled completion, teardown terminal ordering, bounded text, automatic
+heartbeat, idempotent close, detach client identity, detach-once, and explicit
+destroy.
+
+CI compiles and tests on Java 11, 17, and 21 on Linux, with Java 21 smoke
+coverage on macOS and Windows. Linux CI and the protected release workflow run
+an E2E against a real `qwen serve` process with a temporary workspace and
+model stub.
diff --git a/docs/developers/sdk-java.md b/docs/developers/sdk-java.md
index 7c001977985..890817b4cb6 100644
--- a/docs/developers/sdk-java.md
+++ b/docs/developers/sdk-java.md
@@ -1,18 +1,18 @@
# Qwen Code Java SDK
-The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
+The Qwen Code Java SDK provides a recommended daemon transport for `qwen serve` and retains the experimental legacy stdio API for compatibility. Both APIs ship in the same `com.alibaba:qwencode-sdk` artifact.
## Requirements
-- Java >= 1.8
-- Maven >= 3.6.0 (for building from source)
-- qwen-code >= 0.5.0
+- Java >= 11 for `0.1.0-alpha`
+- Maven >= 3.9.2 when building or publishing this SDK from source
+- A compatible `qwen serve` for the daemon API, or qwen-code >= 0.5.0 for the legacy stdio API
### Dependencies
-- **Logging**: ch.qos.logback:logback-classic
+- **Logging API**: org.slf4j:slf4j-api (choose an SLF4J provider in your application)
- **Utilities**: org.apache.commons:commons-lang3
-- **JSON Processing**: com.alibaba.fastjson2:fastjson2
+- **JSON Processing**: Fastjson2 for encoding and Jackson Core for strict decoding
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
## Installation
@@ -23,14 +23,14 @@ Add the following dependency to your Maven `pom.xml`:
com.alibaba
qwencode-sdk
- {$version}
+ 0.1.0-alpha
```
Or if using Gradle, add to your `build.gradle`:
```gradle
-implementation 'com.alibaba:qwencode-sdk:{$version}'
+implementation 'com.alibaba:qwencode-sdk:0.1.0-alpha'
```
## Building and Running
@@ -51,9 +51,63 @@ mvn package
mvn install
```
-## Quick Start
+### Real daemon E2E from source
-The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
+Run the real-daemon Java integration tests from the repository root after building both the workspaces and the root CLI bundle:
+
+```bash
+npm run build
+npm run bundle
+npx tsx scripts/run-java-daemon-sdk-e2e.ts
+```
+
+`npm run build` alone does not refresh `dist/cli.js`; the E2E harness launches that bundle and fails with an explicit prerequisite error when it is missing.
+
+## Recommended daemon API
+
+Start `qwen serve`, then create an independent thread-scoped session. `promptText` returns only after a matching `turn_complete`; incomplete streams fail with `PromptOutcomeIndeterminateException` rather than returning partial text as success.
+
+For the lifecycle guarantees assumed by `0.1.0-alpha`, use the qwen-code build released from the same source revision as the SDK. The daemon must contain the idempotent per-client detach ledger from [#7386](https://github.com/QwenLM/qwen-code/pull/7386), the per-epoch terminal guarantee from [#7400](https://github.com/QwenLM/qwen-code/pull/7400), and this release's acknowledged admission cancellation plus FIFO cancel-drain fence. The #7400 commit alone is not sufficient: a same-wire daemon can acknowledge cancel before agent dispatch without stopping the admitted prompt, or let an unacknowledged session-scoped cancel reach a queued successor. The bundled ACP child uses one acknowledged admission-aware cancellation handshake; a custom standards-compliant ACP child without that extension receives one standard `session/cancel` notification. Feature negotiation cannot distinguish older same-wire daemon builds, so the SDK fails closed rather than reporting partial output as success.
+
+The bundled cancellation handshake deliberately waits for the targeted prompt call to settle before the daemon dispatches its queued successor. It has no timeout that merely acknowledges cancellation: doing so could let a late session-scoped cancel reach the next prompt. If a provider, tool, or custom integration ignores its `AbortSignal` indefinitely, the cancel mutation can therefore remain outcome-unknown and that session must not be reused. Treat a formal prompt terminal received within the caller's observation boundary as authoritative; otherwise close or destroy the session after observation fails. Recovering a wedged shared ACP child without disturbing its sibling sessions requires stronger runtime isolation and is outside this alpha contract.
+
+```java
+import com.alibaba.qwen.code.daemon.DaemonClient;
+import com.alibaba.qwen.code.daemon.DaemonSessionClient;
+import com.alibaba.qwen.code.daemon.PromptTextResult;
+import java.net.URI;
+
+try (DaemonClient daemon = DaemonClient.builder()
+ .baseUri(URI.create("http://127.0.0.1:4170"))
+ .build();
+ DaemonSessionClient session = daemon.createSession()) {
+ PromptTextResult result = session.promptText("Explain this repository");
+ System.out.println(result.getText());
+}
+```
+
+If `qwen serve` requires authentication, add
+`.bearerToken(System.getenv("QWEN_SERVER_TOKEN"))` to the `DaemonClient`
+builder. The SDK sends the bearer on REST and SSE requests and never puts it in
+the URL.
+
+Use `startPrompt` with a `PromptObserver` when you need ordered text, thought, tool, usage, permission, and raw event callbacks. Its `acceptanceFuture()` and `completionFuture()` views separately expose daemon admission and the reliable turn terminal. `respondToPermission()` returns `false` when the request was already resolved or no longer pending. Cancelling the future views does not cancel the daemon prompt; use `cancelActivePrompt()` for the session-level daemon cancel operation and still wait for the matching terminal. A cooperative cancellation completes with `turn_complete` and `stopReason=cancelled`; `promptText()` returns its `PromptTextResult`, so callers that distinguish cancellation must inspect `result.getTerminal().getStopReason()`. If the agent or provider fails while cancelling, the daemon can instead publish `turn_error`, which makes `promptText()` throw `PromptTurnException`.
+
+When cancellation, deadline, teardown, or agent settlement race, the daemon's exactly-once latch publishes the first formal terminal and suppresses later candidates. Always branch on the received terminal itself; the last control mutation sent by the client does not determine the terminal kind or error code.
+
+The SSE transport sends `Accept-Encoding: identity` and `Last-Event-ID`, validates framing and event IDs, deduplicates replay, and reconnects only the SSE GET. Prompt and other mutation requests are never retried automatically. HTTP 408 and 5xx responses to prompt admission, session creation, permission, cancel, heartbeat, detach, or delete are reported as outcome-unknown because they do not prove that the daemon rejected the mutation. Finite response bodies and SSE observation have independent deadlines.
+
+Creation-time model selection is intentionally not exposed by the Java daemon SDK API in this alpha. The daemon reports a rejected `modelServiceId` only as an SSE event emitted before the create response, while this SDK opens its stream from the later prompt-admission watermark. Until the daemon returns a definitive create result or the SDK owns a separate session-event subscription from `Last-Event-ID: 0`, use the daemon's configured default model.
+
+`PromptRequest.Builder.deadline(Duration)` requests a daemon-enforced prompt deadline and is accepted only when the daemon advertises `prompt_absolute_deadline`; otherwise the SDK fails before sending the prompt. The value must be between 1 and 2,147,483,647 milliseconds, matching the daemon's Node timer range. This is separate from `observationTimeout(Duration)`, which only bounds local SSE observation and never sends a cancel mutation.
+
+Before creating a session, the SDK requires the daemon to advertise the REST transport and `session_scope_override`; this prevents an older daemon from silently ignoring the requested `thread` scope and attaching the client to a shared session. When `client_heartbeat` is advertised, an open session sends a fresh heartbeat every minute so the daemon does not reap an otherwise idle client. Set `heartbeatInterval(Duration.ZERO)` on the `DaemonClient` builder to disable this behavior, or choose a different positive interval. A heartbeat is never retried; the next scheduled heartbeat is a separate keepalive. Prompt observation is bounded to 32 concurrent prompts per client by default and can be adjusted with `maximumConcurrentPrompts`. Admission and terminal future callbacks run away from transport workers; callbacks that remain blocked consume bounded publication capacity. SSE stream cleanup is also bounded, and a close that remains blocked retains its cleanup reservation. Either condition can cause a later `startPrompt` to fail with `DaemonClientCapacityException` rather than dropping a timeout close or growing threads and queued work without limit.
+
+An indeterminate completion is an outcome boundary, not a session-reuse boundary. After `PromptAdmissionUnknownException` or `PromptOutcomeIndeterminateException`, that `DaemonSessionClient` permanently rejects further prompts even if local stream cleanup later succeeds; close or destroy the session instead. An observation timeout is published without waiting forever for a blocked stream close, while cleanup continues asynchronously and retains bounded client capacity until it finishes.
+
+## Legacy stdio API
+
+The existing `com.alibaba.qwen.code.cli` API remains available:
```java
public static void runSimpleExample() {
@@ -126,17 +180,24 @@ public static void runStreamingExample() {
other examples see src/test/java/com/alibaba/qwen/code/cli/example
+## Java 11 migration and alpha limits
+
+`0.1.0-alpha` raises the minimum Java version for the whole artifact from 8 to 11. Java 8 applications must remain on `0.0.3-alpha`. Logback is no longer a runtime dependency; add the SLF4J provider your application uses.
+
+This alpha deliberately fails closed when it cannot prove a prompt terminal. It does not guarantee exactly-once execution across daemon restarts, automatic epoch recovery, snapshot/resync, persisted cursors, or true prompt-ID-targeted cancellation. `prompt_cancelled` and queue events are advisory; only matching `turn_complete` and `turn_error` are terminal.
+
+If session creation has an ambiguous transport outcome, the daemon may retain a session whose ID never reached the caller. The SDK does not retry creation and cannot detach that unknown session; daemon-side lifecycle reaping is the recovery boundary.
+
## Architecture
-The SDK follows a layered architecture:
+The artifact contains two isolated implementations:
+
+- **Daemon API**: `DaemonClient` and `DaemonSessionClient` use REST mutations plus resumable SSE and own bounded HTTP, prompt, maintenance, and timer resources.
+- **Legacy stdio API**: `QwenCodeCli`, `Session`, and `ProcessTransport` manage a child CLI process using the existing CLI protocol DTOs and utilities.
-- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
-- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
-- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
-- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
-- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
+The daemon implementation does not reuse the legacy process transport, session model, DTOs, or global executor.
-## Key Features
+## Legacy stdio features
### Permission Modes
@@ -284,11 +345,12 @@ The SDK provides specific exception types for different error scenarios:
### Q: Do I need to install the Qwen CLI separately?
-A: yes, requires Qwen CLI 0.5.5 or higher.
+A: Yes. The daemon API requires a compatible `qwen serve`; the legacy stdio
+API requires qwen-code 0.5.0 or higher.
### Q: What Java versions are supported?
-A: The SDK requires Java 1.8 or higher.
+A: `0.1.0-alpha` requires Java 11 or higher. Java 8 users must remain on `0.0.3-alpha`.
### Q: How do I handle long-running requests?
diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts
index fb8fbbf896f..d71d59b5685 100644
--- a/packages/acp-bridge/src/bridge.test.ts
+++ b/packages/acp-bridge/src/bridge.test.ts
@@ -80,6 +80,7 @@ import {
import { SessionArtifactAuthorizationError } from './sessionArtifacts.js';
import {
MID_TURN_QUEUE_DRAIN_METHOD,
+ PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
} from './bridgeTypes.js';
@@ -6020,11 +6021,9 @@ describe('createAcpSessionBridge', () => {
const evt = await peerCancel;
expect(evt.type).toBe('prompt_cancelled');
expect((evt.data as { reason?: string }).reason).toBe('forward_failed');
- await vi.waitFor(() => {
- expect(cancelSpy).toHaveBeenCalledWith({
- sessionId: session.sessionId,
- });
- });
+ // A rejected prompt request has already settled in the child. Sending a
+ // late session-scoped cancel here could hit the queued successor.
+ expect(cancelSpy).not.toHaveBeenCalled();
peerAbort.abort();
await bridge.shutdown();
});
@@ -7135,7 +7134,16 @@ describe('createAcpSessionBridge', () => {
});
it('publishes a deadline turn_error, unlocks the FIFO, and clears active state when the agent wedges (DAEMON-003)', async () => {
- const handle = wedgeChannel();
+ const handle = makeChannel({
+ promptImpl: async (request) => {
+ const text = (request.prompt[0] as { text?: string }).text;
+ if (text === 'wedge') {
+ return new Promise(() => {});
+ }
+ return { stopReason: 'end_turn' };
+ },
+ cancelImpl: () => new Promise(() => {}),
+ });
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const events: BridgeEvent[] = [];
@@ -7253,6 +7261,9 @@ describe('createAcpSessionBridge', () => {
});
await new Promise((r) => setTimeout(r, 60));
expect(terminalsFor(events, 'prompt-queued-deadline')).toHaveLength(1);
+ expect(
+ bridge.getSessionSummary(session.sessionId).turnError,
+ ).toBeUndefined();
// A queued prompt never ran, so its deadline terminal must not
// advertise a session-level turnError nor arm the retry path — those
@@ -7657,6 +7668,286 @@ describe('createAcpSessionBridge', () => {
});
describe('cancelSession', () => {
+ it('cancels an admitted prompt before agent dispatch', async () => {
+ const events: BridgeEvent[] = [];
+ const handle = makeChannel({
+ cancelImpl: () => {
+ throw {
+ code: -32603,
+ message: 'Internal error',
+ data: { details: 'Not currently generating' },
+ };
+ },
+ });
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const sub = (async () => {
+ for await (const event of bridge.subscribeEvents(session.sessionId)) {
+ events.push(event);
+ }
+ })();
+ sub.catch(() => {});
+
+ const prompt = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'cancel immediately' }],
+ },
+ undefined,
+ { promptId: 'prompt-admitted' },
+ );
+ await bridge.cancelSession(session.sessionId);
+
+ await expect(prompt).rejects.toMatchObject({ name: 'AbortError' });
+ expect(handle.agent.promptCalls).toHaveLength(0);
+ expect(handle.agent.cancelCalls).toHaveLength(0);
+ await vi.waitFor(() => {
+ const terminals = events.filter(
+ (event) =>
+ (event.type === 'turn_complete' || event.type === 'turn_error') &&
+ event.promptId === 'prompt-admitted',
+ );
+ expect(terminals).toHaveLength(1);
+ expect(terminals[0]).toMatchObject({
+ type: 'turn_complete',
+ data: { stopReason: 'cancelled' },
+ });
+ });
+
+ await bridge.shutdown();
+ });
+
+ it('keeps cancelling while the child admits the prompt', async () => {
+ const promptEntered = deferred();
+ const cancelAccepted = deferred();
+ let generating = false;
+ const handle = makeChannel({
+ promptImpl: async () => {
+ promptEntered.resolve(undefined);
+ setTimeout(() => {
+ generating = true;
+ }, 10);
+ await cancelAccepted.promise;
+ return { stopReason: 'cancelled' };
+ },
+ cancelImpl: () => {
+ if (!generating) {
+ throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE);
+ }
+ cancelAccepted.resolve(undefined);
+ },
+ });
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const prompt = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'cancel during admission' }],
+ },
+ undefined,
+ { promptId: 'prompt-child-admission' },
+ );
+
+ await promptEntered.promise;
+ await bridge.cancelSession(session.sessionId);
+
+ await expect(prompt).resolves.toMatchObject({ stopReason: 'cancelled' });
+ expect(handle.agent.cancelCalls.length).toBeGreaterThan(1);
+ await bridge.shutdown();
+ });
+
+ it('drains a late cancel retry before dispatching the queued successor', async () => {
+ const firstPromptEntered = deferred();
+ const finishFirstPrompt = deferred();
+ const retryEntered = deferred();
+ const releaseRetry = deferred();
+ let cancelCalls = 0;
+ const handle = makeChannel({
+ promptImpl: async (request) => {
+ const text = (request.prompt[0] as { text?: string }).text;
+ if (text === 'first') {
+ firstPromptEntered.resolve(undefined);
+ return finishFirstPrompt.promise;
+ }
+ return { stopReason: 'end_turn' };
+ },
+ cancelImpl: async () => {
+ cancelCalls += 1;
+ if (cancelCalls === 1) {
+ throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE);
+ }
+ retryEntered.resolve(undefined);
+ await releaseRetry.promise;
+ },
+ });
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const first = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'first' }],
+ },
+ undefined,
+ { promptId: 'prompt-first' },
+ );
+ await firstPromptEntered.promise;
+ const second = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'second' }],
+ },
+ undefined,
+ { promptId: 'prompt-second' },
+ );
+
+ const cancel = bridge.cancelSession(session.sessionId);
+ await retryEntered.promise;
+ finishFirstPrompt.resolve({ stopReason: 'cancelled' });
+ await expect(first).resolves.toEqual({ stopReason: 'cancelled' });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(handle.agent.promptCalls).toHaveLength(1);
+
+ releaseRetry.resolve(undefined);
+ await cancel;
+ await expect(second).resolves.toEqual({ stopReason: 'end_turn' });
+ expect(handle.agent.promptCalls).toHaveLength(2);
+ await bridge.shutdown();
+ });
+
+ it('releases cancellation and queued work when the channel exits during the handshake', async () => {
+ const firstPromptEntered = deferred();
+ const handle = makeChannel({
+ promptImpl: async (request) => {
+ const text = (request.prompt[0] as { text?: string }).text;
+ if (text === 'first') {
+ firstPromptEntered.resolve(undefined);
+ return new Promise(() => {});
+ }
+ return { stopReason: 'end_turn' };
+ },
+ cancelImpl: () => new Promise(() => {}),
+ });
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const first = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'first' }],
+ },
+ undefined,
+ { promptId: 'prompt-first' },
+ );
+ void first.catch(() => {});
+ await firstPromptEntered.promise;
+ const second = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'second' }],
+ },
+ undefined,
+ { promptId: 'prompt-second' },
+ );
+ void second.catch(() => {});
+
+ const cancel = bridge.cancelSession(session.sessionId);
+ await vi.waitFor(() => {
+ expect(handle.agent.extMethodCalls).toContainEqual({
+ method: PROMPT_CANCEL_METHOD,
+ params: { sessionId: session.sessionId },
+ });
+ });
+ handle.crash({ exitCode: 1, signalCode: null });
+
+ await expect(cancel).rejects.toBeInstanceOf(BridgeChannelClosedError);
+ await expect(first).rejects.toBeInstanceOf(BridgeChannelClosedError);
+ await expect(second).rejects.toBeDefined();
+ expect(handle.agent.promptCalls).toHaveLength(1);
+ await bridge.shutdown();
+ });
+
+ it('falls back once to standard ACP cancellation for custom agents', async () => {
+ const promptEntered = deferred();
+ const cancelAccepted = deferred();
+ const handle = makeChannel({
+ promptCancelExtension: false,
+ promptImpl: async () => {
+ promptEntered.resolve(undefined);
+ await cancelAccepted.promise;
+ return { stopReason: 'cancelled' };
+ },
+ cancelImpl: () => cancelAccepted.resolve(undefined),
+ });
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const prompt = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: 'standard ACP fallback' }],
+ },
+ undefined,
+ { promptId: 'prompt-standard-cancel' },
+ );
+
+ await promptEntered.promise;
+ await bridge.cancelSession(session.sessionId);
+
+ await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
+ expect(handle.agent.extMethodCalls).toEqual([
+ {
+ method: PROMPT_CANCEL_METHOD,
+ params: { sessionId: session.sessionId },
+ },
+ ]);
+ expect(handle.agent.cancelCalls).toEqual([
+ { sessionId: session.sessionId },
+ ]);
+ await bridge.shutdown();
+ });
+
+ it('broadcasts immediate cancellation once for each prompt', async () => {
+ const events: BridgeEvent[] = [];
+ const handle = makeChannel();
+ const bridge = makeBridge({ channelFactory: async () => handle.channel });
+ const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
+ const sub = (async () => {
+ for await (const event of bridge.subscribeEvents(session.sessionId)) {
+ events.push(event);
+ }
+ })();
+ sub.catch(() => {});
+
+ for (const promptId of ['prompt-immediate-1', 'prompt-immediate-2']) {
+ const prompt = bridge.sendPrompt(
+ session.sessionId,
+ {
+ sessionId: session.sessionId,
+ prompt: [{ type: 'text', text: promptId }],
+ },
+ undefined,
+ { promptId },
+ );
+ await bridge.cancelSession(session.sessionId);
+ await expect(prompt).rejects.toMatchObject({ name: 'AbortError' });
+ }
+
+ await vi.waitFor(() => {
+ expect(
+ events
+ .filter((event) => event.type === 'prompt_cancelled')
+ .map((event) => event.promptId),
+ ).toEqual(['prompt-immediate-1', 'prompt-immediate-2']);
+ });
+ expect(handle.agent.promptCalls).toHaveLength(0);
+ await bridge.shutdown();
+ });
+
it('forwards a cancel notification with the routing id', async () => {
const handles: ChannelHandle[] = [];
const factory: ChannelFactory = async () => {
diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts
index 0c25a6d946e..9f48bdd10c7 100644
--- a/packages/acp-bridge/src/bridge.ts
+++ b/packages/acp-bridge/src/bridge.ts
@@ -101,6 +101,7 @@ import {
LOAD_REPLAY_MODE_META_KEY,
LOAD_REPLAY_PAGE_SIZE_META_KEY,
LOAD_REPLAY_VERSION,
+ PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
} from './bridgeTypes.js';
import { getChannelStartupProfileAttributes } from './channel-startup-profile.js';
@@ -590,16 +591,8 @@ interface SessionEntry {
errorKind?: string;
};
retryAllowed: boolean;
- /**
- * Per-prompt "already broadcast `prompt_cancelled`" latch. The explicit
- * `cancelSession` route and the `sendPrompt` abort path (originator SSE
- * drop) can both fire for the same active prompt — e.g. a client POSTs
- * /cancel then immediately closes its socket. Without dedup, peers
- * receive two `prompt_cancelled` frames for one turn. Reset to `false`
- * when the **next prompt starts** (the latch is per-prompt); set `true`
- * on the first broadcast.
- */
- cancelBroadcast?: boolean;
+ /** Prompt id whose `prompt_cancelled` event has already been broadcast. */
+ cancelBroadcastPromptId?: string;
/**
* Count of times `spawnOrAttach` has returned `attached: true` for
* this entry — i.e. a second-or-subsequent client claimed this
@@ -982,10 +975,9 @@ function broadcastPromptCancelled(
/**
* Dedup wrapper around {@link broadcastPromptCancelled}. Broadcasts at
- * most once per active prompt by latching `entry.cancelBroadcast`, so the
+ * most once per active prompt by recording its id, so the
* `cancelSession` route and the `sendPrompt` abort path can't both emit a
* `prompt_cancelled` for a single turn (POST /cancel then socket close).
- * The latch is reset when the next prompt starts.
*/
function broadcastPromptCancelledOnce(
entry: SessionEntry,
@@ -994,13 +986,15 @@ function broadcastPromptCancelledOnce(
originatorClientId: string | undefined,
reason?: 'forward_failed',
): void {
- if (entry.cancelBroadcast) {
+ if (promptId !== undefined && entry.cancelBroadcastPromptId === promptId) {
writeStderrLine(
- `broadcastPromptCancelledOnce: suppressed duplicate cancel for session ${sessionId} (latch already set)`,
+ `broadcastPromptCancelledOnce: suppressed duplicate cancel for session ${sessionId} prompt=${promptId}`,
);
return;
}
- entry.cancelBroadcast = true;
+ if (promptId !== undefined) {
+ entry.cancelBroadcastPromptId = promptId;
+ }
broadcastPromptCancelled(
entry,
sessionId,
@@ -1800,6 +1794,73 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// daemon. Cleared in the `finally` of the creator.
let inFlightChannelSpawn: Promise | undefined;
const byId = new Map();
+ const forwardRunningPromptCancel = async (
+ entry: SessionEntry,
+ pending: PendingPromptEntry,
+ notification: CancelNotification,
+ ): Promise => {
+ if (pending.cancelForwardInitial) {
+ return pending.cancelForwardInitial;
+ }
+ const initial = (async () => {
+ try {
+ const extension = entry.connection
+ .extMethod(PROMPT_CANCEL_METHOD, notification)
+ .then((result) => ({ kind: 'result' as const, result }));
+ const outcome = await Promise.race([
+ extension,
+ getTransportClosedReject(entry),
+ ...(pending.cancelForwardDeadline
+ ? [
+ pending.cancelForwardDeadline.then(() => ({
+ kind: 'deadline' as const,
+ })),
+ ]
+ : []),
+ ]);
+ if (outcome.kind === 'deadline') return;
+ const { result } = outcome;
+ if (typeof result['cancelled'] !== 'boolean') {
+ throw new Error(
+ `${PROMPT_CANCEL_METHOD} returned an invalid acknowledgement`,
+ );
+ }
+ } catch (error) {
+ if (
+ (typeof error === 'object' &&
+ error !== null &&
+ 'code' in error &&
+ error.code === -32601) ||
+ isNotCurrentlyGeneratingCancelError(error)
+ ) {
+ await Promise.race([
+ entry.connection.cancel(notification),
+ getTransportClosedReject(entry),
+ ...(pending.cancelForwardDeadline
+ ? [pending.cancelForwardDeadline]
+ : []),
+ ]);
+ return;
+ }
+ throw error;
+ }
+ })().catch((error) => {
+ if (pending.cancelForwardInitial === initial) {
+ delete pending.cancelForwardInitial;
+ }
+ throw error;
+ });
+ pending.cancelForwardInitial = initial;
+ // The same-revision extension resolves only after cancellation is handled
+ // (or the target prompt has already settled). ACP-compatible custom agents
+ // that do not implement it receive one standard session/cancel notification.
+ // The FIFO tail awaits this promise so no extension request remains in flight
+ // when prompt ownership advances, except when the prompt deadline invokes the
+ // documented DAEMON-003 overlap policy.
+ pending.cancelForwardDrain = initial;
+ void initial.catch(() => {});
+ return initial;
+ };
const generationRequests = new Map<
string,
{
@@ -5180,6 +5241,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// The race consumer may not be attached yet (or ever, for a queued
// prompt that never dispatches) — keep the rejection handled.
deadlinePromise.catch(() => {});
+ pendingEntry.cancelForwardDeadline = deadlinePromise.then(
+ () => undefined,
+ () => undefined,
+ );
const onDeadline = () => {
if (pendingEntry.terminalPublished) return;
const deadlineErr = new PromptDeadlineExceededError(deadlineMs);
@@ -5373,7 +5438,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
//
// Retry: skip echo — the original user_message_chunk is already
// in the transcript from the first attempt.
- entry.cancelBroadcast = false;
// Continuations carry no user prompt to echo (empty `prompt`);
// the original user_message_chunk is already in the transcript.
if (!isRetry && !isContinue) {
@@ -5388,6 +5452,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
settleActivePromptState(entry, pendingEntry.promptId);
throw echoErr;
}
+ pendingEntry.dispatched = true;
const promptPromise = entry.connection
.prompt(promptRequest)
.finally(() => {
@@ -5440,7 +5505,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// onDeadline already published the terminal and
// aborted the prompt — the abort listener (onAbort)
// ran synchronously and handled the cancel broadcast
- // + connection.cancel. Nothing to compensate.
+ // and cancellation handshake. Nothing to compensate.
return;
}
if (
@@ -5464,11 +5529,6 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
'forward_failed',
);
cancelPendingForSession(sessionId);
- entry.connection.cancel({ sessionId }).catch((err) => {
- writeStderrLine(
- `[pending-prompt] cancel forward failed after prompt abort session=${sessionId}: ${extractErrorMessage(err)}`,
- );
- });
},
)
.catch(() => {});
@@ -5485,11 +5545,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
originatorClientId,
);
cancelPendingForSession(sessionId);
- entry.connection.cancel({ sessionId }).catch((err) => {
- writeStderrLine(
- `[pending-prompt] cancel forward failed after removePendingPrompt session=${sessionId}: ${extractErrorMessage(err)}`,
- );
- });
+ if (byId.get(sessionId) === entry) {
+ void forwardRunningPromptCancel(entry, pendingEntry, {
+ sessionId,
+ }).catch((err) => {
+ writeStderrLine(
+ `[pending-prompt] cancel forward failed after removePendingPrompt session=${sessionId}: ${extractErrorMessage(err)}`,
+ );
+ });
+ }
};
if (abortSignal.aborted) {
onAbort();
@@ -5539,9 +5603,17 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
);
// Tail swallows failures so subsequent prompts still run. The caller
// still sees rejections on its own `result` reference.
+ const drainCancelForwarding = async (): Promise => {
+ try {
+ await pendingEntry.cancelForwardDrain;
+ } catch {
+ // The initiating mutation already reports or logs forwarding
+ // failures. The queue only needs to fence any in-flight write.
+ }
+ };
entry.promptQueue = result.then(
- () => undefined,
- () => undefined,
+ drainCancelForwarding,
+ drainCancelForwarding,
);
result
.finally(() => {
@@ -5637,6 +5709,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
entry,
context?.clientId,
);
+ const runningPrompt = entry.pendingPromptList.find(
+ (pending) => pending.state === 'running' && !pending.terminalPublished,
+ );
// Broadcast `prompt_cancelled` so other SSE-subscribed clients see
// the cancel as a first-class event rather than inferring it from
// the absence of further `agent_message_chunk` frames. Mirrors
@@ -5656,14 +5731,13 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// user-voted); this top-level `prompt_cancelled` carries the
// cancelling client so peer UIs can attribute it.
//
- // `...Once` dedups against the `sendPrompt` abort path so a client
- // that POSTs /cancel and then drops its socket doesn't emit two
- // `prompt_cancelled` frames for the same turn. The latch resets at
- // the next prompt start, so a later turn still broadcasts.
+ // `...Once` dedups against the `sendPrompt` abort path by prompt id, so
+ // a client that POSTs /cancel and then drops its socket doesn't emit two
+ // `prompt_cancelled` frames for the same turn.
broadcastPromptCancelledOnce(
entry,
sessionId,
- entry.activePromptId,
+ entry.activePromptId ?? runningPrompt?.promptId,
cancelOriginatorClientId,
);
// ACP spec: cancelling a prompt MUST resolve outstanding
@@ -5696,6 +5770,21 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
'session.id': sessionId,
},
async () => {
+ if (runningPrompt) {
+ const forwarding =
+ runningPrompt.dispatched === true &&
+ entry.activePromptId === runningPrompt.promptId
+ ? forwardRunningPromptCancel(entry, runningPrompt, notif)
+ : Promise.resolve();
+ runningPrompt.abortController.abort(
+ new DOMException(
+ 'Prompt cancelled before dispatch',
+ 'AbortError',
+ ),
+ );
+ await forwarding;
+ return;
+ }
try {
await entry.connection.cancel(notif);
} catch (err) {
diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts
index 663ddc3d1d2..c42070f7d1b 100644
--- a/packages/acp-bridge/src/bridgeTypes.ts
+++ b/packages/acp-bridge/src/bridgeTypes.ts
@@ -545,6 +545,9 @@ export const MID_TURN_QUEUE_DRAIN_METHOD = 'craft/drainMidTurnQueue';
export const TODO_STOP_GUARD_QUEUE_RELEASE_METHOD =
'craft/todoStopGuardQueueReleased';
+/** Parent-to-agent request that acknowledges prompt cancellation handling. */
+export const PROMPT_CANCEL_METHOD = 'craft/cancelPendingPrompt';
+
/**
* Reverse tool channel marker (issue #5626, Phase 2). The parent serve process
* stamps this boolean on a client-hosted (extension) MCP server's
@@ -601,6 +604,14 @@ export interface PendingPromptEntry {
* later publish attempts for the same prompt are suppressed.
*/
terminalPublished?: boolean;
+ /** Cancellation handshake; duplicate callers await rather than resend it. */
+ cancelForwardInitial?: Promise;
+ /** Full cancellation handshake, used to fence the next FIFO dispatch. */
+ cancelForwardDrain?: Promise;
+ /** Releases the cancellation fence when the prompt deadline expires. */
+ cancelForwardDeadline?: Promise;
+ /** True after the prompt request has been handed to the ACP connection. */
+ dispatched?: boolean;
/**
* Set when `removePendingPrompt` cancels a RUNNING prompt. The entry
* stays on `pendingPromptList` (hidden from `getPendingPrompts`) until
diff --git a/packages/acp-bridge/src/internal/testUtils.ts b/packages/acp-bridge/src/internal/testUtils.ts
index 41a7627d773..66aad392223 100644
--- a/packages/acp-bridge/src/internal/testUtils.ts
+++ b/packages/acp-bridge/src/internal/testUtils.ts
@@ -39,6 +39,7 @@ import * as path from 'node:path';
import {
AgentSideConnection,
PROTOCOL_VERSION,
+ RequestError,
ndJsonStream,
} from '@agentclientprotocol/sdk';
import type {
@@ -62,8 +63,9 @@ import type {
SetSessionModeResponse,
} from '@agentclientprotocol/sdk';
import { createAcpSessionBridge } from '../bridge.js';
+import { isNotCurrentlyGeneratingCancelError } from '../bridgeErrors.js';
import type { BridgeOptions } from '../bridgeOptions.js';
-import type { AcpSessionBridge } from '../bridgeTypes.js';
+import { PROMPT_CANCEL_METHOD, type AcpSessionBridge } from '../bridgeTypes.js';
import type { AcpChannel } from '../channel.js';
// Workspace fixtures must round-trip through `path.resolve` so the
@@ -119,6 +121,8 @@ export interface FakeAgentOpts {
self: FakeAgent,
) => Promise | PromptResponse;
cancelImpl?: (p: CancelNotification, self: FakeAgent) => Promise | void;
+ /** Make the fake expose only standard ACP cancellation. */
+ promptCancelExtension?: boolean;
/**
* Custom `newSession` handler. Default returns a synthesized id (see
* `newSession` below). Used by tests that need to exercise the
@@ -234,6 +238,29 @@ export class FakeAgent implements Agent {
params: Record,
): Promise> {
this.extMethodCalls.push({ method, params });
+ if (method === PROMPT_CANCEL_METHOD) {
+ if (this.opts.promptCancelExtension === false) {
+ throw RequestError.methodNotFound(method);
+ }
+ const sessionId = params['sessionId'];
+ if (typeof sessionId !== 'string') {
+ throw new Error('Invalid or missing sessionId');
+ }
+ let delayMs = 1;
+ while (true) {
+ try {
+ await this.cancel({ sessionId });
+ return { cancelled: true };
+ } catch (error) {
+ if (!isNotCurrentlyGeneratingCancelError(error)) throw error;
+ }
+ await new Promise((resolve) => {
+ const timer = setTimeout(resolve, delayMs);
+ timer.unref();
+ });
+ delayMs = Math.min(delayMs * 2, 100);
+ }
+ }
if (this.opts.extMethodImpl) {
return this.opts.extMethodImpl(method, params, this);
}
diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts
index c111f8ccabe..5a2e8618010 100644
--- a/packages/cli/src/acp-integration/acpAgent.test.ts
+++ b/packages/cli/src/acp-integration/acpAgent.test.ts
@@ -849,6 +849,7 @@ import { buildAuthMethods } from './authMethods.js';
import {
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
+ PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
} from '@qwen-code/acp-bridge/bridgeTypes';
import {
@@ -1478,8 +1479,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
rewindToTurn: ReturnType;
getRewindableUserTurnCount: ReturnType;
clearTodoStopGuardTrust: ReturnType;
- releaseTodoStopGuardQueuedPromptWait: ReturnType;
+ cancelPendingPrompt: ReturnType;
prompt: ReturnType;
+ releaseTodoStopGuardQueuedPromptWait: ReturnType;
}
| undefined;
let processExitSpy: MockInstance;
@@ -1681,6 +1683,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
_meta: { keep: true },
},
invocation,
+ expect.any(AbortSignal),
);
mockConnectionState.resolve();
await agentPromise;
@@ -1764,6 +1767,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
_meta: { keep: true },
},
undefined,
+ expect.any(AbortSignal),
);
mockConnectionState.resolve();
await agentPromise;
@@ -2745,6 +2749,58 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
+ it('acknowledges prompt cancellation after the tracked prompt settles', async () => {
+ const sessionId = '11111111-1111-1111-1111-111111111111';
+ await setupSessionMocks(sessionId);
+ const { agent, agentPromise } = await bootAcpAgent();
+ await agent.newSession({ cwd: '/tmp', mcpServers: [] });
+ let finishPrompt: ((value: unknown) => void) | undefined;
+ lastSessionMock?.prompt.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ finishPrompt = resolve;
+ }),
+ );
+ const prompt = agent.prompt({ sessionId, prompt: [] });
+
+ await vi.waitFor(() => expect(lastSessionMock?.prompt).toHaveBeenCalled());
+ const cancellationSignal = lastSessionMock?.prompt.mock.calls[0]?.[2] as
+ | AbortSignal
+ | undefined;
+
+ let cancellationSettled = false;
+ const cancellation = agent
+ .extMethod(PROMPT_CANCEL_METHOD, { sessionId })
+ .finally(() => {
+ cancellationSettled = true;
+ });
+ await vi.waitFor(() => expect(cancellationSignal?.aborted).toBe(true));
+ expect(cancellationSettled).toBe(false);
+ expect(lastSessionMock?.cancelPendingPrompt).not.toHaveBeenCalled();
+
+ finishPrompt?.({ stopReason: 'cancelled' });
+ await expect(cancellation).resolves.toEqual({ cancelled: true });
+ await prompt;
+
+ mockConnectionState.resolve();
+ await agentPromise;
+ });
+
+ it('acknowledges cancellation as a no-op when no prompt call is active', async () => {
+ const sessionId = '11111111-1111-1111-1111-111111111111';
+ await setupSessionMocks(sessionId);
+ const { agent, agentPromise } = await bootAcpAgent();
+ await agent.newSession({ cwd: '/tmp', mcpServers: [] });
+
+ await expect(
+ agent.extMethod(PROMPT_CANCEL_METHOD, { sessionId }),
+ ).resolves.toEqual({ cancelled: false });
+ expect(lastSessionMock?.cancelPendingPrompt).not.toHaveBeenCalled();
+
+ mockConnectionState.resolve();
+ await agentPromise;
+ });
+
it('reconnects an MCP server in every live non-pooled runtime', async () => {
const server = { command: 'node', args: ['server.js'] };
const workspaceDiscover = vi.fn().mockResolvedValue(undefined);
diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts
index e7473ede0ef..043d7b00120 100644
--- a/packages/cli/src/acp-integration/acpAgent.ts
+++ b/packages/cli/src/acp-integration/acpAgent.ts
@@ -296,6 +296,7 @@ import {
LOAD_REPLAY_MODE_META_KEY,
LOAD_REPLAY_PAGE_SIZE_META_KEY,
LOAD_REPLAY_VERSION,
+ PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
type ClientMcpOverWsRuntimeConfig,
type BridgeLoadReplayEnvelope,
@@ -3003,8 +3004,14 @@ interface PendingMcpAuthentication {
}>;
}
+interface ActivePromptCall {
+ controller: AbortController;
+ settled: Promise;
+}
+
class QwenAgent implements Agent {
private sessions: Map = new Map();
+ private activePromptCalls = new Map>();
private workspaceMcpDiscoveryConfig: Config | undefined;
private workspaceMcpDiscoveryPromise: Promise | undefined;
private workspaceMcpDiscoveryError: string | undefined;
@@ -4443,7 +4450,32 @@ class QwenAgent implements Agent {
'Invalid trusted ACP invocation context',
);
}
- return session.prompt(sanitizedParams, invocationContext);
+ let settleCall = () => {};
+ const call: ActivePromptCall = {
+ controller: new AbortController(),
+ settled: new Promise((resolve) => {
+ settleCall = resolve;
+ }),
+ };
+ let calls = this.activePromptCalls.get(params.sessionId);
+ if (!calls) {
+ calls = new Set();
+ this.activePromptCalls.set(params.sessionId, calls);
+ }
+ calls.add(call);
+ try {
+ return await session.prompt(
+ sanitizedParams,
+ invocationContext,
+ call.controller.signal,
+ );
+ } finally {
+ calls.delete(call);
+ if (calls.size === 0) {
+ this.activePromptCalls.delete(params.sessionId);
+ }
+ settleCall();
+ }
}
async cancel(params: CancelNotification): Promise {
@@ -6685,6 +6717,31 @@ class QwenAgent implements Agent {
const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/;
switch (method) {
+ case PROMPT_CANCEL_METHOD: {
+ const sessionId = params['sessionId'];
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
+ throw RequestError.invalidParams(
+ undefined,
+ 'Invalid or missing sessionId',
+ );
+ }
+ const session = this.sessions.get(sessionId);
+ if (!session) {
+ throw RequestError.invalidParams(
+ undefined,
+ `Session not found for id: ${sessionId}`,
+ );
+ }
+ const targetedCalls = new Set(
+ this.activePromptCalls.get(sessionId) ?? [],
+ );
+ if (targetedCalls.size === 0) {
+ return { cancelled: false };
+ }
+ targetedCalls.forEach((call) => call.controller.abort());
+ await Promise.all(Array.from(targetedCalls, (call) => call.settled));
+ return { cancelled: true };
+ }
case TODO_STOP_GUARD_QUEUE_RELEASE_METHOD: {
const sessionId = params['sessionId'];
if (typeof sessionId !== 'string' || sessionId.length === 0) {
diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts
index 329cd3ace36..a6e6f6843d7 100644
--- a/packages/cli/src/acp-integration/session/Session.test.ts
+++ b/packages/cli/src/acp-integration/session/Session.test.ts
@@ -774,6 +774,32 @@ describe('Session', () => {
});
});
+ it('cancels an admitted call without cancelling a background turn', async () => {
+ let releaseAdmission!: () => void;
+ const admission = new Promise((resolve) => {
+ releaseAdmission = resolve;
+ });
+ mockConfig.assertCanStartTurn = vi.fn().mockReturnValue(admission);
+ const cancellation = new AbortController();
+
+ const prompt = session.prompt(
+ {
+ sessionId: 'test-session-id',
+ prompt: [{ type: 'text', text: 'hello' }],
+ },
+ undefined,
+ cancellation.signal,
+ );
+ await vi.waitFor(() =>
+ expect(mockConfig.assertCanStartTurn).toHaveBeenCalledOnce(),
+ );
+ cancellation.abort();
+ releaseAdmission();
+
+ await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
+ expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
+ });
+
it('pins durable cron startup, prompt restart, and stop to the session runtime', async () => {
const runtimeDir = path.resolve('runtime', 'cron-session');
const observedStarts: string[] = [];
@@ -16418,6 +16444,54 @@ describe('Session', () => {
});
}
+ it('cleans up an admitted retry cancelled during previous-turn drain', async () => {
+ rebuildSessionWithGuard();
+ let releasePreviousTurn!: () => void;
+ const previousTurn = new Promise((resolve) => {
+ releasePreviousTurn = resolve;
+ });
+ const cancellation = new AbortController();
+ const internals = session as unknown as {
+ pendingPrompt: AbortController | null;
+ pendingPromptCompletion: Promise | null;
+ todoStopGuard: {
+ hasTrustedUnfinishedState: boolean;
+ isHardSuspended: boolean;
+ observeTodoWrite(resultDisplay: unknown, allowArm: boolean): boolean;
+ };
+ };
+ internals.todoStopGuard.observeTodoWrite(
+ { type: 'todo_list', todos: pendingTodos },
+ true,
+ );
+ expect(internals.todoStopGuard.hasTrustedUnfinishedState).toBe(true);
+ internals.pendingPromptCompletion = previousTurn;
+
+ const prompt = session.prompt(
+ {
+ sessionId: 'test-session-id',
+ prompt: [{ type: 'text', text: 'retry after cancellation' }],
+ _meta: { 'qwen.daemon.retry': true },
+ } as Parameters[0],
+ undefined,
+ cancellation.signal,
+ );
+ await vi.waitFor(() => {
+ expect(internals.pendingPrompt).not.toBeNull();
+ expect(internals.todoStopGuard.isHardSuspended).toBe(false);
+ });
+
+ cancellation.abort();
+ releasePreviousTurn();
+
+ await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
+ expect(internals.pendingPrompt).toBeNull();
+ expect(internals.todoStopGuard.isHardSuspended).toBe(true);
+ await expect(session.cancelPendingPrompt()).rejects.toThrow(
+ 'Not currently generating',
+ );
+ });
+
function createDeferredAbortStream() {
let markStarted!: () => void;
const started = new Promise((resolve) => {
diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts
index 82359236c74..e066576f384 100644
--- a/packages/cli/src/acp-integration/session/Session.ts
+++ b/packages/cli/src/acp-integration/session/Session.ts
@@ -1985,11 +1985,15 @@ export class Session implements SessionContext {
async prompt(
params: PromptRequest,
invocationContext?: InvocationContextV1,
+ admissionCancellation?: AbortSignal,
): Promise {
if (this.closing) {
throw RequestError.invalidParams(undefined, 'Session is closing');
}
await this.assertCanStartTurn();
+ if (admissionCancellation?.aborted) {
+ return { stopReason: 'cancelled' };
+ }
const todoStopGuardPreparation =
this.#prepareTodoStopGuardForPrompt(params);
// After writer admission, install this prompt's AbortController before
@@ -1997,7 +2001,20 @@ export class Session implements SessionContext {
// targets us. A cancel during admission cannot target this pending prompt.
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
+ const cancelPendingSend = () => pendingSend.abort(USER_CANCEL_ABORT_REASON);
+ if (admissionCancellation) {
+ admissionCancellation.addEventListener('abort', cancelPendingSend, {
+ once: true,
+ });
+ if (admissionCancellation.aborted) cancelPendingSend();
+ }
this.pendingPrompt = pendingSend;
+ const releasePendingSend = () => {
+ admissionCancellation?.removeEventListener('abort', cancelPendingSend);
+ if (this.pendingPrompt === pendingSend) {
+ this.pendingPrompt = null;
+ }
+ };
// Abort the previous turn's in-flight follow-up suggestion
// generation (if any). Mirrors `pendingPrompt?.abort()` above —
@@ -2051,6 +2068,8 @@ export class Session implements SessionContext {
// Cancelled while waiting for the previous prompt to finish.
if (pendingSend.signal.aborted) {
+ releasePendingSend();
+ this.todoStopGuard.suspend();
return { stopReason: 'cancelled' };
}
@@ -2075,7 +2094,7 @@ export class Session implements SessionContext {
pendingSend,
invocationContext,
);
- this.pendingPrompt = null;
+ releasePendingSend();
// Drain any cron prompts that queued while the prompt was active
void this.#drainCronQueue();
void this.#drainNotificationQueue();
@@ -2089,7 +2108,7 @@ export class Session implements SessionContext {
}
throw error;
} finally {
- this.pendingPrompt = null;
+ releasePendingSend();
const shouldDrainAutomaticQueues =
todoStopGuardPreparation.drainSupersededAutomaticQueues ||
this.todoStopGuardDrainAutomaticQueuesWhenIdle ||
diff --git a/packages/sdk-java/qwencode/QWEN.md b/packages/sdk-java/qwencode/QWEN.md
index 0486fecaf03..701cf51db8a 100644
--- a/packages/sdk-java/qwencode/QWEN.md
+++ b/packages/sdk-java/qwencode/QWEN.md
@@ -1,377 +1,25 @@
# Qwen Code Java SDK
-## Project Overview
+This package publishes `com.alibaba:qwencode-sdk:0.1.0-alpha` and requires
+Java 11 or newer. Java 8 users must remain on `0.0.3-alpha`.
-The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
+The recommended API is the Java 11 daemon transport in
+`com.alibaba.qwen.code.daemon`. It talks to `qwen serve` through REST and SSE,
+creates thread-scoped sessions by default, fails closed without a reliable
+prompt terminal, and uses periodic heartbeats when the daemon advertises them.
-**Context Information:**
+The experimental stdio API remains in `com.alibaba.qwen.code.cli` for source
+compatibility. The daemon package is intentionally independent of its process
+transport, DTOs, sessions, and global executor.
-- Current Date: Monday 5 January 2026
-- Operating System: darwin
-- Working Directory: /Users/weigeng/repos/qwen-code/packages/sdk-java
-
-## Project Details
-
-- **Group ID**: com.alibaba
-- **Artifact ID**: qwencode-sdk (as per pom.xml)
-- **Version**: 0.0.1-SNAPSHOT
-- **Packaging**: JAR
-- **Java Version**: 1.8+ (source and target)
-- **License**: Apache-2.0
-
-## Architecture
-
-The SDK follows a layered architecture:
-
-- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
-- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
-- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
-- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
-- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
-
-## Key Components
-
-### Main Classes
-
-- `QwenCodeCli`: Main entry point with static methods for simple queries
-- `Session`: Manages communication sessions with the CLI
-- `Transport`: Abstracts the communication mechanism (currently using process transport)
-- `ProcessTransport`: Implementation that communicates via process execution
-- `TransportOptions`: Configuration class for transport layer settings
-- `SessionEventSimpleConsumers`: High-level event handler for processing responses
-- `AssistantContentSimpleConsumers`: Handles different types of content within assistant messages
-
-### Dependencies
-
-- **Logging**: ch.qos.logback:logback-classic
-- **Utilities**: org.apache.commons:commons-lang3
-- **JSON Processing**: com.alibaba.fastjson2:fastjson2
-- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
-
-## Building and Running
-
-### Prerequisites
-
-- Java 8 or higher
-- Apache Maven 3.6.0 or higher
-
-### Build Commands
+Build and test with Maven:
```bash
-# Compile the project
-mvn compile
-
-# Run tests
mvn test
-
-# Package the JAR
-mvn package
-
-# Install to local repository
-mvn install
-
-# Run checkstyle verification
mvn checkstyle:check
-
-# Generate Javadoc
-mvn javadoc:javadoc
-```
-
-### Testing
-
-The project includes basic unit tests using JUnit 5. The main test class `QwenCodeCliTest` demonstrates how to use the SDK to make simple queries to the Qwen Code CLI.
-
-### Code Quality
-
-The project uses Checkstyle for code formatting and style enforcement. The configuration is defined in `checkstyle.xml` and includes rules for:
-
-- Whitespace and indentation
-- Naming conventions
-- Import ordering
-- Code structure
-- Line endings (LF only)
-- No trailing whitespace
-- 8-space indentation for line wrapping
-
-## Development Conventions
-
-### Coding Standards
-
-- Java 8 language features are supported
-- Follow standard Java naming conventions
-- Use UTF-8 encoding for source files
-- Line endings should be LF (Unix-style)
-- No trailing whitespace allowed
-- Use 8-space indentation for line wrapping
-
-### Testing Practices
-
-- Write unit tests using JUnit 5
-- Test classes should be in the `src/test/java` directory
-- Follow the naming convention `*Test.java` for test classes
-- Use appropriate assertions to validate functionality
-
-### Documentation
-
-- API documentation should follow Javadoc conventions
-- Update README files when adding new features
-- Include examples in documentation
-
-## API Reference
-
-### QwenCodeCli Class
-
-The main class provides several primary methods:
-
-- `simpleQuery(String prompt)`: Synchronous method that returns a list of responses
-- `simpleQuery(String prompt, TransportOptions transportOptions)`: Synchronous method with custom transport options
-- `simpleQuery(String prompt, TransportOptions transportOptions, AssistantContentConsumers assistantContentConsumers)`: Advanced method with custom content consumers
-- `newSession()`: Creates a new session with default options
-- `newSession(TransportOptions transportOptions)`: Creates a new session with custom options
-
-### Permission Modes
-
-The SDK supports different permission modes for controlling tool execution:
-
-- **`default`**: Write tools are denied unless approved via `canUseTool` callback or in `allowedTools`. Read-only tools execute without confirmation.
-- **`plan`**: Blocks all write tools, instructing AI to present a plan first.
-- **`auto-edit`**: Auto-approve edit tools (`edit`, `write_file`, `notebook_edit`) while other tools require confirmation.
-- **`yolo`**: All tools execute automatically without confirmation.
-
-### Transport Options
-
-The `TransportOptions` class allows configuration of how the SDK communicates with the Qwen Code CLI:
-
-- `pathToQwenExecutable`: Path to the Qwen Code CLI executable
-- `cwd`: Working directory for the CLI process
-- `model`: AI model to use for the session
-- `permissionMode`: Permission mode that controls tool execution
-- `env`: Environment variables to pass to the CLI process
-- `maxSessionTurns`: Limits the number of conversation turns in a session
-- `coreTools`: List of core tools that should be available to the AI
-- `excludeTools`: List of tools to exclude from being available to the AI
-- `allowedTools`: List of tools that are pre-approved for use without additional confirmation
-- `authType`: Authentication type to use for the session
-- `includePartialMessages`: Enables receiving partial messages during streaming responses
-- `turnTimeout`: Timeout for a complete turn of conversation
-- `messageTimeout`: Timeout for individual messages within a turn
-- `resumeSessionId`: ID of a previous session to resume
-- `otherOptions`: Additional command-line options to pass to the CLI
-
-### Session Control Features
-
-- **Session creation**: Use `QwenCodeCli.newSession()` to create a new session with custom options
-- **Session management**: The `Session` class provides methods to send prompts, handle responses, and manage session state
-- **Session cleanup**: Always close sessions using `session.close()` to properly terminate the CLI process
-- **Session resumption**: Use `setResumeSessionId()` in `TransportOptions` to resume a previous session
-- **Session interruption**: Use `session.interrupt()` to interrupt a currently running prompt
-- **Dynamic model switching**: Use `session.setModel()` to change the model during a session
-- **Dynamic permission mode switching**: Use `session.setPermissionMode()` to change the permission mode during a session
-
-### Thread Pool Configuration
-
-The SDK uses a thread pool for managing concurrent operations with the following default configuration:
-
-- **Core Pool Size**: 30 threads
-- **Maximum Pool Size**: 100 threads
-- **Keep-Alive Time**: 60 seconds
-- **Queue Capacity**: 300 tasks (using LinkedBlockingQueue)
-- **Thread Naming**: "qwen_code_cli-pool-{number}"
-- **Daemon Threads**: false
-- **Rejected Execution Handler**: CallerRunsPolicy
-
-### Session Event Consumers and Assistant Content Consumers
-
-The SDK provides two key interfaces for handling events and content from the CLI:
-
-#### SessionEventConsumers Interface
-
-The `SessionEventConsumers` interface provides callbacks for different types of messages during a session:
-
-- `onSystemMessage`: Handles system messages from the CLI (receives Session and SDKSystemMessage)
-- `onResultMessage`: Handles result messages from the CLI (receives Session and SDKResultMessage)
-- `onAssistantMessage`: Handles assistant messages (AI responses) (receives Session and SDKAssistantMessage)
-- `onPartialAssistantMessage`: Handles partial assistant messages during streaming (receives Session and SDKPartialAssistantMessage)
-- `onUserMessage`: Handles user messages (receives Session and SDKUserMessage)
-- `onOtherMessage`: Handles other types of messages (receives Session and String message)
-- `onControlResponse`: Handles control responses (receives Session and CLIControlResponse)
-- `onControlRequest`: Handles control requests (receives Session and CLIControlRequest, returns CLIControlResponse)
-- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlRequest, returns Behavior)
-
-#### AssistantContentConsumers Interface
-
-The `AssistantContentConsumers` interface handles different types of content within assistant messages:
-
-- `onText`: Handles text content (receives Session and TextAssistantContent)
-- `onThinking`: Handles thinking content (receives Session and ThinkingAssistantContent)
-- `onToolUse`: Handles tool use content (receives Session and ToolUseAssistantContent)
-- `onToolResult`: Handles tool result content (receives Session and ToolResultAssistantContent)
-- `onOtherContent`: Handles other content types (receives Session and AssistantContent)
-- `onUsage`: Handles usage information (receives Session and AssistantUsage)
-- `onPermissionRequest`: Handles permission requests (receives Session and CLIControlPermissionRequest, returns Behavior)
-- `onOtherControlRequest`: Handles other control requests (receives Session and ControlRequestPayload, returns ControlResponsePayload)
-
-#### Relationship Between the Interfaces
-
-**Important Note on Event Hierarchy:**
-
-- `SessionEventConsumers` is the **high-level** event processor that handles different message types (system, assistant, user, etc.)
-- `AssistantContentConsumers` is the **low-level** content processor that handles different types of content within assistant messages (text, tools, thinking, etc.)
-
-**Processor Relationship:**
-
-- `SessionEventConsumers` → `AssistantContentConsumers` (SessionEventConsumers uses AssistantContentConsumers to process content within assistant messages)
-
-**Event Derivation Relationships:**
-
-- `onAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`, `onUsage`
-- `onPartialAssistantMessage` → `onText`, `onThinking`, `onToolUse`, `onToolResult`, `onOtherContent`
-- `onControlRequest` → `onPermissionRequest`, `onOtherControlRequest`
-
-**Event Timeout Relationships:**
-
-Each event handler method has a corresponding timeout method that allows customizing the timeout behavior for that specific event:
-
-- `onSystemMessage` ↔ `onSystemMessageTimeout`
-- `onResultMessage` ↔ `onResultMessageTimeout`
-- `onAssistantMessage` ↔ `onAssistantMessageTimeout`
-- `onPartialAssistantMessage` ↔ `onPartialAssistantMessageTimeout`
-- `onUserMessage` ↔ `onUserMessageTimeout`
-- `onOtherMessage` ↔ `onOtherMessageTimeout`
-- `onControlResponse` ↔ `onControlResponseTimeout`
-- `onControlRequest` ↔ `onControlRequestTimeout`
-
-For AssistantContentConsumers timeout methods:
-
-- `onText` ↔ `onTextTimeout`
-- `onThinking` ↔ `onThinkingTimeout`
-- `onToolUse` ↔ `onToolUseTimeout`
-- `onToolResult` ↔ `onToolResultTimeout`
-- `onOtherContent` ↔ `onOtherContentTimeout`
-- `onPermissionRequest` ↔ `onPermissionRequestTimeout`
-- `onOtherControlRequest` ↔ `onOtherControlRequestTimeout`
-
-**Default Timeout Values:**
-
-- `SessionEventSimpleConsumers` default timeout: 180 seconds (Timeout.TIMEOUT_180_SECONDS)
-- `AssistantContentSimpleConsumers` default timeout: 60 seconds (Timeout.TIMEOUT_60_SECONDS)
-
-**Timeout Hierarchy Requirements:**
-
-For proper operation, the following timeout relationships should be maintained:
-
-- `onAssistantMessageTimeout` return value should be greater than `onTextTimeout`, `onThinkingTimeout`, `onToolUseTimeout`, `onToolResultTimeout`, and `onOtherContentTimeout` return values
-- `onControlRequestTimeout` return value should be greater than `onPermissionRequestTimeout` and `onOtherControlRequestTimeout` return values
-
-#### Relationship Between the Interfaces
-
-- `AssistantContentSimpleConsumers` is the default implementation of `AssistantContentConsumers`
-- `SessionEventSimpleConsumers` is the concrete implementation that combines both interfaces and depends on an `AssistantContentConsumers` instance to handle content within assistant messages
-- The timeout methods in `SessionEventConsumers` now include the message object as a parameter (e.g., `onSystemMessageTimeout(Session session, SDKSystemMessage systemMessage)`)
-
-Event processing is subject to the timeout settings configured in `TransportOptions` and `SessionEventConsumers`. For detailed timeout configuration options, see the "Timeout" section above.
-
-## Usage Examples
-
-The SDK includes several example files in `src/test/java/com/alibaba/qwen/code/cli/example/` that demonstrate different aspects of the API:
-
-### Basic Usage
-
-- `QuickStartExample.java`: Demonstrates simple query usage, transport options configuration, and streaming content handling
-
-### Session Control
-
-- `SessionExample.java`: Shows session control features including permission mode changes, model switching, interruption, and event handling
-
-### Configuration
-
-- `ThreadPoolConfigurationExample.java`: Shows how to configure the thread pool used by the SDK
-
-## Error Handling
-
-The SDK provides specific exception types for different error scenarios:
-
-- `SessionControlException`: Thrown when there's an issue with session control (creation, initialization, etc.)
-- `SessionSendPromptException`: Thrown when there's an issue sending a prompt or receiving a response
-- `SessionClosedException`: Thrown when attempting to use a closed session
-
-## Project Structure
-
-```
-src/
-├── example/
-│ └── java/
-│ └── com/
-│ └── alibaba/
-│ └── qwen/
-│ └── code/
-│ └── example/
-├── main/
-│ └── java/
-│ └── com/
-│ └── alibaba/
-│ └── qwen/
-│ └── code/
-│ └── cli/
-│ ├── QwenCodeCli.java
-│ ├── protocol/
-│ ├── session/
-│ ├── transport/
-│ └── utils/
-└── test/
- ├── java/
- │ └── com/
- │ └── alibaba/
- │ └── qwen/
- │ └── code/
- │ └── cli/
- │ ├── QwenCodeCliTest.java
- │ ├── session/
- │ │ └── SessionTest.java
- │ └── transport/
- │ ├── PermissionModeTest.java
- │ └── process/
- │ └── ProcessTransportTest.java
- └── temp/
+mvn package
```
-## Configuration Files
-
-- `pom.xml`: Maven build configuration and dependencies
-- `checkstyle.xml`: Code style and formatting rules
-- `.editorconfig`: Editor configuration settings
-
-## FAQ / Troubleshooting
-
-### Q: Do I need to install the Qwen CLI separately?
-
-A: No, from v0.1.1, the CLI is bundled with the SDK, so no standalone CLI installation is needed.
-
-### Q: What Java versions are supported?
-
-A: The SDK requires Java 1.8 or higher.
-
-### Q: How do I handle long-running requests?
-
-A: The SDK includes timeout utilities. You can configure timeouts using the `Timeout` class in `TransportOptions`.
-
-### Q: Why are some tools not executing?
-
-A: This is likely due to permission modes. Check your permission mode settings and consider using `allowedTools` to pre-approve certain tools.
-
-### Q: How do I resume a previous session?
-
-A: Use the `setResumeSessionId()` method in `TransportOptions` to resume a previous session.
-
-### Q: Can I customize the environment for the CLI process?
-
-A: Yes, use the `setEnv()` method in `TransportOptions` to pass environment variables to the CLI process.
-
-### Q: What happens if the CLI process crashes?
-
-A: The SDK will throw appropriate exceptions. Make sure to handle `SessionControlException` and implement retry logic if needed.
-
-## Maintainers
-
-- **Developer**: skyfire (gengwei.gw(at)alibaba-inc.com)
-- **Organization**: Alibaba Group
+Use the package `README.md` for API examples, compatibility notes, reliability
+semantics, and known alpha limitations. The implementation design is tracked
+in `docs/design/java-daemon-sdk-alpha.md` at the repository root.
diff --git a/packages/sdk-java/qwencode/README.md b/packages/sdk-java/qwencode/README.md
index 1934c4b5abc..44eae3d10c4 100644
--- a/packages/sdk-java/qwencode/README.md
+++ b/packages/sdk-java/qwencode/README.md
@@ -1,18 +1,18 @@
# Qwen Code Java SDK
-The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
+The Qwen Code Java SDK provides a recommended daemon transport for `qwen serve` and retains the experimental legacy stdio API for compatibility. Both APIs ship in the same `com.alibaba:qwencode-sdk` artifact.
## Requirements
-- Java >= 1.8
-- Maven >= 3.6.0 (for building from source)
-- qwen-code >= 0.5.0
+- Java >= 11 for `0.1.0-alpha`
+- Maven >= 3.9.2 when building or publishing this SDK from source
+- A compatible `qwen serve` for the daemon API, or qwen-code >= 0.5.0 for the legacy stdio API
### Dependencies
-- **Logging**: ch.qos.logback:logback-classic
+- **Logging API**: org.slf4j:slf4j-api (choose an SLF4J provider in your application)
- **Utilities**: org.apache.commons:commons-lang3
-- **JSON Processing**: com.alibaba.fastjson2:fastjson2
+- **JSON Processing**: Fastjson2 for encoding and Jackson Core for strict decoding
- **Testing**: JUnit 5 (org.junit.jupiter:junit-jupiter)
## Installation
@@ -23,14 +23,14 @@ Add the following dependency to your Maven `pom.xml`:
com.alibaba
qwencode-sdk
- {$version}
+ 0.1.0-alpha
```
Or if using Gradle, add to your `build.gradle`:
```gradle
-implementation 'com.alibaba:qwencode-sdk:{$version}'
+implementation 'com.alibaba:qwencode-sdk:0.1.0-alpha'
```
## Building and Running
@@ -51,9 +51,63 @@ mvn package
mvn install
```
-## Quick Start
+### Real daemon E2E from source
-The simplest way to use the SDK is through the `QwenCodeCli.simpleQuery()` method:
+Run the real-daemon Java integration tests from the repository root after building both the workspaces and the root CLI bundle:
+
+```bash
+npm run build
+npm run bundle
+npx tsx scripts/run-java-daemon-sdk-e2e.ts
+```
+
+`npm run build` alone does not refresh `dist/cli.js`; the E2E harness launches that bundle and fails with an explicit prerequisite error when it is missing.
+
+## Recommended daemon API
+
+Start `qwen serve`, then create an independent thread-scoped session. `promptText` returns only after a matching `turn_complete`; incomplete streams fail with `PromptOutcomeIndeterminateException` rather than returning partial text as success.
+
+For the lifecycle guarantees assumed by `0.1.0-alpha`, use the qwen-code build released from the same source revision as the SDK. The daemon must contain the idempotent per-client detach ledger from [#7386](https://github.com/QwenLM/qwen-code/pull/7386), the per-epoch terminal guarantee from [#7400](https://github.com/QwenLM/qwen-code/pull/7400), and this release's acknowledged admission cancellation plus FIFO cancel-drain fence. The #7400 commit alone is not sufficient: a same-wire daemon can acknowledge cancel before agent dispatch without stopping the admitted prompt, or let an unacknowledged session-scoped cancel reach a queued successor. The bundled ACP child uses one acknowledged admission-aware cancellation handshake; a custom standards-compliant ACP child without that extension receives one standard `session/cancel` notification. Feature negotiation cannot distinguish older same-wire daemon builds, so the SDK fails closed rather than reporting partial output as success.
+
+The bundled cancellation handshake deliberately waits for the targeted prompt call to settle before the daemon dispatches its queued successor. It has no timeout that merely acknowledges cancellation: doing so could let a late session-scoped cancel reach the next prompt. If a provider, tool, or custom integration ignores its `AbortSignal` indefinitely, the cancel mutation can therefore remain outcome-unknown and that session must not be reused. Treat a formal prompt terminal received within the caller's observation boundary as authoritative; otherwise close or destroy the session after observation fails. Recovering a wedged shared ACP child without disturbing its sibling sessions requires stronger runtime isolation and is outside this alpha contract.
+
+```java
+import com.alibaba.qwen.code.daemon.DaemonClient;
+import com.alibaba.qwen.code.daemon.DaemonSessionClient;
+import com.alibaba.qwen.code.daemon.PromptTextResult;
+import java.net.URI;
+
+try (DaemonClient daemon = DaemonClient.builder()
+ .baseUri(URI.create("http://127.0.0.1:4170"))
+ .build();
+ DaemonSessionClient session = daemon.createSession()) {
+ PromptTextResult result = session.promptText("Explain this repository");
+ System.out.println(result.getText());
+}
+```
+
+If `qwen serve` requires authentication, add
+`.bearerToken(System.getenv("QWEN_SERVER_TOKEN"))` to the `DaemonClient`
+builder. The SDK sends the bearer on REST and SSE requests and never puts it in
+the URL.
+
+Use `startPrompt` with a `PromptObserver` when you need ordered text, thought, tool, usage, permission, and raw event callbacks. Its `acceptanceFuture()` and `completionFuture()` views separately expose daemon admission and the reliable turn terminal. `respondToPermission()` returns `false` when the request was already resolved or no longer pending. Cancelling the future views does not cancel the daemon prompt; use `cancelActivePrompt()` for the session-level daemon cancel operation and still wait for the matching terminal. A cooperative cancellation completes with `turn_complete` and `stopReason=cancelled`; `promptText()` returns its `PromptTextResult`, so callers that distinguish cancellation must inspect `result.getTerminal().getStopReason()`. If the agent or provider fails while cancelling, the daemon can instead publish `turn_error`, which makes `promptText()` throw `PromptTurnException`.
+
+When cancellation, deadline, teardown, or agent settlement race, the daemon's exactly-once latch publishes the first formal terminal and suppresses later candidates. Always branch on the received terminal itself; the last control mutation sent by the client does not determine the terminal kind or error code.
+
+The SSE transport sends `Accept-Encoding: identity` and `Last-Event-ID`, validates framing and event IDs, deduplicates replay, and reconnects only the SSE GET. Prompt and other mutation requests are never retried automatically. HTTP 408 and 5xx responses to prompt admission, session creation, permission, cancel, heartbeat, detach, or delete are reported as outcome-unknown because they do not prove that the daemon rejected the mutation. Finite response bodies and SSE observation have independent deadlines.
+
+Creation-time model selection is intentionally not exposed by the Java daemon SDK API in this alpha. The daemon reports a rejected `modelServiceId` only as an SSE event emitted before the create response, while this SDK opens its stream from the later prompt-admission watermark. Until the daemon returns a definitive create result or the SDK owns a separate session-event subscription from `Last-Event-ID: 0`, use the daemon's configured default model.
+
+`PromptRequest.Builder.deadline(Duration)` requests a daemon-enforced prompt deadline and is accepted only when the daemon advertises `prompt_absolute_deadline`; otherwise the SDK fails before sending the prompt. The value must be between 1 and 2,147,483,647 milliseconds, matching the daemon's Node timer range. This is separate from `observationTimeout(Duration)`, which only bounds local SSE observation and never sends a cancel mutation.
+
+Before creating a session, the SDK requires the daemon to advertise the REST transport and `session_scope_override`; this prevents an older daemon from silently ignoring the requested `thread` scope and attaching the client to a shared session. When `client_heartbeat` is advertised, an open session sends a fresh heartbeat every minute so the daemon does not reap an otherwise idle client. Set `heartbeatInterval(Duration.ZERO)` on the `DaemonClient` builder to disable this behavior, or choose a different positive interval. A heartbeat is never retried; the next scheduled heartbeat is a separate keepalive. Prompt observation is bounded to 32 concurrent prompts per client by default and can be adjusted with `maximumConcurrentPrompts`. Admission and terminal future callbacks run away from transport workers; callbacks that remain blocked consume bounded publication capacity. SSE stream cleanup is also bounded, and a close that remains blocked retains its cleanup reservation. Either condition can cause a later `startPrompt` to fail with `DaemonClientCapacityException` rather than dropping a timeout close or growing threads and queued work without limit.
+
+An indeterminate completion is an outcome boundary, not a session-reuse boundary. After `PromptAdmissionUnknownException` or `PromptOutcomeIndeterminateException`, that `DaemonSessionClient` permanently rejects further prompts even if local stream cleanup later succeeds; close or destroy the session instead. An observation timeout is published without waiting forever for a blocked stream close, while cleanup continues asynchronously and retains bounded client capacity until it finishes.
+
+## Legacy stdio API
+
+The existing `com.alibaba.qwen.code.cli` API remains available:
```java
public static void runSimpleExample() {
@@ -126,17 +180,24 @@ public static void runStreamingExample() {
other examples see src/test/java/com/alibaba/qwen/code/cli/example
+## Java 11 migration and alpha limits
+
+`0.1.0-alpha` raises the minimum Java version for the whole artifact from 8 to 11. Java 8 applications must remain on `0.0.3-alpha`. Logback is no longer a runtime dependency; add the SLF4J provider your application uses.
+
+This alpha deliberately fails closed when it cannot prove a prompt terminal. It does not guarantee exactly-once execution across daemon restarts, automatic epoch recovery, snapshot/resync, persisted cursors, or true prompt-ID-targeted cancellation. `prompt_cancelled` and queue events are advisory; only matching `turn_complete` and `turn_error` are terminal.
+
+If session creation has an ambiguous transport outcome, the daemon may retain a session whose ID never reached the caller. The SDK does not retry creation and cannot detach that unknown session; daemon-side lifecycle reaping is the recovery boundary.
+
## Architecture
-The SDK follows a layered architecture:
+The artifact contains two isolated implementations:
+
+- **Daemon API**: `DaemonClient` and `DaemonSessionClient` use REST mutations plus resumable SSE and own bounded HTTP, prompt, maintenance, and timer resources.
+- **Legacy stdio API**: `QwenCodeCli`, `Session`, and `ProcessTransport` manage a child CLI process using the existing CLI protocol DTOs and utilities.
-- **API Layer**: Provides the main entry points through `QwenCodeCli` class with simple static methods for basic usage
-- **Session Layer**: Manages communication sessions with the Qwen Code CLI through the `Session` class
-- **Transport Layer**: Handles the communication mechanism between the SDK and CLI process (currently using process transport via `ProcessTransport`)
-- **Protocol Layer**: Defines data structures for communication based on the CLI protocol
-- **Utils**: Common utilities for concurrent execution, timeout handling, and error management
+The daemon implementation does not reuse the legacy process transport, session model, DTOs, or global executor.
-## Key Features
+## Legacy stdio features
### Permission Modes
@@ -284,7 +345,7 @@ The SDK provides specific exception types for different error scenarios:
### Q: What Java versions are supported?
-A: The SDK requires Java 1.8 or higher.
+A: `0.1.0-alpha` requires Java 11 or higher. Java 8 users must remain on `0.0.3-alpha`.
### Q: How do I handle long-running requests?
diff --git a/packages/sdk-java/qwencode/RELEASE.md b/packages/sdk-java/qwencode/RELEASE.md
index 520ea1e7d13..8ed7e03fa08 100644
--- a/packages/sdk-java/qwencode/RELEASE.md
+++ b/packages/sdk-java/qwencode/RELEASE.md
@@ -1,6 +1,51 @@
# Release Notes
-### Changes in 0.0.2-alpha
+## Changes in 0.1.0-alpha
+
+### Summary
+
+This release adds the Java 11 daemon transport to the existing `com.alibaba:qwencode-sdk` artifact. The new `com.alibaba.qwen.code.daemon` API uses `POST` admission watermarks and resumable SSE to avoid returning truncated prompt output as success. The legacy stdio API remains available.
+
+### Compatibility
+
+- Minimum Java version: 11
+- Java 8 users must remain on `0.0.3-alpha`
+- Applications now select their own SLF4J provider; Logback is test-only
+- Fastjson2 and Jackson Core remain implementation dependencies and are absent from daemon public API signatures
+- Use the qwen-code build released from the same source revision as the SDK; the daemon must contain [#7386](https://github.com/QwenLM/qwen-code/pull/7386), [#7400](https://github.com/QwenLM/qwen-code/pull/7400), and this release's acknowledged admission cancellation plus FIFO cancel-drain fence
+
+### Reliability contract
+
+- Prompt, create, permission, cancel, heartbeat, detach, and delete mutations are not retried automatically
+- HTTP 408 and 5xx responses to those mutations remain outcome-unknown
+- SSE uses identity encoding, replay cursors, ordered callbacks, duplicate suppression, gap detection, and bounded reconnect
+- JSON decoding rejects non-standard syntax and duplicate object keys
+- Finite HTTP response bodies and SSE observation are independently deadline-bound
+- Session creation fails before mutation unless REST and `session_scope_override` are advertised
+- A requested daemon prompt deadline fails before mutation unless `prompt_absolute_deadline` is advertised
+- When the daemon advertises `client_heartbeat`, open sessions send periodic
+ heartbeat mutations until detach or destroy
+- Only a matching `turn_complete` or `turn_error` is terminal
+- A cooperative cancellation is `turn_complete` with `stopReason=cancelled`; an agent or provider failure during cancellation can instead produce `turn_error`, so callers wait for and inspect the formal terminal
+- When cancellation, deadline, teardown, and agent settlement race, the daemon's first formal terminal wins; callers must not infer the outcome from the last control mutation they sent
+- Missing terminal, resync, session death, observer failure, timeout, and reconnect exhaustion fail closed
+- `close()` attempts detach at most once; only `destroySession()` sends DELETE
+
+### Known alpha limits
+
+The SDK does not promise exactly-once prompt execution across daemon restarts, automatic epoch recovery, snapshot/resync, persisted cursors, or true prompt-ID-targeted cancellation. Creation-time model selection is omitted because the current daemon reports rejection only through an SSE event emitted before the create response. An ambiguous create may leave an unidentified session until the daemon reaps it. The acknowledged cancellation handshake intentionally has no acknowledgement-only timeout because that could let a late session-scoped cancel reach the next prompt; a provider or tool that ignores its `AbortSignal` can therefore leave the session unusable until stronger runtime isolation is available.
+
+### Maven configuration
+
+```xml
+
+ com.alibaba
+ qwencode-sdk
+ 0.1.0-alpha
+
+```
+
+## Changes in 0.0.2-alpha
### Summary
@@ -24,7 +69,7 @@ January 14, 2026
```
-### Changes in 0.0.1-alpha
+## Changes in 0.0.1-alpha
### Summary
@@ -76,17 +121,15 @@ January 5, 2026
### Known Issues
-1. **CLI Bundling**: From v0.1.1, the CLI is bundled with the SDK, eliminating the need for separate CLI installation. However, users upgrading from earlier versions should remove any standalone CLI installations to avoid conflicts.
-
-2. **Memory Management**: Long-running sessions with extensive streaming content may consume significant memory. Proper session cleanup using `session.close()` is essential.
+1. **Memory Management**: Long-running sessions with extensive streaming content may consume significant memory. Proper session cleanup using `session.close()` is essential.
-3. **Thread Pool Configuration**: The default thread pool configuration (30 core, 100 max threads) may need adjustment based on application load and concurrent session requirements.
+2. **Thread Pool Configuration**: The legacy stdio API's default thread pool configuration (30 core, 100 max threads) may need adjustment based on application load and concurrent session requirements.
-4. **Timeout Configuration**: Users experiencing timeout issues should adjust the `turnTimeout` and `messageTimeout` values in `TransportOptions` based on their specific use cases.
+3. **Timeout Configuration**: Legacy stdio users experiencing timeout issues should adjust the `turnTimeout` and `messageTimeout` values in `TransportOptions` based on their specific use cases.
-5. **Permission Mode Confusion**: The different permission modes (default, plan, auto-edit, yolo) may cause confusion for new users. Clear documentation and examples are needed to guide users in selecting appropriate permission modes.
+4. **Permission Mode Confusion**: The different legacy stdio permission modes (default, plan, auto-edit, yolo) may cause confusion for new users. Clear documentation and examples are needed to guide users in selecting appropriate permission modes.
-6. **Environment Variable Limitations**: Environment variables passed to the CLI process may have platform-specific limitations on length and character sets.
+5. **Environment Variable Limitations**: Environment variables passed to the legacy stdio CLI process may have platform-specific limitations on length and character sets.
### Maven Build Configuration
@@ -94,14 +137,14 @@ The project uses Maven for build management with the following key plugins and c
#### Compiler Plugin
-- Source and Target: Java 1.8
+- Compiler release: Java 11
- Encoding: UTF-8
#### Dependencies
-- Logging: ch.qos.logback:logback-classic
+- Logging API: org.slf4j:slf4j-api
- Utilities: org.apache.commons:commons-lang3
-- JSON Processing: com.alibaba.fastjson2:fastjson2
+- JSON Processing: Fastjson2 for encoding and Jackson Core for strict decoding
- Testing: JUnit 5 (org.junit.jupiter:junit-jupiter)
#### Build Plugins
@@ -113,19 +156,18 @@ The project uses Maven for build management with the following key plugins and c
- **Javadoc Plugin**: Generates and attaches Javadoc JARs
- **GPG Plugin**: Signs artifacts for secure publishing to Maven Central
-#### Distribution Management
+#### Publishing
-- Snapshot Repository: https://central.sonatype.com/repository/maven-snapshots/
-- Release Repository: https://central.sonatype.org/service/local/staging/deploy/maven2/
+- Releases: Sonatype Central Publisher Portal through the official Maven plugin
### Deployment Instructions
-To deploy a new version of the SDK:
+To release this version of the SDK:
-1. Update the version in `pom.xml`
-2. Run `mvn clean deploy` to build and deploy to Maven Central
-3. Ensure GPG signing keys are properly configured
-4. Verify the deployment in the Sonatype staging repository
+1. Merge the version and release notes to protected `main`.
+2. Run the `Release Java SDK` workflow in dry-run mode.
+3. Approve the protected production environment and rerun with dry-run disabled.
+4. The workflow first pins the verified source with an immutable tag, then signs and publishes the artifacts, waits for Maven Central availability, and creates the GitHub Release. A failed publish can safely resume from the matching tag commit even if `main` has advanced.
### Future Enhancements
diff --git a/packages/sdk-java/qwencode/pom.xml b/packages/sdk-java/qwencode/pom.xml
index 7defb5926e4..ee840eb5b57 100644
--- a/packages/sdk-java/qwencode/pom.xml
+++ b/packages/sdk-java/qwencode/pom.xml
@@ -5,12 +5,10 @@
com.alibaba
qwencode-sdk
jar
- 0.0.3-alpha
+ 0.1.0-alpha
qwencode-sdk
- The Qwen Code Java SDK is a minimum experimental SDK for programmatic access to Qwen Code functionality. It provides a Java interface
- to interact with the Qwen Code CLI, allowing developers to integrate Qwen Code capabilities into their Java applications.
-
- https://maven.apache.org
+ Java APIs for the Qwen Code daemon transport and legacy CLI process transport.
+ https://github.com/QwenLM/qwen-code/tree/main/packages/sdk-java/qwencode
Apache 2
@@ -22,21 +20,25 @@
https://github.com/QwenLM/qwen-code
scm:git:https://github.com/QwenLM/qwen-code.git
+ scm:git:ssh://git@github.com/QwenLM/qwen-code.git
- 1.8
- 1.8
+ 11
UTF-8
3.6.0
- 0.8.12
+ 0.8.14
5.14.1
1.3.16
+ 2.0.17
2.0.60
+ 2.22.0
3.13.0
- 0.8.0
- 2.2.1
- 2.9.1
- 1.5
+ 3.4.2
+ 3.5.4
+ 0.11.0
+ 3.4.0
+ 3.12.0
+ 3.2.8
@@ -51,10 +53,16 @@
+
+ org.slf4j
+ slf4j-api
+ ${slf4j-api.version}
+
ch.qos.logback
logback-classic
${logback-classic.version}
+ test
org.apache.commons
@@ -66,6 +74,11 @@
fastjson2
${fastjson2.version}
+
+ com.fasterxml.jackson.core
+ jackson-core
+ ${jackson-core.version}
+
org.junit.jupiter
junit-jupiter
@@ -75,6 +88,32 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven-compiler-plugin.version}
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ ${maven-jar-plugin.version}
+
+
+
+ qwencode.sdk
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${maven-surefire-plugin.version}
+
+ true
+ integration
+
+
org.apache.maven.plugins
maven-checkstyle-plugin
@@ -116,6 +155,8 @@
true
central
+ true
+ published
@@ -149,6 +190,12 @@
org.apache.maven.plugins
maven-gpg-plugin
${maven-gpg-plugin.version}
+
+
+ --pinentry-mode
+ loopback
+
+
sign-artifacts
@@ -180,14 +227,4 @@
-
-
- central
- https://central.sonatype.com/repository/maven-snapshots/
-
-
- central
- https://central.sonatype.org/service/local/staging/deploy/maven2/
-
-
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateSessionRequest.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateSessionRequest.java
new file mode 100644
index 00000000000..162fc086b47
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/CreateSessionRequest.java
@@ -0,0 +1,83 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Options for creating a daemon session. */
+public final class CreateSessionRequest {
+ private final String workspaceCwd;
+ private final String approvalMode;
+ private final String sessionScope;
+
+ private CreateSessionRequest(Builder builder) {
+ this.workspaceCwd = builder.workspaceCwd;
+ this.approvalMode = builder.approvalMode;
+ this.sessionScope = builder.sessionScope;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static CreateSessionRequest defaults() {
+ return builder().build();
+ }
+
+ Map toJson() {
+ Map result = new LinkedHashMap<>();
+ if (workspaceCwd != null) {
+ result.put("cwd", workspaceCwd);
+ }
+ if (approvalMode != null) {
+ result.put("approvalMode", approvalMode);
+ }
+ result.put("sessionScope", sessionScope);
+ return result;
+ }
+
+ public static final class Builder {
+ private String workspaceCwd;
+ private String approvalMode;
+ private String sessionScope = "thread";
+
+ private Builder() {
+ }
+
+ public Builder workspaceCwd(String workspaceCwd) {
+ this.workspaceCwd = requireNonBlank(workspaceCwd, "workspaceCwd");
+ return this;
+ }
+
+ public Builder approvalMode(DaemonApprovalMode approvalMode) {
+ if (approvalMode == null) {
+ throw new IllegalArgumentException("approvalMode must not be null");
+ }
+ this.approvalMode = approvalMode.getWireValue();
+ return this;
+ }
+
+ public Builder rawApprovalMode(String approvalMode) {
+ this.approvalMode = requireNonBlank(approvalMode, "approvalMode");
+ return this;
+ }
+
+ public Builder sessionScope(String sessionScope) {
+ if (!"thread".equals(sessionScope) && !"single".equals(sessionScope)) {
+ throw new IllegalArgumentException("sessionScope must be thread or single");
+ }
+ this.sessionScope = sessionScope;
+ return this;
+ }
+
+ public CreateSessionRequest build() {
+ return new CreateSessionRequest(this);
+ }
+
+ private static String requireNonBlank(String value, String name) {
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonApprovalMode.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonApprovalMode.java
new file mode 100644
index 00000000000..c91e00a94e3
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonApprovalMode.java
@@ -0,0 +1,20 @@
+package com.alibaba.qwen.code.daemon;
+
+/** Approval modes accepted by the current daemon session API. */
+public enum DaemonApprovalMode {
+ PLAN("plan"),
+ DEFAULT("default"),
+ AUTO_EDIT("auto-edit"),
+ AUTO("auto"),
+ YOLO("yolo");
+
+ private final String wireValue;
+
+ DaemonApprovalMode(String wireValue) {
+ this.wireValue = wireValue;
+ }
+
+ public String getWireValue() {
+ return wireValue;
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonCapabilities.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonCapabilities.java
new file mode 100644
index 00000000000..7416643b7a7
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonCapabilities.java
@@ -0,0 +1,60 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/** Validated capabilities returned by {@code GET /capabilities}. */
+public final class DaemonCapabilities {
+ private final int version;
+ private final String mode;
+ private final List features;
+ private final List transports;
+ private final String workspaceCwd;
+ private final String qwenCodeVersion;
+ private final Map raw;
+
+ DaemonCapabilities(int version, String mode, List features,
+ List transports, String workspaceCwd,
+ String qwenCodeVersion, Map raw) {
+ this.version = version;
+ this.mode = mode;
+ this.features = Collections.unmodifiableList(features);
+ this.transports = Collections.unmodifiableList(transports);
+ this.workspaceCwd = workspaceCwd;
+ this.qwenCodeVersion = qwenCodeVersion;
+ this.raw = Collections.unmodifiableMap(raw);
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public String getMode() {
+ return mode;
+ }
+
+ public List getFeatures() {
+ return features;
+ }
+
+ public List getTransports() {
+ return transports;
+ }
+
+ public String getWorkspaceCwd() {
+ return workspaceCwd;
+ }
+
+ public String getQwenCodeVersion() {
+ return qwenCodeVersion;
+ }
+
+ public Map getRaw() {
+ return raw;
+ }
+
+ public boolean supports(String feature) {
+ return features.contains(feature);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClient.java
new file mode 100644
index 00000000000..f5f928fa97c
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClient.java
@@ -0,0 +1,753 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URLEncoder;
+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.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Supplier;
+
+/** Java 11 client for the {@code qwen serve} REST and SSE transport. */
+public final class DaemonClient implements AutoCloseable {
+ private static final AtomicLong CLIENT_SEQUENCE = new AtomicLong();
+
+ private final String baseUrl;
+ private final String bearerToken;
+ private final Duration requestTimeout;
+ private final Duration promptObservationTimeout;
+ private final Duration sseIdleTimeout;
+ private final int maximumReconnectAttempts;
+ private final int maximumSseFrameBytes;
+ private final Duration heartbeatInterval;
+ private final ExecutorService executor;
+ private final ExecutorService maintenanceExecutor;
+ private final ExecutorService futureExecutor;
+ private final ExecutorService httpExecutor;
+ private final ExecutorService streamCloseExecutor;
+ private final ScheduledThreadPoolExecutor scheduler;
+ private final HttpClient httpClient;
+ private final Set sessions = ConcurrentHashMap.newKeySet();
+ private final Semaphore promptSlots;
+ private final Semaphore streamLifecycleSlots;
+ private final Semaphore futurePublicationSlots;
+ private final AtomicBoolean closed = new AtomicBoolean();
+ private final AtomicInteger activePromptTasks = new AtomicInteger();
+ private final AtomicInteger activeStreamLifecycles = new AtomicInteger();
+ private final ThreadLocal futurePublicationThread = new ThreadLocal<>();
+ private final Object lifecycleLock = new Object();
+ private int activeSessionCreations;
+
+ private DaemonClient(Builder builder) {
+ this.baseUrl = normalizeBaseUri(builder.baseUri);
+ this.bearerToken = builder.bearerToken;
+ this.requestTimeout = builder.requestTimeout;
+ this.promptObservationTimeout = builder.promptObservationTimeout;
+ this.sseIdleTimeout = builder.sseIdleTimeout;
+ this.maximumReconnectAttempts = builder.maximumReconnectAttempts;
+ this.maximumSseFrameBytes = builder.maximumSseFrameBytes;
+ this.heartbeatInterval = builder.heartbeatInterval;
+ long clientNumber = CLIENT_SEQUENCE.incrementAndGet();
+ this.promptSlots = new Semaphore(builder.maximumConcurrentPrompts);
+ int streamLifecycleCapacity = builder.maximumConcurrentPrompts;
+ this.streamLifecycleSlots = new Semaphore(streamLifecycleCapacity);
+ this.executor = new ThreadPoolExecutor(builder.maximumConcurrentPrompts,
+ builder.maximumConcurrentPrompts, 0L, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>(builder.maximumConcurrentPrompts),
+ daemonThreadFactory(
+ "qwencode-daemon-" + clientNumber + "-worker-"));
+ this.maintenanceExecutor = new ThreadPoolExecutor(4, 4, 0L,
+ TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(256),
+ daemonThreadFactory("qwencode-daemon-" + clientNumber
+ + "-maintenance-"));
+ int futureThreads = (int) Math.min(Integer.MAX_VALUE,
+ Math.max(2L, builder.maximumConcurrentPrompts * 2L));
+ int futurePublicationCapacity = (int) Math.min(Integer.MAX_VALUE,
+ futureThreads * 2L);
+ this.futurePublicationSlots = new Semaphore(futurePublicationCapacity);
+ this.futureExecutor = new ThreadPoolExecutor(futureThreads,
+ futureThreads, 0L, TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>(futurePublicationCapacity),
+ daemonThreadFactory(
+ "qwencode-daemon-" + clientNumber + "-future-"));
+ int httpThreads = Math.min(16,
+ Math.max(4, builder.maximumConcurrentPrompts));
+ this.httpExecutor = new ThreadPoolExecutor(httpThreads, httpThreads,
+ 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(256),
+ daemonThreadFactory("qwencode-daemon-" + clientNumber + "-http-"));
+ this.streamCloseExecutor = new ThreadPoolExecutor(
+ builder.maximumConcurrentPrompts,
+ builder.maximumConcurrentPrompts, 0L, TimeUnit.MILLISECONDS,
+ new ArrayBlockingQueue<>(streamLifecycleCapacity),
+ daemonThreadFactory("qwencode-daemon-" + clientNumber
+ + "-stream-close-"));
+ this.scheduler = new ScheduledThreadPoolExecutor(1,
+ daemonThreadFactory("qwencode-daemon-" + clientNumber + "-timer-"));
+ this.scheduler.setRemoveOnCancelPolicy(true);
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(builder.connectTimeout)
+ .executor(httpExecutor)
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .version(HttpClient.Version.HTTP_1_1)
+ .build();
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public DaemonCapabilities capabilities() {
+ ensureOpen();
+ HttpSupport.Response response = sendRead("/capabilities", "GET /capabilities");
+ requireStatus(response, 200, "GET /capabilities");
+ Map json = JsonSupport.parseObject(response.getBody(),
+ "GET /capabilities response");
+ int version = JsonSupport.requiredInt(json, "v", "capabilities");
+ if (version != 1) {
+ throw new DaemonProtocolException("Unsupported capabilities version: " + version);
+ }
+ String mode = JsonSupport.requiredString(json, "mode", "capabilities");
+ java.util.List transports = JsonSupport.stringList(json,
+ "transports");
+ return new DaemonCapabilities(version, mode,
+ JsonSupport.stringList(json, "features"), transports,
+ JsonSupport.optionalString(json, "workspaceCwd"),
+ JsonSupport.optionalString(json, "qwenCodeVersion"), json);
+ }
+
+ public DaemonSessionClient createSession() {
+ return createSession(CreateSessionRequest.defaults());
+ }
+
+ public DaemonSessionClient createSession(CreateSessionRequest request) {
+ if (request == null) {
+ throw new IllegalArgumentException("request must not be null");
+ }
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ activeSessionCreations += 1;
+ }
+ try {
+ DaemonCapabilities capabilities = capabilities();
+ if (!capabilities.getTransports().contains("rest")) {
+ throw new DaemonProtocolException(
+ "The daemon does not advertise the REST transport");
+ }
+ if (!capabilities.supports("session_scope_override")) {
+ throw new DaemonProtocolException(
+ "The daemon does not advertise session_scope_override; "
+ + "the SDK cannot guarantee the requested session scope");
+ }
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ }
+ HttpSupport.Response response;
+ try {
+ response = send("/session", "POST", request.toJson(), null,
+ requestTimeout);
+ } catch (IOException | InterruptedException e) {
+ restoreInterrupt(e);
+ throw new SessionCreationOutcomeUnknownException(e);
+ } catch (DaemonTransportException | DaemonProtocolException e) {
+ throw new SessionCreationOutcomeUnknownException(e);
+ }
+ try {
+ if (isAmbiguousMutationStatus(response.getStatusCode())) {
+ throw new SessionCreationOutcomeUnknownException(
+ new DaemonHttpException("POST /session",
+ response.getStatusCode(), response.getBody()));
+ }
+ requireStatus(response, 200, "POST /session");
+ Map json = JsonSupport.parseObject(response.getBody(),
+ "POST /session response");
+ String clientId = JsonSupport.requiredString(json, "clientId",
+ "session");
+ validateClientId(clientId);
+ DaemonSession session = new DaemonSession(
+ JsonSupport.requiredString(json, "sessionId", "session"),
+ JsonSupport.requiredString(json, "workspaceCwd", "session"),
+ JsonSupport.requiredBoolean(json, "attached", "session"),
+ clientId,
+ JsonSupport.optionalString(json, "createdAt"));
+ DaemonSessionClient result = new DaemonSessionClient(this, session,
+ capabilities.supports("client_heartbeat"),
+ capabilities.supports("prompt_absolute_deadline"));
+ synchronized (lifecycleLock) {
+ if (!closed.get()) {
+ sessions.add(result);
+ result.startAutomaticHeartbeat();
+ return result;
+ }
+ }
+ IllegalStateException failure = new IllegalStateException(
+ "DaemonClient is closed");
+ try {
+ result.close();
+ } catch (RuntimeException cleanupFailure) {
+ failure.addSuppressed(cleanupFailure);
+ }
+ throw failure;
+ } catch (DaemonProtocolException e) {
+ throw new SessionCreationOutcomeUnknownException(e);
+ }
+ } finally {
+ boolean shutdownHttpExecutor = false;
+ synchronized (lifecycleLock) {
+ activeSessionCreations -= 1;
+ if (closed.get() && activeSessionCreations == 0) {
+ shutdownHttpExecutor = true;
+ }
+ }
+ if (shutdownHttpExecutor) {
+ httpExecutor.shutdownNow();
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ List sessionsToClose;
+ boolean shutdownHttpExecutor;
+ synchronized (lifecycleLock) {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ sessionsToClose = new ArrayList<>(sessions);
+ shutdownHttpExecutor = activeSessionCreations == 0;
+ }
+ RuntimeException firstFailure = null;
+ try {
+ for (DaemonSessionClient session : sessionsToClose) {
+ try {
+ session.close();
+ } catch (RuntimeException e) {
+ if (firstFailure == null) {
+ firstFailure = e;
+ } else {
+ firstFailure.addSuppressed(e);
+ }
+ }
+ }
+ } finally {
+ executor.shutdown();
+ maintenanceExecutor.shutdownNow();
+ if (shutdownHttpExecutor) {
+ httpExecutor.shutdownNow();
+ }
+ awaitTermination(executor);
+ awaitTermination(maintenanceExecutor);
+ if (shutdownHttpExecutor) {
+ awaitTermination(httpExecutor);
+ }
+ shutdownPromptSupportIfIdle();
+ if (scheduler.isShutdown()) {
+ awaitTermination(scheduler);
+ }
+ if (streamCloseExecutor.isShutdown()) {
+ awaitTermination(streamCloseExecutor);
+ }
+ if (futureExecutor.isShutdown()
+ && futurePublicationThread.get() == null) {
+ awaitTermination(futureExecutor);
+ }
+ }
+ if (firstFailure != null) {
+ throw firstFailure;
+ }
+ }
+
+ HttpSupport.Response sendMutation(String path, Map body,
+ String clientId) throws IOException,
+ InterruptedException {
+ return send(path, "POST", body, clientId, requestTimeout);
+ }
+
+ HttpSupport.Response sendSessionMutation(String path, Map body,
+ String clientId) throws IOException, InterruptedException {
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ }
+ return send(path, "POST", body, clientId, requestTimeout);
+ }
+
+ HttpSupport.Response sendDelete(String path, String clientId)
+ throws IOException, InterruptedException {
+ return send(path, "DELETE", null, clientId, requestTimeout);
+ }
+
+ HttpResponse openSse(String path, String clientId, long lastEventId,
+ Duration observationRemaining)
+ throws IOException, InterruptedException {
+ HttpRequest request = requestBuilder(path, clientId)
+ .header("Accept", "text/event-stream")
+ .header("Accept-Encoding", "identity")
+ .header("Cache-Control", "no-cache")
+ .header("Last-Event-ID", Long.toString(lastEventId))
+ .timeout(shorter(requestTimeout, observationRemaining))
+ .GET()
+ .build();
+ try {
+ return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+ } catch (RejectedExecutionException e) {
+ throw new IOException("HTTP executor is saturated", e);
+ }
+ }
+
+ void submit(Runnable task, Runnable afterCompletion,
+ Runnable afterCapacityRelease,
+ Supplier> streamCleanup) {
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ if (!promptSlots.tryAcquire()) {
+ throw new DaemonClientCapacityException(
+ new RejectedExecutionException(
+ "Prompt capacity is exhausted"));
+ }
+ if (!streamLifecycleSlots.tryAcquire()) {
+ promptSlots.release();
+ throw new DaemonClientCapacityException(
+ new RejectedExecutionException(
+ "Stream cleanup capacity is exhausted"));
+ }
+ activePromptTasks.incrementAndGet();
+ activeStreamLifecycles.incrementAndGet();
+ FutureTask submitted = new FutureTask(() -> {
+ task.run();
+ return null;
+ }) {
+ @Override
+ protected void done() {
+ try {
+ afterCompletion.run();
+ } finally {
+ try {
+ registerStreamCleanup(streamCleanup);
+ } finally {
+ promptSlots.release();
+ try {
+ afterCapacityRelease.run();
+ } finally {
+ activePromptTasks.decrementAndGet();
+ shutdownPromptSupportIfIdle();
+ }
+ }
+ }
+ }
+ };
+ try {
+ executor.execute(submitted);
+ } catch (RejectedExecutionException e) {
+ promptSlots.release();
+ activePromptTasks.decrementAndGet();
+ releaseStreamLifecycle();
+ shutdownPromptSupportIfIdle();
+ throw new DaemonClientCapacityException(e);
+ }
+ }
+ }
+
+ Future> submitMaintenance(Runnable task) {
+ try {
+ return maintenanceExecutor.submit(task);
+ } catch (RejectedExecutionException ignored) {
+ return null;
+ }
+ }
+
+ CompletableFuture closeStreamAsync(InputStream stream) {
+ CompletableFuture completion = new CompletableFuture<>();
+ try {
+ streamCloseExecutor.execute(() -> {
+ try {
+ stream.close();
+ completion.complete(null);
+ } catch (IOException | RuntimeException e) {
+ completion.completeExceptionally(e);
+ } catch (Error e) {
+ completion.completeExceptionally(e);
+ throw e;
+ }
+ });
+ } catch (RejectedExecutionException e) {
+ completion.completeExceptionally(e);
+ }
+ return completion;
+ }
+
+ Executor reserveFuturePublications() {
+ if (!futurePublicationSlots.tryAcquire(2)) {
+ throw new DaemonClientCapacityException(
+ new RejectedExecutionException(
+ "Future publication capacity is exhausted"));
+ }
+ AtomicInteger remaining = new AtomicInteger(2);
+ return command -> {
+ if (remaining.getAndDecrement() <= 0) {
+ throw new IllegalStateException(
+ "Prompt future publication reservation is exhausted");
+ }
+ Runnable publication = () -> {
+ futurePublicationSlots.release();
+ futurePublicationThread.set(Boolean.TRUE);
+ try {
+ command.run();
+ } finally {
+ futurePublicationThread.remove();
+ }
+ };
+ try {
+ futureExecutor.execute(publication);
+ } catch (RejectedExecutionException e) {
+ publication.run();
+ }
+ };
+ }
+
+ void unregister(DaemonSessionClient session) {
+ sessions.remove(session);
+ }
+
+ ScheduledExecutorService scheduler() {
+ return scheduler;
+ }
+
+ Duration promptObservationTimeout() {
+ return promptObservationTimeout;
+ }
+
+ Duration sseIdleTimeout() {
+ return sseIdleTimeout;
+ }
+
+ Duration requestTimeout() {
+ return requestTimeout;
+ }
+
+ Duration heartbeatInterval() {
+ return heartbeatInterval;
+ }
+
+ int maximumReconnectAttempts() {
+ return maximumReconnectAttempts;
+ }
+
+ int maximumSseFrameBytes() {
+ return maximumSseFrameBytes;
+ }
+
+ static void requireStatus(HttpSupport.Response response, int expected,
+ String operation) {
+ if (response.getStatusCode() != expected) {
+ if (response.isSuccess()) {
+ throw new DaemonProtocolException(operation
+ + " returned unexpected successful HTTP "
+ + response.getStatusCode());
+ }
+ throw new DaemonHttpException(operation, response.getStatusCode(),
+ response.getBody());
+ }
+ }
+
+ static boolean isAmbiguousMutationStatus(int statusCode) {
+ return statusCode == 408 || statusCode >= 500;
+ }
+
+ static String encodePathSegment(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8)
+ .replace("+", "%20");
+ }
+
+ private HttpSupport.Response sendRead(String path, String operation) {
+ try {
+ return send(path, "GET", null, null, requestTimeout);
+ } catch (IOException e) {
+ throw new DaemonTransportException(operation + " transport failed", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new DaemonTransportException(operation + " was interrupted", e);
+ }
+ }
+
+ private HttpSupport.Response send(String path, String method,
+ Map body, String clientId, Duration timeout)
+ throws IOException, InterruptedException {
+ HttpRequest.Builder builder = requestBuilder(path, clientId)
+ .header("Accept", "application/json")
+ .header("Accept-Encoding", "identity")
+ .timeout(timeout);
+ if (body == null) {
+ builder.method(method, HttpRequest.BodyPublishers.noBody());
+ } else {
+ builder.header("Content-Type", "application/json; charset=utf-8")
+ .method(method, HttpRequest.BodyPublishers.ofString(
+ JsonSupport.encode(body), StandardCharsets.UTF_8));
+ }
+ java.util.concurrent.CompletableFuture> future;
+ try {
+ future = httpClient.sendAsync(builder.build(), HttpSupport.bodyHandler());
+ } catch (RejectedExecutionException e) {
+ throw new IOException("HTTP executor is saturated", e);
+ }
+ HttpResponse response;
+ try {
+ response = future.get(timeoutNanos(timeout), TimeUnit.NANOSECONDS);
+ } catch (InterruptedException e) {
+ future.cancel(true);
+ throw e;
+ } catch (TimeoutException e) {
+ future.cancel(true);
+ throw new java.net.http.HttpTimeoutException(
+ method + " " + path + " response timed out");
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof IOException) {
+ throw (IOException) cause;
+ }
+ if (cause instanceof RejectedExecutionException) {
+ throw new IOException("HTTP executor is saturated", cause);
+ }
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw new IOException(method + " " + path + " failed", cause);
+ }
+ return HttpSupport.consume(response, method + " " + path);
+ }
+
+ private HttpRequest.Builder requestBuilder(String path, String clientId) {
+ HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path));
+ if (bearerToken != null) {
+ builder.header("Authorization", "Bearer " + bearerToken);
+ }
+ if (clientId != null) {
+ builder.header("X-Qwen-Client-Id", clientId);
+ }
+ return builder;
+ }
+
+ private void ensureOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("DaemonClient is closed");
+ }
+ }
+
+ private static String normalizeBaseUri(URI baseUri) {
+ String scheme = baseUri.getScheme();
+ if (!("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) {
+ throw new IllegalArgumentException("baseUri must use http or https");
+ }
+ if (baseUri.getHost() == null || baseUri.getUserInfo() != null
+ || baseUri.getQuery() != null
+ || baseUri.getFragment() != null) {
+ throw new IllegalArgumentException(
+ "baseUri must be an absolute HTTP origin or path without credentials");
+ }
+ String value = baseUri.toString();
+ while (value.endsWith("/")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ return value;
+ }
+
+ private static void validateClientId(String clientId) {
+ if (clientId.length() > 128
+ || !clientId.matches("[A-Za-z0-9._:-]+")) {
+ throw new DaemonProtocolException(
+ "session.clientId is not a valid daemon client identifier");
+ }
+ }
+
+ private static ThreadFactory daemonThreadFactory(String prefix) {
+ AtomicLong sequence = new AtomicLong();
+ return runnable -> {
+ Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ };
+ }
+
+ private static void restoreInterrupt(Exception exception) {
+ if (exception instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static void awaitTermination(ExecutorService service) {
+ try {
+ service.awaitTermination(5, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void shutdownPromptSupportIfIdle() {
+ if (closed.get() && activePromptTasks.get() == 0) {
+ futureExecutor.shutdown();
+ scheduler.shutdownNow();
+ }
+ if (closed.get() && activeStreamLifecycles.get() == 0) {
+ streamCloseExecutor.shutdown();
+ }
+ }
+
+ private void releaseStreamLifecycle() {
+ streamLifecycleSlots.release();
+ activeStreamLifecycles.decrementAndGet();
+ shutdownPromptSupportIfIdle();
+ }
+
+ private void registerStreamCleanup(
+ Supplier> streamCleanup) {
+ try {
+ CompletableFuture cleanup = streamCleanup.get();
+ if (cleanup == null) {
+ throw new IllegalStateException(
+ "Prompt stream cleanup future is null");
+ }
+ cleanup.whenComplete((ignored, failure) ->
+ releaseStreamLifecycle());
+ } catch (RuntimeException e) {
+ releaseStreamLifecycle();
+ throw e;
+ }
+ }
+
+ private static Duration shorter(Duration first, Duration second) {
+ return first.compareTo(second) <= 0 ? first : second;
+ }
+
+ private static long timeoutNanos(Duration timeout) {
+ try {
+ return timeout.toNanos();
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+
+ public static final class Builder {
+ private URI baseUri = URI.create("http://127.0.0.1:4170");
+ private String bearerToken;
+ private Duration connectTimeout = Duration.ofSeconds(10);
+ private Duration requestTimeout = Duration.ofSeconds(30);
+ private Duration promptObservationTimeout = Duration.ofMinutes(30);
+ private Duration sseIdleTimeout = Duration.ofSeconds(45);
+ private Duration heartbeatInterval = Duration.ofMinutes(1);
+ private int maximumReconnectAttempts = 8;
+ private int maximumSseFrameBytes = 16 * 1024 * 1024;
+ private int maximumConcurrentPrompts = 32;
+
+ private Builder() {
+ }
+
+ public Builder baseUri(URI baseUri) {
+ if (baseUri == null) {
+ throw new IllegalArgumentException("baseUri must not be null");
+ }
+ this.baseUri = baseUri;
+ return this;
+ }
+
+ public Builder bearerToken(String bearerToken) {
+ if (bearerToken == null || bearerToken.trim().isEmpty()) {
+ throw new IllegalArgumentException("bearerToken must not be blank");
+ }
+ this.bearerToken = bearerToken;
+ return this;
+ }
+
+ public Builder connectTimeout(Duration connectTimeout) {
+ this.connectTimeout = positive(connectTimeout, "connectTimeout");
+ return this;
+ }
+
+ public Builder requestTimeout(Duration requestTimeout) {
+ this.requestTimeout = positive(requestTimeout, "requestTimeout");
+ return this;
+ }
+
+ public Builder promptObservationTimeout(Duration timeout) {
+ this.promptObservationTimeout = positive(timeout,
+ "promptObservationTimeout");
+ return this;
+ }
+
+ public Builder sseIdleTimeout(Duration sseIdleTimeout) {
+ this.sseIdleTimeout = positive(sseIdleTimeout, "sseIdleTimeout");
+ return this;
+ }
+
+ public Builder heartbeatInterval(Duration heartbeatInterval) {
+ if (heartbeatInterval == null || heartbeatInterval.isNegative()) {
+ throw new IllegalArgumentException(
+ "heartbeatInterval must be non-negative");
+ }
+ this.heartbeatInterval = heartbeatInterval;
+ return this;
+ }
+
+ public Builder maximumReconnectAttempts(int attempts) {
+ if (attempts < 0) {
+ throw new IllegalArgumentException(
+ "maximumReconnectAttempts must be non-negative");
+ }
+ this.maximumReconnectAttempts = attempts;
+ return this;
+ }
+
+ public Builder maximumSseFrameBytes(int bytes) {
+ if (bytes < 1024) {
+ throw new IllegalArgumentException(
+ "maximumSseFrameBytes must be at least 1024");
+ }
+ this.maximumSseFrameBytes = bytes;
+ return this;
+ }
+
+ public Builder maximumConcurrentPrompts(int prompts) {
+ if (prompts <= 0) {
+ throw new IllegalArgumentException(
+ "maximumConcurrentPrompts must be positive");
+ }
+ this.maximumConcurrentPrompts = prompts;
+ return this;
+ }
+
+ public DaemonClient build() {
+ return new DaemonClient(this);
+ }
+
+ private static Duration positive(Duration duration, String name) {
+ if (duration == null || duration.isZero() || duration.isNegative()) {
+ throw new IllegalArgumentException(name + " must be positive");
+ }
+ return duration;
+ }
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClientCapacityException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClientCapacityException.java
new file mode 100644
index 00000000000..957f5026133
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonClientCapacityException.java
@@ -0,0 +1,8 @@
+package com.alibaba.qwen.code.daemon;
+
+/** The configured client-wide prompt or publication capacity is exhausted. */
+public final class DaemonClientCapacityException extends DaemonException {
+ DaemonClientCapacityException(Throwable cause) {
+ super("DaemonClient capacity is exhausted", cause);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonEvent.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonEvent.java
new file mode 100644
index 00000000000..fe7f8864511
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonEvent.java
@@ -0,0 +1,121 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.util.Collections;
+import java.util.Map;
+
+/** One validated daemon SSE event envelope. */
+public final class DaemonEvent {
+ private final Long id;
+ private final int version;
+ private final String type;
+ private final Object data;
+ private final String promptId;
+ private final String originatorClientId;
+ private final Map metadata;
+
+ DaemonEvent(Long id, int version, String type, Object data,
+ String promptId, String originatorClientId,
+ Map metadata) {
+ this.id = id;
+ this.version = version;
+ this.type = type;
+ this.data = data;
+ this.promptId = promptId;
+ this.originatorClientId = originatorClientId;
+ this.metadata = Collections.unmodifiableMap(metadata);
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public int getVersion() {
+ return version;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public Object getData() {
+ return data;
+ }
+
+ public String getPromptId() {
+ return promptId;
+ }
+
+ public String getOriginatorClientId() {
+ return originatorClientId;
+ }
+
+ public Map getMetadata() {
+ return metadata;
+ }
+
+ boolean belongsTo(String expectedPromptId) {
+ if (!("turn_complete".equals(type) || "turn_error".equals(type))) {
+ return expectedPromptId.equals(promptId);
+ }
+ Map object = dataObject();
+ String dataPromptId = object == null ? null
+ : JsonSupport.optionalString(object, "promptId");
+ if (promptId != null && dataPromptId != null
+ && !promptId.equals(dataPromptId)) {
+ throw new DaemonProtocolException(
+ "SSE envelope promptId conflicts with data.promptId");
+ }
+ String canonicalPromptId = promptId == null ? dataPromptId : promptId;
+ return expectedPromptId.equals(canonicalPromptId);
+ }
+
+ String updateKind() {
+ return JsonSupport.requiredString(update(), "sessionUpdate",
+ "session_update.data.update");
+ }
+
+ String textChunk() {
+ Map content = JsonSupport.requiredObject(update(),
+ "content", "session_update.data.update");
+ String contentType = JsonSupport.requiredString(content, "type",
+ "session_update.data.update.content");
+ if (!"text".equals(contentType)) {
+ return null;
+ }
+ Object text = content.get("text");
+ if (!(text instanceof String)) {
+ throw new DaemonProtocolException(
+ "session_update.data.update.content.text must be a string");
+ }
+ return (String) text;
+ }
+
+ Map update() {
+ return JsonSupport.requiredObject(
+ requireDataObject("session_update.data"), "update",
+ "session_update.data");
+ }
+
+ Map requireDataObject(String context) {
+ Map object = dataObject();
+ if (object == null) {
+ throw new DaemonProtocolException(context + " must be an object");
+ }
+ return object;
+ }
+
+ void requireSessionId(String expectedSessionId, String context) {
+ String actualSessionId = JsonSupport.requiredString(
+ requireDataObject(context + ".data"), "sessionId",
+ context + ".data");
+ if (!expectedSessionId.equals(actualSessionId)) {
+ throw new DaemonProtocolException(context
+ + ".data.sessionId does not match the session");
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map dataObject() {
+ return data instanceof Map ? (Map) data : null;
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonException.java
new file mode 100644
index 00000000000..8d11cf586af
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonException.java
@@ -0,0 +1,12 @@
+package com.alibaba.qwen.code.daemon;
+
+/** Base class for daemon client failures. */
+public class DaemonException extends RuntimeException {
+ public DaemonException(String message) {
+ super(message);
+ }
+
+ public DaemonException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonHttpException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonHttpException.java
new file mode 100644
index 00000000000..7b1f777ac75
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonHttpException.java
@@ -0,0 +1,22 @@
+package com.alibaba.qwen.code.daemon;
+
+/** A received non-success HTTP response from the daemon or an intermediary. */
+public final class DaemonHttpException extends DaemonException {
+ private final int statusCode;
+ private final String responseBody;
+
+ DaemonHttpException(String operation, int statusCode, String responseBody) {
+ super(operation + " failed with HTTP " + statusCode
+ + (responseBody.isEmpty() ? "" : ": " + responseBody));
+ this.statusCode = statusCode;
+ this.responseBody = responseBody;
+ }
+
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ public String getResponseBody() {
+ return responseBody;
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonProtocolException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonProtocolException.java
new file mode 100644
index 00000000000..ab4af6ad4eb
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonProtocolException.java
@@ -0,0 +1,12 @@
+package com.alibaba.qwen.code.daemon;
+
+/** A malformed or unsupported daemon protocol response. */
+public final class DaemonProtocolException extends DaemonException {
+ DaemonProtocolException(String message) {
+ super(message);
+ }
+
+ DaemonProtocolException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSession.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSession.java
new file mode 100644
index 00000000000..1e0608fade3
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSession.java
@@ -0,0 +1,39 @@
+package com.alibaba.qwen.code.daemon;
+
+/** Identity returned by {@code POST /session}. */
+public final class DaemonSession {
+ private final String sessionId;
+ private final String workspaceCwd;
+ private final boolean attached;
+ private final String clientId;
+ private final String createdAt;
+
+ DaemonSession(String sessionId, String workspaceCwd, boolean attached,
+ String clientId, String createdAt) {
+ this.sessionId = sessionId;
+ this.workspaceCwd = workspaceCwd;
+ this.attached = attached;
+ this.clientId = clientId;
+ this.createdAt = createdAt;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public String getWorkspaceCwd() {
+ return workspaceCwd;
+ }
+
+ public boolean isAttached() {
+ return attached;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public String getCreatedAt() {
+ return createdAt;
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java
new file mode 100644
index 00000000000..1f95b9408a9
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonSessionClient.java
@@ -0,0 +1,1198 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** One independently attached daemon session. */
+public final class DaemonSessionClient implements AutoCloseable {
+ public static final long DEFAULT_MAXIMUM_TEXT_BYTES = 4L * 1024L * 1024L;
+
+ private static final Set SESSION_FAILURE_EVENTS = Set.of(
+ "client_evicted", "session_closed", "session_died",
+ "state_resync_required", "stream_error");
+ private static final Set IDLESS_SYNTHETIC_EVENTS = Set.of(
+ "client_evicted", "slow_client_warning", "stream_error",
+ "state_resync_required", "replay_complete");
+ private static final Set OBSERVABLE_STREAM_EVENTS = Set.of(
+ "slow_client_warning", "replay_complete");
+ private static final Set RETRYABLE_SSE_STATUS = Set.of(
+ 408, 429, 500, 502, 503, 504);
+
+ private final DaemonClient client;
+ private final DaemonSession session;
+ private final boolean automaticHeartbeatSupported;
+ private final boolean promptDeadlineSupported;
+ private final AtomicBoolean closed = new AtomicBoolean();
+ private final AtomicBoolean detachAttempted = new AtomicBoolean();
+ private final AtomicBoolean destroySucceeded = new AtomicBoolean();
+ private final AtomicReference activePrompt = new AtomicReference<>();
+ private final AtomicBoolean heartbeatUnsupported = new AtomicBoolean();
+ private final AtomicBoolean heartbeatInFlight = new AtomicBoolean();
+ private final Object lifecycleLock = new Object();
+ private ScheduledFuture> heartbeatTask;
+ private boolean automaticHeartbeatRunning;
+
+ DaemonSessionClient(DaemonClient client, DaemonSession session,
+ boolean automaticHeartbeatSupported, boolean promptDeadlineSupported) {
+ this.client = client;
+ this.session = session;
+ this.automaticHeartbeatSupported = automaticHeartbeatSupported;
+ this.promptDeadlineSupported = promptDeadlineSupported;
+ }
+
+ public DaemonSession getSession() {
+ return session;
+ }
+
+ public String getSessionId() {
+ return session.getSessionId();
+ }
+
+ public String getClientId() {
+ return session.getClientId();
+ }
+
+ public PromptCall startPrompt(PromptRequest request, PromptObserver observer) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(observer, "observer");
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ if (request.getDeadlineMillis() != null && !promptDeadlineSupported) {
+ throw new DaemonProtocolException(
+ "The daemon does not advertise prompt_absolute_deadline; "
+ + "the SDK cannot guarantee the requested deadline");
+ }
+ if (activePrompt.get() != null) {
+ throw new PromptAlreadyActiveException();
+ }
+ PromptExecution execution = new PromptExecution(request, observer);
+ activePrompt.set(execution);
+ try {
+ client.submit(execution::run,
+ execution::publishCompletion,
+ execution::releaseTerminalPublication,
+ execution::streamCleanupCompletion);
+ } catch (RuntimeException e) {
+ activePrompt.compareAndSet(execution, null);
+ execution.failBeforeStart(e);
+ }
+ return execution.call;
+ }
+ }
+
+ public PromptTextResult promptText(String text) {
+ return promptText(PromptRequest.text(text), DEFAULT_MAXIMUM_TEXT_BYTES);
+ }
+
+ public PromptTextResult promptText(PromptRequest request) {
+ return promptText(request, DEFAULT_MAXIMUM_TEXT_BYTES);
+ }
+
+ public PromptTextResult promptText(PromptRequest request, long maximumBytes) {
+ if (maximumBytes <= 0) {
+ throw new IllegalArgumentException("maximumBytes must be positive");
+ }
+ TextCollector collector = new TextCollector(maximumBytes);
+ PromptTerminal terminal;
+ try {
+ terminal = startPrompt(request, collector).completionFuture().join();
+ } catch (CompletionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof PromptContentLimitException) {
+ throw (PromptContentLimitException) cause;
+ }
+ String partialText;
+ try {
+ collector.finish();
+ partialText = collector.getText();
+ } catch (PromptContentLimitException contentLimit) {
+ partialText = contentLimit.getPartialText();
+ }
+ if (cause instanceof PromptOutcomeIndeterminateException) {
+ PromptOutcomeIndeterminateException indeterminate =
+ (PromptOutcomeIndeterminateException) cause;
+ throw new PromptOutcomeIndeterminateException(
+ indeterminate.getMessage(), indeterminate,
+ partialText);
+ }
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ throw e;
+ }
+ collector.finish();
+ if (terminal.getKind() == PromptTerminal.Kind.ERROR) {
+ throw new PromptTurnException(terminal, collector.getText());
+ }
+ return new PromptTextResult(collector.getText(), terminal);
+ }
+
+ public boolean respondToPermission(String requestId, PermissionResponse response) {
+ if (requestId == null || requestId.isEmpty()) {
+ throw new IllegalArgumentException("requestId must not be empty");
+ }
+ Objects.requireNonNull(response, "response");
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ String path = sessionPath() + "/permission/"
+ + DaemonClient.encodePathSegment(requestId);
+ HttpSupport.Response httpResponse = sendMutation(path,
+ "POST /session/:id/permission/:requestId", response.toJson());
+ if (httpResponse.getStatusCode() == 404) {
+ if (isCurrentPermissionNotFound(httpResponse, requestId)) {
+ return false;
+ }
+ throw new DaemonHttpException(
+ "POST /session/:id/permission/:requestId",
+ httpResponse.getStatusCode(), httpResponse.getBody());
+ }
+ requireMutationStatus(httpResponse, 200,
+ "POST /session/:id/permission/:requestId");
+ return true;
+ }
+ }
+
+ public void cancelActivePrompt() {
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ HttpSupport.Response response = sendMutation(sessionPath() + "/cancel",
+ "POST /session/:id/cancel", Collections.emptyMap());
+ requireMutationStatus(response, 204, "POST /session/:id/cancel");
+ }
+ }
+
+ public HeartbeatResult heartbeat() {
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ return sendHeartbeat();
+ }
+ }
+
+ public void detach() {
+ close();
+ }
+
+ public void destroySession() {
+ synchronized (lifecycleLock) {
+ if (destroySucceeded.get()) {
+ return;
+ }
+ closed.set(true);
+ cancelAutomaticHeartbeat();
+ awaitAutomaticHeartbeat();
+ stopActivePrompt("Session was destroyed locally");
+ try {
+ HttpSupport.Response response;
+ try {
+ response = client.sendDelete(sessionPath(),
+ detachAttempted.get() ? null : session.getClientId());
+ } catch (IOException | InterruptedException e) {
+ restoreInterrupt(e);
+ throw new MutationOutcomeUnknownException(
+ "DELETE /session/:id", e);
+ } catch (DaemonTransportException | DaemonProtocolException e) {
+ throw new MutationOutcomeUnknownException(
+ "DELETE /session/:id", e);
+ }
+ if (response.getStatusCode() == 404) {
+ if (!isCurrentSessionNotFound(response)) {
+ throw new DaemonHttpException("DELETE /session/:id",
+ response.getStatusCode(), response.getBody());
+ }
+ } else {
+ requireMutationStatus(response, 204, "DELETE /session/:id");
+ }
+ destroySucceeded.set(true);
+ } finally {
+ client.unregister(this);
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ synchronized (lifecycleLock) {
+ if (!closed.compareAndSet(false, true)) {
+ return;
+ }
+ cancelAutomaticHeartbeat();
+ awaitAutomaticHeartbeat();
+ stopActivePrompt("Session was detached locally");
+ try {
+ if (!destroySucceeded.get()
+ && detachAttempted.compareAndSet(false, true)) {
+ HttpSupport.Response response;
+ try {
+ response = client.sendMutation(sessionPath() + "/detach",
+ Collections.emptyMap(),
+ session.getClientId());
+ } catch (IOException | InterruptedException e) {
+ restoreInterrupt(e);
+ throw new DetachOutcomeUnknownException(e);
+ } catch (DaemonTransportException | DaemonProtocolException e) {
+ throw new DetachOutcomeUnknownException(e);
+ }
+ if (DaemonClient.isAmbiguousMutationStatus(
+ response.getStatusCode())) {
+ throw new DetachOutcomeUnknownException(
+ new DaemonHttpException(
+ "POST /session/:id/detach",
+ response.getStatusCode(),
+ response.getBody()));
+ }
+ try {
+ DaemonClient.requireStatus(response, 204,
+ "POST /session/:id/detach");
+ } catch (DaemonProtocolException e) {
+ throw new DetachOutcomeUnknownException(e);
+ }
+ }
+ } finally {
+ client.unregister(this);
+ }
+ }
+ }
+
+ private HttpSupport.Response sendMutation(String path, String operation,
+ Map body) {
+ try {
+ return client.sendSessionMutation(path, body, session.getClientId());
+ } catch (IOException | InterruptedException e) {
+ restoreInterrupt(e);
+ throw new MutationOutcomeUnknownException(operation, e);
+ } catch (DaemonTransportException | DaemonProtocolException e) {
+ throw new MutationOutcomeUnknownException(operation, e);
+ }
+ }
+
+ private static void requireMutationStatus(HttpSupport.Response response,
+ int expected, String operation) {
+ if (DaemonClient.isAmbiguousMutationStatus(response.getStatusCode())) {
+ throw new MutationOutcomeUnknownException(operation,
+ new DaemonHttpException(operation,
+ response.getStatusCode(), response.getBody()));
+ }
+ try {
+ DaemonClient.requireStatus(response, expected, operation);
+ } catch (DaemonProtocolException e) {
+ throw new MutationOutcomeUnknownException(operation, e);
+ }
+ }
+
+ void startAutomaticHeartbeat() {
+ Duration interval = client.heartbeatInterval();
+ if (interval.isZero() || !automaticHeartbeatSupported) {
+ return;
+ }
+ synchronized (lifecycleLock) {
+ if (closed.get() || heartbeatTask != null) {
+ return;
+ }
+ long delayMillis = saturatedMillis(interval);
+ heartbeatTask = client.scheduler().scheduleWithFixedDelay(
+ this::submitAutomaticHeartbeat,
+ delayMillis, delayMillis, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ private void submitAutomaticHeartbeat() {
+ if (closed.get()
+ || !heartbeatInFlight.compareAndSet(false, true)) {
+ return;
+ }
+ Future> submitted = client.submitMaintenance(this::runAutomaticHeartbeat);
+ if (submitted == null) {
+ heartbeatInFlight.set(false);
+ }
+ }
+
+ private void runAutomaticHeartbeat() {
+ synchronized (lifecycleLock) {
+ if (closed.get()) {
+ heartbeatInFlight.set(false);
+ return;
+ }
+ automaticHeartbeatRunning = true;
+ }
+ try {
+ automaticHeartbeat();
+ } finally {
+ finishAutomaticHeartbeat();
+ }
+ }
+
+ private void automaticHeartbeat() {
+ if (closed.get() || heartbeatUnsupported.get()) {
+ return;
+ }
+ try {
+ sendHeartbeat();
+ } catch (DaemonHttpException e) {
+ if (e.getStatusCode() == 404 || e.getStatusCode() == 405) {
+ heartbeatUnsupported.set(true);
+ }
+ } catch (DaemonException ignored) {
+ // A later scheduled heartbeat is a new keepalive, not a request retry.
+ }
+ }
+
+ private HeartbeatResult sendHeartbeat() {
+ HttpSupport.Response response = sendMutation(
+ sessionPath() + "/heartbeat",
+ "POST /session/:id/heartbeat", Collections.emptyMap());
+ try {
+ requireMutationStatus(response, 200,
+ "POST /session/:id/heartbeat");
+ Map json = JsonSupport.parseObject(response.getBody(),
+ "heartbeat response");
+ String responseSessionId = JsonSupport.requiredString(json,
+ "sessionId", "heartbeat");
+ if (!session.getSessionId().equals(responseSessionId)) {
+ throw new DaemonProtocolException(
+ "Heartbeat response sessionId does not match the session");
+ }
+ String responseClientId = JsonSupport.requiredString(json,
+ "clientId", "heartbeat");
+ if (!session.getClientId().equals(responseClientId)) {
+ throw new DaemonProtocolException(
+ "Heartbeat response clientId does not match the client");
+ }
+ return new HeartbeatResult(responseSessionId,
+ responseClientId,
+ JsonSupport.requiredNonNegativeLong(json, "lastSeenAt",
+ "heartbeat"));
+ } catch (DaemonProtocolException e) {
+ throw new MutationOutcomeUnknownException(
+ "POST /session/:id/heartbeat", e);
+ }
+ }
+
+ private void finishAutomaticHeartbeat() {
+ synchronized (lifecycleLock) {
+ automaticHeartbeatRunning = false;
+ heartbeatInFlight.set(false);
+ lifecycleLock.notifyAll();
+ }
+ }
+
+ private void awaitAutomaticHeartbeat() {
+ boolean interrupted = false;
+ while (automaticHeartbeatRunning) {
+ try {
+ lifecycleLock.wait();
+ } catch (InterruptedException e) {
+ interrupted = true;
+ }
+ }
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void cancelAutomaticHeartbeat() {
+ ScheduledFuture> task = heartbeatTask;
+ if (task != null) {
+ task.cancel(false);
+ heartbeatTask = null;
+ }
+ }
+
+ private String sessionPath() {
+ return "/session/" + DaemonClient.encodePathSegment(session.getSessionId());
+ }
+
+ private boolean isCurrentSessionNotFound(HttpSupport.Response response) {
+ try {
+ Map body = JsonSupport.parseObject(response.getBody(),
+ "DELETE /session/:id error response");
+ String responseSessionId = JsonSupport.optionalString(body, "sessionId");
+ String code = JsonSupport.optionalString(body, "code");
+ return session.getSessionId().equals(responseSessionId)
+ && (code == null || "session_not_found".equals(code));
+ } catch (DaemonProtocolException e) {
+ return false;
+ }
+ }
+
+ private boolean isCurrentPermissionNotFound(HttpSupport.Response response,
+ String requestId) {
+ try {
+ Map body = JsonSupport.parseObject(response.getBody(),
+ "permission response error");
+ String responseSessionId = JsonSupport.optionalString(body, "sessionId");
+ String responseRequestId = JsonSupport.optionalString(body, "requestId");
+ String code = JsonSupport.optionalString(body, "code");
+ return session.getSessionId().equals(responseSessionId)
+ && (responseRequestId == null
+ || requestId.equals(responseRequestId))
+ && (code == null || "session_not_found".equals(code));
+ } catch (DaemonProtocolException e) {
+ return false;
+ }
+ }
+
+ private void ensureOpen() {
+ if (closed.get()) {
+ throw new IllegalStateException("DaemonSessionClient is closed");
+ }
+ }
+
+ private void stopActivePrompt(String message) {
+ PromptExecution execution = activePrompt.get();
+ if (execution != null) {
+ execution.stop(new PromptOutcomeIndeterminateException(message, ""));
+ }
+ }
+
+ private static void restoreInterrupt(Exception exception) {
+ if (exception instanceof InterruptedException) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private final class PromptExecution {
+ private final PromptRequest request;
+ private final PromptObserver observer;
+ private final CompletableFuture acceptance =
+ new CompletableFuture<>();
+ private final CompletableFuture completion =
+ new CompletableFuture<>();
+ private final CountDownLatch terminalPublicationGate =
+ new CountDownLatch(1);
+ private final PromptCall call = new PromptCall(acceptance, completion,
+ client.reserveFuturePublications(), terminalPublicationGate);
+ private final AtomicReference activeStream = new AtomicReference<>();
+ private final Object streamLifecycleLock = new Object();
+ private final Object observerLifecycleLock = new Object();
+ private final Object outcomeLock = new Object();
+ private final AtomicReference stopFailure =
+ new AtomicReference<>();
+ private volatile CompletableFuture streamClose =
+ CompletableFuture.completedFuture(null);
+ private volatile boolean admissionStarted;
+ private volatile PromptTerminal terminalResult;
+ private volatile Throwable terminalFailure;
+ private volatile Thread runner;
+ private boolean observerInProgress;
+ private boolean outcomeClaimed;
+
+ PromptExecution(PromptRequest request, PromptObserver observer) {
+ this.request = request;
+ this.observer = observer;
+ }
+
+ void failBeforeStart(RuntimeException failure) {
+ terminalPublicationGate.countDown();
+ acceptance.completeExceptionally(failure);
+ completion.completeExceptionally(failure);
+ }
+
+ void releaseTerminalPublication() {
+ terminalPublicationGate.countDown();
+ }
+
+ CompletableFuture streamCleanupCompletion() {
+ synchronized (streamLifecycleLock) {
+ return streamClose;
+ }
+ }
+
+ void stop(PromptOutcomeIndeterminateException failure) {
+ synchronized (outcomeLock) {
+ if (outcomeClaimed) {
+ return;
+ }
+ outcomeClaimed = true;
+ stopFailure.set(failure);
+ }
+ synchronized (observerLifecycleLock) {
+ if (!observerInProgress && acceptance.isDone()) {
+ completion.completeExceptionally(failure);
+ terminalPublicationGate.countDown();
+ }
+ }
+ InputStream stream = activeStream.get();
+ if (stream != null) {
+ requestStreamClose(stream);
+ }
+ Thread currentRunner = runner;
+ if (currentRunner != null
+ && currentRunner != Thread.currentThread()) {
+ currentRunner.interrupt();
+ }
+ }
+
+ void run() {
+ runner = Thread.currentThread();
+ try {
+ PromptAcceptance admitted;
+ synchronized (lifecycleLock) {
+ ensureOpen();
+ admissionStarted = true;
+ admitted = admit();
+ }
+ acceptance.complete(admitted);
+ try {
+ PromptTerminal terminal = observe(admitted);
+ activePrompt.compareAndSet(this, null);
+ terminalResult = terminal;
+ } catch (PromptOutcomeIndeterminateException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new PromptOutcomeIndeterminateException(
+ "Prompt observation failed", e, "");
+ }
+ } catch (Throwable failure) {
+ if (!acceptance.isDone()
+ && failure instanceof DaemonHttpException) {
+ activePrompt.compareAndSet(this, null);
+ }
+ if (!acceptance.isDone()) {
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ acceptance.completeExceptionally(stopped != null
+ && !admissionStarted
+ ? new DaemonException(
+ "Prompt stopped before admission was dispatched")
+ : failure);
+ }
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ terminalFailure = stopped == null ? failure : stopped;
+ } finally {
+ runner = null;
+ }
+ }
+
+ void publishCompletion() {
+ PromptTerminal terminal = terminalResult;
+ Throwable failure = terminalFailure;
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ if (!acceptance.isDone()) {
+ acceptance.completeExceptionally(stopped != null
+ && !admissionStarted
+ ? new DaemonException(
+ "Prompt stopped before admission was dispatched")
+ : failure == null ? new PromptOutcomeIndeterminateException(
+ "Prompt ended without an admission outcome", "")
+ : failure);
+ }
+ if (terminal != null) {
+ completion.complete(terminal);
+ } else {
+ completion.completeExceptionally(stopped != null
+ ? stopped
+ : failure == null ? new PromptOutcomeIndeterminateException(
+ "Prompt ended without a terminal outcome", "")
+ : failure);
+ }
+ }
+
+ private PromptAcceptance admit() {
+ HttpSupport.Response response;
+ try {
+ response = client.sendMutation(sessionPath() + "/prompt",
+ request.toJson(),
+ session.getClientId());
+ } catch (IOException | InterruptedException e) {
+ restoreInterrupt(e);
+ throw new PromptAdmissionUnknownException(e);
+ } catch (DaemonTransportException | DaemonProtocolException e) {
+ throw new PromptAdmissionUnknownException(e);
+ }
+ if (response.getStatusCode() != 202) {
+ if (DaemonClient.isAmbiguousMutationStatus(
+ response.getStatusCode())) {
+ throw new PromptAdmissionUnknownException(
+ new DaemonHttpException("POST /session/:id/prompt",
+ response.getStatusCode(), response.getBody()));
+ }
+ if (!response.isSuccess()) {
+ throw new DaemonHttpException("POST /session/:id/prompt",
+ response.getStatusCode(), response.getBody());
+ }
+ throw new PromptAdmissionUnknownException(
+ "Expected 202 admission watermark but received HTTP "
+ + response.getStatusCode());
+ }
+ try {
+ Map json = JsonSupport.parseObject(response.getBody(),
+ "prompt admission response");
+ return new PromptAcceptance(
+ JsonSupport.requiredString(json, "promptId", "prompt admission"),
+ JsonSupport.requiredNonNegativeLong(json, "lastEventId",
+ "prompt admission"));
+ } catch (DaemonProtocolException e) {
+ throw new PromptAdmissionUnknownException(e);
+ }
+ }
+
+ private PromptTerminal observe(PromptAcceptance admitted) {
+ Duration timeout = request.observationTimeoutOr(
+ client.promptObservationTimeout());
+ long deadline = deadlineAfter(timeout);
+ long cursor = admitted.getLastEventId();
+ int consecutiveFailures = 0;
+ Duration serverRetry = null;
+ while (true) {
+ checkStoppedOrExpired(deadline);
+ awaitPriorStreamClose(deadline);
+ checkStoppedOrExpired(deadline);
+ HttpResponse response;
+ try {
+ response = client.openSse(sessionPath() + "/events",
+ session.getClientId(), cursor,
+ Duration.ofMillis(remainingMillis(deadline)));
+ } catch (IOException e) {
+ reconnectOrThrow(++consecutiveFailures,
+ serverRetry, deadline, e);
+ continue;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw stoppedOrIndeterminate("SSE connection was interrupted", e);
+ }
+ if (response.statusCode() != 200) {
+ int statusCode = response.statusCode();
+ Duration retryAfter = retryAfter(response.headers());
+ Duration retryDelay = retryAfter == null
+ ? serverRetry : retryAfter;
+ String body;
+ try {
+ body = readSseError(response, deadline);
+ } catch (DaemonProtocolException e) {
+ throw indeterminate("SSE error response was malformed", e);
+ } catch (DaemonTransportException e) {
+ if (!RETRYABLE_SSE_STATUS.contains(statusCode)) {
+ throw indeterminate("SSE failed with HTTP "
+ + statusCode, e);
+ }
+ reconnectOrThrow(++consecutiveFailures,
+ retryDelay, deadline, e);
+ continue;
+ }
+ if (!RETRYABLE_SSE_STATUS.contains(statusCode)) {
+ Throwable responseFailure = statusCode >= 200
+ && statusCode < 300
+ ? new DaemonProtocolException(
+ "GET /session/:id/events returned "
+ + "unexpected successful HTTP "
+ + statusCode)
+ : new DaemonHttpException(
+ "GET /session/:id/events",
+ statusCode, body);
+ throw indeterminate("SSE failed with HTTP "
+ + statusCode, responseFailure);
+ }
+ reconnectOrThrow(++consecutiveFailures,
+ retryDelay, deadline,
+ new DaemonHttpException("GET /session/:id/events",
+ statusCode, body));
+ continue;
+ }
+ InputStream stream = response.body();
+ activeStream.set(stream);
+ AtomicLong lastActivity = new AtomicLong(System.nanoTime());
+ AtomicBoolean idleClosed = new AtomicBoolean();
+ ScheduledFuture> watchdog = null;
+ ScheduledFuture> deadlineWatchdog = null;
+ SseReader reader = null;
+ try {
+ checkStoppedOrExpired(deadline);
+ validateSseHeaders(response.headers());
+ watchdog = scheduleIdleWatchdog(stream,
+ lastActivity, idleClosed);
+ deadlineWatchdog = scheduleDeadlineWatchdog(deadline);
+ reader = new SseReader(stream,
+ client.maximumSseFrameBytes(),
+ () -> lastActivity.set(System.nanoTime()));
+ while (true) {
+ checkStoppedOrExpired(deadline);
+ SseReader.Frame frame = reader.next();
+ if (frame == null) {
+ throw new IOException("SSE stream ended before terminal");
+ }
+ DaemonEvent event = parseEvent(frame);
+ Long eventId = event.getId();
+ if (eventId != null && eventId <= cursor) {
+ continue;
+ }
+ if (eventId != null && eventId != cursor + 1) {
+ throw new DaemonProtocolException("SSE event ID gap: expected "
+ + (cursor + 1) + " but received " + eventId);
+ }
+ PromptTerminal terminal = processEvent(event, admitted.getPromptId());
+ if (terminal == null) {
+ checkStoppedOrExpired(deadline);
+ }
+ if (eventId != null) {
+ cursor = eventId;
+ consecutiveFailures = 0;
+ }
+ if (terminal != null) {
+ if (!claimTerminal(terminal, deadline)) {
+ PromptOutcomeIndeterminateException stopped =
+ stopFailure.get();
+ if (stopped == null) {
+ stop(indeterminate(
+ "Prompt observation timed out", null));
+ stopped = stopFailure.get();
+ }
+ throw stopped == null
+ ? indeterminate(
+ "Prompt outcome was already settled",
+ null)
+ : stopped;
+ }
+ return terminal;
+ }
+ }
+ } catch (IOException e) {
+ requestStreamClose(stream);
+ checkStoppedOrExpired(deadline);
+ String reason = idleClosed.get()
+ ? "SSE idle timeout" : "SSE stream failed";
+ if (reader != null && reader.getRetryMillis() != null) {
+ serverRetry = Duration.ofMillis(reader.getRetryMillis());
+ }
+ reconnectOrThrow(++consecutiveFailures,
+ serverRetry, deadline,
+ new IOException(reason, e));
+ } catch (DaemonProtocolException e) {
+ throw indeterminate(e.getMessage(), e);
+ } catch (RuntimeException e) {
+ if (e instanceof PromptOutcomeIndeterminateException) {
+ throw e;
+ }
+ throw indeterminate("Prompt observer failed", e);
+ } finally {
+ if (watchdog != null) {
+ watchdog.cancel(false);
+ }
+ if (deadlineWatchdog != null) {
+ deadlineWatchdog.cancel(false);
+ }
+ requestStreamClose(stream);
+ }
+ }
+ }
+
+ private PromptTerminal processEvent(DaemonEvent event, String promptId) {
+ if (SESSION_FAILURE_EVENTS.contains(event.getType())) {
+ throw indeterminate("Daemon session stream reported "
+ + event.getType(), null);
+ }
+ if (OBSERVABLE_STREAM_EVENTS.contains(event.getType())) {
+ notifyObserver(() -> observer.onEvent(event));
+ return null;
+ }
+ if (!event.belongsTo(promptId)) {
+ return null;
+ }
+ if ("turn_complete".equals(event.getType())
+ || "turn_error".equals(event.getType())) {
+ PromptTerminal terminal = PromptTerminal.from(event, promptId,
+ session.getSessionId());
+ dispatch(event);
+ return terminal;
+ }
+ dispatch(event);
+ return null;
+ }
+
+ private void dispatch(DaemonEvent event) {
+ notifyObserver(() -> dispatchToObserver(event));
+ }
+
+ private void dispatchToObserver(DaemonEvent event) {
+ if ("permission_request".equals(event.getType())) {
+ event.requireSessionId(session.getSessionId(), "permission_request");
+ observer.onPermission(PermissionRequest.from(event), event);
+ }
+ if ("session_update".equals(event.getType())) {
+ event.requireSessionId(session.getSessionId(), "session_update");
+ Map update = event.update();
+ String kind = event.updateKind();
+ if ("agent_message_chunk".equals(kind)) {
+ String text = event.textChunk();
+ if (text != null) {
+ observer.onText(text, event);
+ }
+ Map metadata = JsonSupport.extensionObject(
+ update.get("_meta"));
+ Map usage = metadata == null ? null
+ : JsonSupport.extensionObject(metadata.get("usage"));
+ if (usage != null) {
+ observer.onUsage(usage, event);
+ }
+ } else if ("agent_thought_chunk".equals(kind)) {
+ String text = event.textChunk();
+ if (text != null) {
+ observer.onThought(text, event);
+ }
+ } else if ("tool_call".equals(kind)
+ || "tool_call_update".equals(kind)) {
+ observer.onTool(update, event);
+ } else if ("usage_update".equals(kind)) {
+ JsonSupport.requiredNonNegativeLong(update, "used",
+ "session_update.data.update");
+ JsonSupport.requiredNonNegativeLong(update, "size",
+ "session_update.data.update");
+ observer.onUsage(update, event);
+ }
+ }
+ observer.onEvent(event);
+ }
+
+ private void notifyObserver(Runnable callback) {
+ synchronized (observerLifecycleLock) {
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ if (stopped != null) {
+ throw stopped;
+ }
+ observerInProgress = true;
+ }
+ try {
+ callback.run();
+ } finally {
+ synchronized (observerLifecycleLock) {
+ observerInProgress = false;
+ }
+ }
+ }
+
+ private void reconnectOrThrow(int failures,
+ Duration retryAfter, long deadline, Throwable cause) {
+ checkStoppedOrExpired(deadline);
+ if (failures > client.maximumReconnectAttempts()) {
+ throw indeterminate("SSE reconnect attempts exhausted", cause);
+ }
+ long maximumDelayMillis = Math.min(5000L,
+ 250L << Math.min(failures - 1, 4));
+ long delayMillis = retryAfter == null
+ ? ThreadLocalRandom.current().nextLong(maximumDelayMillis + 1)
+ : Math.min(5000L, retryAfter.toMillis());
+ long remainingMillis = remainingMillis(deadline);
+ if (remainingMillis <= 0) {
+ throw indeterminate("Prompt observation timed out", cause);
+ }
+ delayMillis = Math.min(delayMillis, remainingMillis);
+ try {
+ Thread.sleep(delayMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw stoppedOrIndeterminate("SSE reconnect was interrupted", e);
+ }
+ }
+
+ private ScheduledFuture> scheduleIdleWatchdog(InputStream stream,
+ AtomicLong lastActivity, AtomicBoolean idleClosed) {
+ long idleNanos = saturatedNanos(client.sseIdleTimeout());
+ long intervalMillis = Math.max(100L,
+ saturatedMillis(client.sseIdleTimeout()) / 2L);
+ return client.scheduler().scheduleAtFixedRate(() -> {
+ if (System.nanoTime() - lastActivity.get() >= idleNanos
+ && idleClosed.compareAndSet(false, true)) {
+ requestStreamClose(stream);
+ }
+ }, intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
+ }
+
+ private String readSseError(HttpResponse response,
+ long deadline) {
+ InputStream stream = response.body();
+ activeStream.set(stream);
+ ScheduledFuture> watchdog = null;
+ ScheduledFuture> deadlineWatchdog = null;
+ try {
+ checkStoppedOrExpired(deadline);
+ long remaining = remainingMillis(deadline);
+ long requestLimit = saturatedMillis(client.requestTimeout());
+ watchdog = client.scheduler().schedule(
+ () -> requestStreamClose(stream),
+ Math.min(remaining, requestLimit), TimeUnit.MILLISECONDS);
+ deadlineWatchdog = scheduleDeadlineWatchdog(deadline);
+ return HttpSupport.readError(stream,
+ "GET /session/:id/events");
+ } finally {
+ if (watchdog != null) {
+ watchdog.cancel(false);
+ }
+ if (deadlineWatchdog != null) {
+ deadlineWatchdog.cancel(false);
+ }
+ requestStreamClose(stream);
+ }
+ }
+
+ private ScheduledFuture> scheduleDeadlineWatchdog(long deadline) {
+ return client.scheduler().schedule(
+ () -> stop(indeterminate(
+ "Prompt observation timed out", null)),
+ remainingNanos(deadline), TimeUnit.NANOSECONDS);
+ }
+
+ private boolean claimTerminal(PromptTerminal terminal, long deadline) {
+ synchronized (outcomeLock) {
+ if (outcomeClaimed || System.nanoTime() >= deadline) {
+ return false;
+ }
+ outcomeClaimed = true;
+ terminalResult = terminal;
+ return true;
+ }
+ }
+
+ private void requestStreamClose(InputStream stream) {
+ synchronized (streamLifecycleLock) {
+ if (activeStream.compareAndSet(stream, null)) {
+ streamClose = client.closeStreamAsync(stream);
+ }
+ }
+ }
+
+ private void awaitPriorStreamClose(long deadline) {
+ CompletableFuture cleanup;
+ synchronized (streamLifecycleLock) {
+ cleanup = streamClose;
+ }
+ try {
+ cleanup.get(remainingMillis(deadline), TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw stoppedOrIndeterminate(
+ "SSE stream closure was interrupted", e);
+ } catch (ExecutionException e) {
+ throw indeterminate("SSE stream closure failed", e.getCause());
+ } catch (TimeoutException e) {
+ throw indeterminate(
+ "SSE stream closure exceeded the observation timeout", e);
+ }
+ }
+
+ private void checkStoppedOrExpired(long deadline) {
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ if (stopped != null) {
+ throw stopped;
+ }
+ if (System.nanoTime() >= deadline) {
+ throw indeterminate("Prompt observation timed out", null);
+ }
+ }
+
+ private PromptOutcomeIndeterminateException stoppedOrIndeterminate(
+ String message, Throwable cause) {
+ PromptOutcomeIndeterminateException stopped = stopFailure.get();
+ return stopped == null ? indeterminate(message, cause) : stopped;
+ }
+
+ private PromptOutcomeIndeterminateException indeterminate(
+ String message, Throwable cause) {
+ return cause == null
+ ? new PromptOutcomeIndeterminateException(message, "")
+ : new PromptOutcomeIndeterminateException(message, cause, "");
+ }
+ }
+
+ private static DaemonEvent parseEvent(SseReader.Frame frame) {
+ Map envelope = JsonSupport.parseObject(frame.getData(),
+ "SSE data");
+ int version = JsonSupport.requiredInt(envelope, "v", "SSE envelope");
+ if (version != 1) {
+ throw new DaemonProtocolException("Unsupported SSE event version: " + version);
+ }
+ String type = JsonSupport.requiredString(envelope, "type", "SSE envelope");
+ if (frame.getEvent() != null && !frame.getEvent().equals(type)) {
+ throw new DaemonProtocolException("SSE event field does not match envelope type");
+ }
+ Long envelopeId = JsonSupport.optionalPositiveLong(envelope, "id",
+ "SSE envelope");
+ if (!Objects.equals(frame.getId(), envelopeId)) {
+ throw new DaemonProtocolException("SSE id field does not match envelope id");
+ }
+ if (envelopeId == null && !IDLESS_SYNTHETIC_EVENTS.contains(type)) {
+ throw new DaemonProtocolException(
+ "SSE event " + type + " must have a replayable event ID");
+ }
+ if (!envelope.containsKey("data")) {
+ throw new DaemonProtocolException("SSE envelope.data is required");
+ }
+ Object data = envelope.get("data");
+ Map metadata = JsonSupport.optionalObject(envelope, "_meta");
+ return new DaemonEvent(envelopeId, version, type, data,
+ JsonSupport.optionalString(envelope, "promptId"),
+ JsonSupport.optionalString(envelope, "originatorClientId"),
+ metadata == null ? Collections.emptyMap() : metadata);
+ }
+
+ private static void validateSseHeaders(HttpHeaders headers) {
+ String contentType = headers.firstValue("Content-Type").orElse("");
+ String mediaType = contentType.split(";", 2)[0].trim();
+ if (!"text/event-stream".equalsIgnoreCase(mediaType)) {
+ throw new DaemonProtocolException(
+ "SSE response Content-Type is not text/event-stream");
+ }
+ String contentEncoding = headers.firstValue("Content-Encoding")
+ .orElse("identity");
+ if (!"identity".equalsIgnoreCase(contentEncoding)) {
+ throw new DaemonProtocolException("SSE response used unsupported Content-Encoding: "
+ + contentEncoding);
+ }
+ }
+
+ private static Duration retryAfter(HttpHeaders headers) {
+ String value = headers.firstValue("Retry-After").orElse(null);
+ if (value == null) {
+ return null;
+ }
+ try {
+ long seconds = Long.parseLong(value.trim());
+ return Duration.ofSeconds(Math.min(5, Math.max(0, seconds)));
+ } catch (NumberFormatException ignored) {
+ try {
+ ZonedDateTime time = ZonedDateTime.parse(value,
+ DateTimeFormatter.RFC_1123_DATE_TIME);
+ Duration result = Duration.between(ZonedDateTime.now(time.getZone()), time);
+ return result.isNegative() ? Duration.ZERO : result;
+ } catch (DateTimeParseException invalidDate) {
+ return null;
+ }
+ }
+ }
+
+ private static long deadlineAfter(Duration timeout) {
+ long now = System.nanoTime();
+ long nanos;
+ try {
+ nanos = timeout.toNanos();
+ return Math.addExact(now, nanos);
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+
+ private static long remainingMillis(long deadline) {
+ long remaining = deadline - System.nanoTime();
+ if (remaining <= 0) {
+ return 0;
+ }
+ return Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remaining));
+ }
+
+ private static long remainingNanos(long deadline) {
+ return Math.max(0L, deadline - System.nanoTime());
+ }
+
+ private static long saturatedMillis(Duration duration) {
+ try {
+ return Math.max(1L, duration.toMillis());
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+
+ private static long saturatedNanos(Duration duration) {
+ try {
+ return duration.toNanos();
+ } catch (ArithmeticException e) {
+ return Long.MAX_VALUE;
+ }
+ }
+
+ private static final class TextCollector implements PromptObserver {
+ private final long maximumBytes;
+ private final StringBuilder text = new StringBuilder();
+ private long bytes;
+ private boolean pendingHighSurrogate;
+
+ TextCollector(long maximumBytes) {
+ this.maximumBytes = maximumBytes;
+ }
+
+ @Override
+ public void onText(String chunk, DaemonEvent event) {
+ boolean resolvesPendingSurrogate = pendingHighSurrogate
+ && !chunk.isEmpty();
+ long chunkBytes = utf8Bytes(chunk);
+ if (chunkBytes > maximumBytes - bytes) {
+ String partialText = resolvesPendingSurrogate
+ ? text.substring(0, text.length() - 1)
+ : text.toString();
+ throw new PromptContentLimitException(maximumBytes, partialText);
+ }
+ text.append(chunk);
+ bytes += chunkBytes;
+ }
+
+ void finish() {
+ if (!pendingHighSurrogate) {
+ return;
+ }
+ if (bytes == maximumBytes) {
+ throw new PromptContentLimitException(maximumBytes,
+ text.substring(0, text.length() - 1));
+ }
+ bytes++;
+ pendingHighSurrogate = false;
+ }
+
+ private long utf8Bytes(String chunk) {
+ long result = 0;
+ int index = 0;
+ if (pendingHighSurrogate && !chunk.isEmpty()) {
+ if (Character.isLowSurrogate(chunk.charAt(0))) {
+ result += 4;
+ index = 1;
+ } else {
+ result++;
+ }
+ pendingHighSurrogate = false;
+ }
+ while (index < chunk.length()) {
+ char current = chunk.charAt(index++);
+ if (Character.isHighSurrogate(current)) {
+ if (index == chunk.length()) {
+ pendingHighSurrogate = true;
+ } else if (Character.isLowSurrogate(chunk.charAt(index))) {
+ result += 4;
+ index++;
+ } else {
+ result++;
+ }
+ } else if (Character.isLowSurrogate(current)) {
+ result++;
+ } else if (current <= 0x7f) {
+ result++;
+ } else if (current <= 0x7ff) {
+ result += 2;
+ } else {
+ result += 3;
+ }
+ }
+ return result;
+ }
+
+ String getText() {
+ return text.toString();
+ }
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonTransportException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonTransportException.java
new file mode 100644
index 00000000000..f278204bc46
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DaemonTransportException.java
@@ -0,0 +1,8 @@
+package com.alibaba.qwen.code.daemon;
+
+/** A transport failure for an operation whose outcome is otherwise known. */
+public class DaemonTransportException extends DaemonException {
+ DaemonTransportException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DetachOutcomeUnknownException.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DetachOutcomeUnknownException.java
new file mode 100644
index 00000000000..f0788ff005f
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/DetachOutcomeUnknownException.java
@@ -0,0 +1,8 @@
+package com.alibaba.qwen.code.daemon;
+
+/** Detach may have succeeded, and close will not issue another detach. */
+public final class DetachOutcomeUnknownException extends MutationOutcomeUnknownException {
+ DetachOutcomeUnknownException(Throwable cause) {
+ super("POST /session/:id/detach", cause);
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HeartbeatResult.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HeartbeatResult.java
new file mode 100644
index 00000000000..8e49063b8c5
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HeartbeatResult.java
@@ -0,0 +1,26 @@
+package com.alibaba.qwen.code.daemon;
+
+/** Server timestamp returned after a session heartbeat. */
+public final class HeartbeatResult {
+ private final String sessionId;
+ private final String clientId;
+ private final long lastSeenAt;
+
+ HeartbeatResult(String sessionId, String clientId, long lastSeenAt) {
+ this.sessionId = sessionId;
+ this.clientId = clientId;
+ this.lastSeenAt = lastSeenAt;
+ }
+
+ public String getSessionId() {
+ return sessionId;
+ }
+
+ public String getClientId() {
+ return clientId;
+ }
+
+ public long getLastSeenAt() {
+ return lastSeenAt;
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HttpSupport.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HttpSupport.java
new file mode 100644
index 00000000000..515a3671ca0
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/HttpSupport.java
@@ -0,0 +1,190 @@
+package com.alibaba.qwen.code.daemon;
+
+import java.io.ByteArrayOutputStream;
+import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
+import java.nio.charset.CharacterCodingException;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.Flow;
+
+final class HttpSupport {
+ static final int MAXIMUM_ERROR_BYTES = 64 * 1024;
+ static final int MAXIMUM_JSON_BYTES = 1024 * 1024;
+
+ private HttpSupport() {
+ }
+
+ static HttpResponse.BodyHandler bodyHandler() {
+ return responseInfo -> {
+ boolean success = responseInfo.statusCode() >= 200
+ && responseInfo.statusCode() < 300;
+ return new BoundedBodySubscriber(
+ success ? MAXIMUM_JSON_BYTES : MAXIMUM_ERROR_BYTES);
+ };
+ }
+
+ static Response consume(HttpResponse response, String operation) {
+ Body body = response.body();
+ byte[] bytes = body.getBytes();
+ boolean success = response.statusCode() >= 200
+ && response.statusCode() < 300;
+ if (body.isOverflow()) {
+ if (success) {
+ throw new DaemonProtocolException("JSON response exceeds "
+ + MAXIMUM_JSON_BYTES + " bytes");
+ }
+ bytes = appendTruncationSuffix(bytes);
+ }
+ return new Response(response.statusCode(),
+ success ? decode(bytes, operation + " response")
+ : decodeError(bytes));
+ }
+
+ static String readError(java.io.InputStream input,
+ String operation) {
+ try {
+ byte[] bytes = input.readNBytes(MAXIMUM_ERROR_BYTES + 1);
+ if (bytes.length > MAXIMUM_ERROR_BYTES) {
+ byte[] prefix = new byte[MAXIMUM_ERROR_BYTES];
+ System.arraycopy(bytes, 0, prefix, 0, prefix.length);
+ bytes = appendTruncationSuffix(prefix);
+ }
+ return decodeError(bytes);
+ } catch (java.io.IOException e) {
+ throw new DaemonTransportException(operation
+ + " response body could not be read", e);
+ }
+ }
+
+ private static byte[] appendTruncationSuffix(byte[] bytes) {
+ byte[] suffix = "... (truncated)".getBytes(StandardCharsets.UTF_8);
+ int prefixLength = Math.max(0, MAXIMUM_ERROR_BYTES - suffix.length);
+ prefixLength = Math.min(prefixLength, bytes.length);
+ byte[] result = new byte[prefixLength + suffix.length];
+ System.arraycopy(bytes, 0, result, 0, prefixLength);
+ System.arraycopy(suffix, 0, result, prefixLength, suffix.length);
+ return result;
+ }
+
+ private static String decode(byte[] bytes, String context) {
+ try {
+ return StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes)).toString();
+ } catch (CharacterCodingException e) {
+ throw new DaemonProtocolException(context + " contains invalid UTF-8", e);
+ }
+ }
+
+ private static String decodeError(byte[] bytes) {
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+
+ static final class Body {
+ private final byte[] bytes;
+ private final boolean overflow;
+
+ Body(byte[] bytes, boolean overflow) {
+ this.bytes = bytes;
+ this.overflow = overflow;
+ }
+
+ byte[] getBytes() {
+ return bytes;
+ }
+
+ boolean isOverflow() {
+ return overflow;
+ }
+ }
+
+ private static final class BoundedBodySubscriber
+ implements HttpResponse.BodySubscriber {
+ private final int limit;
+ private final ByteArrayOutputStream output = new ByteArrayOutputStream();
+ private final CompletableFuture body = new CompletableFuture<>();
+ private Flow.Subscription subscription;
+
+ BoundedBodySubscriber(int limit) {
+ this.limit = limit;
+ }
+
+ @Override
+ public CompletionStage getBody() {
+ return body;
+ }
+
+ @Override
+ public void onSubscribe(Flow.Subscription newSubscription) {
+ if (subscription != null) {
+ newSubscription.cancel();
+ return;
+ }
+ subscription = newSubscription;
+ newSubscription.request(Long.MAX_VALUE);
+ }
+
+ @Override
+ public void onNext(List buffers) {
+ if (body.isDone()) {
+ return;
+ }
+ for (ByteBuffer buffer : buffers) {
+ int remainingCapacity = limit - output.size();
+ if (buffer.remaining() > remainingCapacity) {
+ copy(buffer, remainingCapacity);
+ subscription.cancel();
+ body.complete(new Body(output.toByteArray(), true));
+ return;
+ }
+ copy(buffer, buffer.remaining());
+ }
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ body.completeExceptionally(throwable);
+ }
+
+ @Override
+ public void onComplete() {
+ body.complete(new Body(output.toByteArray(), false));
+ }
+
+ private void copy(ByteBuffer buffer, int count) {
+ if (count <= 0) {
+ return;
+ }
+ byte[] bytes = new byte[count];
+ buffer.get(bytes);
+ output.write(bytes, 0, bytes.length);
+ }
+ }
+
+ static final class Response {
+ private final int statusCode;
+ private final String body;
+
+ Response(int statusCode, String body) {
+ this.statusCode = statusCode;
+ this.body = body;
+ }
+
+ int getStatusCode() {
+ return statusCode;
+ }
+
+ String getBody() {
+ return body;
+ }
+
+ boolean isSuccess() {
+ return statusCode >= 200 && statusCode < 300;
+ }
+ }
+}
diff --git a/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/JsonSupport.java b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/JsonSupport.java
new file mode 100644
index 00000000000..366d19dc67d
--- /dev/null
+++ b/packages/sdk-java/qwencode/src/main/java/com/alibaba/qwen/code/daemon/JsonSupport.java
@@ -0,0 +1,292 @@
+package com.alibaba.qwen.code.daemon;
+
+import com.alibaba.fastjson2.JSON;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.core.StreamReadFeature;
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+final class JsonSupport {
+ private static final JsonFactory STRICT_JSON = JsonFactory.builder()
+ .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
+ .build();
+
+ private JsonSupport() {
+ }
+
+ static String encode(Object value) {
+ return JSON.toJSONString(value);
+ }
+
+ static Map parseObject(String json, String context) {
+ try (JsonParser parser = STRICT_JSON.createParser(json)) {
+ if (parser.nextToken() != JsonToken.START_OBJECT) {
+ throw new DaemonProtocolException(context + " must be a JSON object");
+ }
+ Map parsed = readObject(parser, context);
+ if (parser.nextToken() != null) {
+ throw new DaemonProtocolException(
+ context + " must contain exactly one JSON value");
+ }
+ return immutableObject(parsed);
+ } catch (IOException e) {
+ throw new DaemonProtocolException(context + " contains invalid JSON", e);
+ }
+ }
+
+ private static Map readObject(JsonParser parser, String context)
+ throws IOException {
+ Map result = new LinkedHashMap<>();
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ if (parser.currentToken() != JsonToken.FIELD_NAME) {
+ throw new DaemonProtocolException(context
+ + " contains a malformed JSON object");
+ }
+ String field = parser.currentName();
+ JsonToken valueToken = parser.nextToken();
+ if (valueToken == null) {
+ throw new DaemonProtocolException(context
+ + " contains an incomplete JSON object");
+ }
+ result.put(field, readValue(parser, valueToken, context));
+ }
+ return result;
+ }
+
+ private static List