qwen-code/packages/sdk-java/qwencode/README.md
Shaojin Wen d565d7463d
fix(sdk-java): Close Hosted Harness review criticals from #12654 (#13314)
* fix(sdk-java): Close Hosted Harness review criticals from #12654

#12654 merged before its second review round was processed. This closes
the 11 Critical and 2 Minor findings, each re-verified against main and
pinned by a new HostedHarnessClientTest case whose mutation witness was
run (reverting the fix reddens the test):

- HarnessEventStream close() no longer parks behind a next() blocked in
  the socket read: the closed flag is atomic, the raw input is closed
  without any stream monitor, and HostedHarnessClient.close() completes;
  the blocked consumer fails with DaemonTransportException (R1-1).
- The hosted SSE transport gains the idle watchdog its stdio sibling
  has: Builder.sseIdleTimeout defaults to 45s, and a silent peer makes
  next() fail with "Hosted Harness SSE idle timeout" (R1-43).
- submitTurn releases the active-prompt slot on a definitive 409 and
  throws PromptAlreadyActiveException; retaining a marker keyed by the
  rejected promptId wedged the session client-side (R1-2). A definitive
  non-409 failure on a same-identity retry releases that slot too (F2).
- getStatus() snapshots the entry before the remote read and removes
  only that registration, so a stale hasActivePrompt:false cannot delete
  a different prompt admitted in flight (R1-4).
- Responses missing the X-Qwen-Harness-Boot-Id fence header on mutation
  paths are classified outcome-unknown (create/prompt/mutation/delete)
  instead of a bare DaemonProtocolException (R1-5).
- A local executor rejection (saturated or terminated pool) surfaces as
  DaemonTransportException instead of claiming the request may have
  reached the daemon (R1-45).
- closeSession(ref) and detachSession treat a definitive 404 as done and
  tear down the heartbeat timer (R1-3, R1-46).
- streamEvents falls back to the session ref's validated watermark when
  the builder omits the resume pair, so the epoch fence actually
  applies (R1-44).
- The shared SSE parser accepts the id-less session_snapshot frame, so
  StreamHarnessEvents.snapshot(true) works (R1-47).
- computePayloadDigest hashes a sorted-key canonical form, so the wire
  dedup key is reproducible across JVM processes (R1-6).
- HarnessRuntimeExecutionRecovery requires status for known outcomes at
  construction instead of NPE-ing in isContinuationReady() (F1).

Test: HostedHarnessClientTest 34/34 (15 new); full module 181 green with
9 env-gated skips on JDK 17 and clean test green on JDK 11; 14 mutation
witness arms all red. The remaining 49 Suggestion-level findings are
dispositioned in their review threads and tracked in #13313.

* fix: Close second-round review criticals on the Hosted Harness client

Addresses the CHANGES_REQUESTED round on #13314, which execution-proved
one real regression in the first commit plus five more critical defects
around it:

- R1-1 (the regression CI confirmed on the fault-gates lane): the digest
  was canonicalized but the wire `prompt` member was not, and the hosted
  route re-derives the digest from the received bytes — every addText
  submission drew `400 invalid_hosted_prompt`. toJson() now emits the
  canonical form, the digest reuses the JsonSupport.encode facade over
  that same value, and the test asserts the wire bytes and the digest
  are literally the same value.
- R1-3: the 45s idle watchdog was inherited from the stdio transport,
  whose server heartbeats; the hosted events route wrote nothing while
  no event was committed, so healthy silent streams (tool calls, the
  10-minute approval wait) were aborted. The hosted route now mirrors
  the primary route's keepalive comment every 15s, and
  Builder.sseIdleTimeout accepts Duration.ZERO for callers that own the
  deadline. A keepalive-only peer is covered by a new test (consumer
  parked in read for three idle budgets).
- R1-4: an idle abort while the consumer was between next() calls
  surfaced IllegalStateException (not the DaemonException family). The
  closed guard now distinguishes who closed the stream: idle abort ->
  DaemonTransportException("Hosted Harness SSE idle timeout"),
  caller close -> IllegalStateException, matching the javadoc.
- R1-5: the cause-chain scan certified any rejection-shaped IOException
  as "never dispatched", but delivery also runs on the caller executor,
  so a rejection after the server accepted the prompt dropped the
  active-prompt marker for an admitted turn. Only the bare
  RejectedExecutionException (submit-time) and the terminated-executor
  case now classify as saturated/known; everything mid-flight stays
  outcome-unknown. A deterministic delivery-phase test drives this via
  a package-private executor seam (park the worker, fill the queue,
  then answer 202).
- R1-6: the missing-boot-header wrap reclassified every unfenced
  response as outcome-unknown, including definitive 4xx the server
  emits itself before the fencing middleware (413, 429). All four
  sites now re-derive: ambiguous (408/5xx) keeps outcome-unknown,
  definitive unfenced statuses surface as DaemonHttpException with
  their status preserved.
- R1-7 (suggestion folded in, same hunk family): detachSession tears
  the local attachment down for every definitive answer, not just 204
  and 404 — a 403/400/410 no longer leaks a heartbeat timer.
- R1-13 (suggestion, adjacent): a caller queued on cursorLock when the
  stream closes re-checks inside the lock and gets the closed-stream
  signal instead of a transport error.

Also: the snapshot frame comment now names the actual producer (the
bridge's withSnapshot on the primary events route — never the hosted
route); the StreamHarnessEvents builder javadoc documents the
lastEventId fallback precedence and that snapshot(true) is a
primary-transport option; the README retry section and both design-doc
languages now state the canonical digest as a cross-language wire
contract and record that changing the digest algorithm requires
quiescing or rewriting in-flight turn rows (R1-2's deployment note);
the two 10ms heartbeat tests assert liveness instead of comparing
zeros; and an explicit-cursor-wins case plus a definitive
boot-headerless DELETE case close the two remaining unpinned arms.

Test: HostedHarnessClientTest 42/42 (full module 189 green, 9
env-gated skips) on JDK 17; clean test green on JDK 11 (2 additional
version-gated skips: JDK 11 parks such sends instead of surfacing the
rejection); hosted-harness-session.test.ts 181/181. Nine mutation
witness arms all red (revert toJson canonicalization, forget idle
signal in the closed guard, drop the in-lock re-check, unbounded
cause-walk, blanket boot-header wrap at prompt and DELETE sites,
detach without finally, watchdog removed with the keepalive arm
staying green, ref-only resume fallback).

* fix(sdk-java): close third-round review criticals on the Hosted Harness client (#13314)

- submitTurn: a definitive unfenced refusal (a response without a valid
  boot header that is not an ambiguous 408/5xx) now releases only the
  active-prompt marker the call owns, in both the validateGeneration arm
  and the post-consume arm; a refusal produced before the route's
  promptId lookup can no longer delete an earlier same-identity entry
  whose outcome is unknown, and a refused fresh submission no longer
  wedges the session.
- send: snapshot the executor termination state before dispatch so a
  close() racing an in-flight send keeps its outcome-unknown
  classification instead of certifying non-dispatch.
- StreamHarnessEvents: an explicit lastEventId cursor now always wins,
  including a deliberate 0 (replay from the beginning), which
  HarnessCoordinator passes for turn rows without a recorded watermark;
  only an omitted cursor falls back to the session ref's watermark.
- HarnessEventStream: the idle watchdog now measures peer silence only
  while a consumer is parked in next(), so time between calls (a
  blocking queue handoff, a slow event handler) no longer aborts a
  healthy keepalived stream.
- docs: the payload digest is re-derived by the server from its own
  re-serialization of the parsed prompt member (JSON.stringify form),
  not from the received bytes; the README qualifies cross-release digest
  stability and the keepalive floor for a custom idle timeout.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): close fourth-round review findings on the Hosted Harness client (#13314)

- Carry the wire status and machine-readable code through
  PromptAlreadyActiveException instead of collapsing every 409 into a
  fixed message
- Keep a prompt marker whose submission is still in flight from being
  cleared by a status answer that predates the admission
- Map a watchdog scheduling rejection to DaemonTransportException and
  drop the stranded stream registration
- Document the enforced idle-abort window (budget to 1.5x) and the
  peer-side backpressure limit on consumer pauses
- Pin the fenced definitive-refusal release, the Duration.ZERO watchdog
  opt-out, the next() entry stamp, and the terminated-executor release
  with dedicated regression tests

* fix(sdk-java): close fifth-round review findings on the Hosted Harness client (#13314)

- Retain the active-prompt marker when a fenced 409 carries
  hosted_prompt_recovery_required: that code proves the promptId is in
  the durable input log, so releasing the marker would admit a second
  identity into a session with two unsettled inputs and fail every
  recovery entrance closed; every other 409 code still releases it
- Clear the hosted events keepalive from stop() as well as from the
  close handler, and send X-Accel-Buffering: no so a buffering
  intermediary cannot swallow the keepalives the client watchdog relies
  on
- Pin the settled status-read clearing, the failed-close unregister, and
  the disabled watchdog's empty scheduler queue with positive witnesses,
  and extract the shared fenced-SSE response prelude in the client tests
- Correct the documented idle-abort window with the 100 ms poll floor,
  state the digest's sha256: wire encoding in the README, and narrow the
  PromptAlreadyActiveException javadoc to what the code does

* fix(sdk-java): lifecycle teardown runs only on definitive answers

Close the corridor the next-round review's close/detach receipts queued:
local attachments (and their heartbeat timers) must not survive a
definitive refusal, and must NOT be torn down on outcome-unknown.

- detachSession: the unconditional `finally { removeAttachment }` also
  ran when requireMutationStatus surfaced unexpected-success as
  MutationOutcomeUnknownException — a state where the server's answer is
  not decided and the lease must keep beating. Teardown now runs on the
  definitive limb (204 or tolerated 404) and inside the
  DaemonHttpException arm only; outcome-unknown surfaces keep the
  attachment (anUnexpectedDetachAnswerKeepsTheAttachment).
- closeSession(HarnessSessionRef) and closeSession(String): mirror the
  same try/catch — a definitive DELETE refusal (e.g. 403) now also retires
  the attachment and the active-prompt ledger instead of leaking the
  timer until close(), while ambiguous answers keep them
  (aRefusedCloseStillTearsDownTheHeartbeat ×2 overloads,
  anUnexpectedCloseAnswerKeepsTheAttachment,
  aBootHeaderlessCloseKeepsTheAttachment for the gateway-502 shape).
- closeSession(String) gains the ensureOpen() guard every other public
  entry has (a deferral receipt plus #13313's R1-50): the closed-client
  path is JVM-independent again, so aLocalExecutorRejectionIsNotReported
  AsOutcomeUnknown becomes closeSessionByIdFailsFastAfterClientClose —
  IllegalStateException on every JDK — and the saturated/terminated
  taxonomy stays pinned by aTerminatedHttpExecutorReleasesTheOwnedMarker.
- closeByRefTreatsAnAbsentHarnessSessionAsClosed now also counts the
  DELETE request (dropping the remote call reddens assertEquals(1)).
- Housekeeping of the takeover branch: aRecoveryRequiredRefusalKeepsThe
  OwnedMarker's stub never incremented its promptCalls counter, so its
  trailing assertion could never pass once it ran (CI never reached it
  through the verify-checkout race) — fixed by counting in the handler.

Witnesses: full module 214/214 green on JDK 17 (clean test green on JDK
11, 13 skips = 9 env + 4 version-gated); corridor mutation arms all red —
detach with an unconditional finally flattens the keep-beating assertion,
removing either close-mirror makes refused-close heartbeats keep growing
(9→14, 10→16), and removing ensureOpen flips the fail-fast test from
IllegalStateException back to the transport exception.

* fix(sdk-java): settle registered admission markers and align lifecycle teardown

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): retire hosted admission state on proven restart and scope the settle latch per attempt (#13314)

A proven harness generation change now retires every local record for
that peer at all three entrances: the submitTurn fence catch releases
the admission ledger entry, the scheduled heartbeat retires the
attachment and ledger instead of swallowing the mismatch, and
detachSession mirrors closeSession for the generation branch while
keeping the ledger across definitive refusals.

The admission settle latch is per-attempt and outcome-scoped: only the
owning call's concluded send settles it, every attempt re-arms it, and
a same-identity retry's 202 publishes a fresh settled entry so a status
read holding a stale snapshot cannot clear the marker that is live now.
Pre-dispatch unchecked escapes release the owned marker via a dispatch
boundary flag in send() instead of settle-and-keep.

The SSE idle watchdog merges the parked flag and activity stamp into
one volatile waitingSince write, so a tick can never pair a fresh flag
with a stale stamp; keepalive refills still refresh the budget.

Docs correct the persisted-digest claim: a form change invalidates rows
whose stored key order was not already canonical, not every row.

* fix(cli): drop ambient QWEN_HOME from the cli test setup (#13314)

A run from inside a Qwen Code session always exports QWEN_HOME, which
outranks the homedir() mocks the settings suite anchors on: the
module-load getUserSettingsPath() then resolves to the ambient home, the
mocked user-settings file is never found, and 13 settings.test.ts tests
fail. The verification gate runs in exactly such an environment. Delete
the variable in test-setup.ts, mirroring the existing QWEN_RUNTIME_DIR
sanitization; tests that exercise QWEN_HOME set it in-body.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): close hosted harness ledger wedges from review (#13314)

Unfenced 2xx/3xx mutation answers are now outcome-unknown instead of
definitive, unfenced refusals carry the peer's buffered body, the
per-attempt settle latch is owner-gated, close/detach retire only the
records they observed, and wire 409s stay reachable through
DaemonHttpException.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): close hosted harness review criticals on digest recovery, refusal typing, and ledger identity (#13314)

- SubmitHarnessTurn accepts a persisted pre-canonicalization payload
  digest and re-sends the prompt in the exact as-built key order that
  digest covers, so recovering an older turn row reaches the route's
  replay path instead of failing the self-check before any request is
  sent.
- An unfenced 4xx on the generic mutation channel surfaces as the
  protocol failure it is rather than DaemonHttpException, whose status
  consumers key terminal verdicts on; only submitTurn keeps the typed
  refusal its admission-marker release keys on.
- ActivePrompt records the submitting client id and replaces the shared
  settle latch with a per-attempt send counter: lifecycle teardowns
  retire only markers whose identity proved stale, and a status read
  clears a marker only while no send attempt is in flight.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): close hosted harness round-10 review criticals on close/detach retirement (#13314)

- README: correct the digest-migration paragraph — the client re-sends
  the shape its digest covers and accepts a pre-canonicalization as-built
  digest, so upgrades do not quiesce or rewrite in-flight turn rows
- closeSession(String): a successful headerless DELETE deleted the
  session outright, so retire every attachment for the id, including one
  registered while the DELETE was in flight
- submitTurn: gate all six owning-release arms on no send attempt still
  open, so a definitive refusal cannot strand a same-identity retry's
  202 with no ledger entry
- detachLifecycle: retire the observed admission marker even when no
  attachment remains (a refused close drops the attachment first), in
  both overloads
- closeSession(HarnessSessionRef): a 204 proves identity-matched
  deletion, so retire every marker for the id; keep identity-matched
  retirement only for the tolerated identity-scoped 404

* fix(cli): wait out the record watcher's backoff in the unrecorded-reply squad test (#13314)

The deterministic verification gate rejected the previous commit on
"wakes the leader with the error when a member reply is never recorded"
(default 1s vi.waitFor timed out waiting for the leader's wake). The
give-up that wakes the leader is eligible the moment the test bumps its
virtual clock, but it fires on the watcher's next real-timer check, and
that chain backs off geometrically (recordWatchMs doubling to a 15s cap)
— under full-suite load the next tick lands past the 1s default. The
sibling restart-recovery test already guards the identical pattern with
timeout: 3_000 and the comment "The watcher backs off; its next check
may be a while."; apply the same bound here. Assertion-preserving.

Probe: with the watcher's initial interval raised to 1.5s, the
unmodified test fails with the gate's exact assertion at the same line;
with the timeout it passes.

* fix(sdk-java): close HostedHarnessClient admission-ledger fences found in review (#13314)

Round-14 review found the admission-ledger fencing still open in eight
places:

- detachLifecycle(HarnessSessionRef) retired the pre-send snapshot even
  on a tolerated 404, wiping the marker the session's current owner
  planted through a re-issued identity; it now retires through the same
  identity-matched retireLedger detachSession uses, which also covers a
  marker this identity replanted while the detach was in flight.
- detachSession collapsed a deleting 204 and a tolerated 404 into one
  identity-matched retirement, so a proven deletion kept a marker from
  an older identity; the 204 arm now retires unconditionally.
- Both closeSession overloads applied the observed-snapshot rule to a
  generation change, so a marker published while the DELETE was in
  flight survived a proven restart; both now retire unconditionally on
  HostedHarnessGenerationException.
- The sendMutation channel and the streamEvents fence observed a
  generation change without retiring local state; both now discard it,
  and sendMutation threads the session id from every caller.
- The unfenced definitive-4xx arm released without reading the refusal
  code; an unfenced 409 is now narrowed by code like the fenced arm, so
  a boot-header-stripped hosted_prompt_recovery_required retains the
  marker and surfaces as PromptAlreadyActiveException.
- When both the owning attempt and a joined same-identity retry were
  definitively refused, neither released the marker; an owning release
  refused by an open sibling send is now owed and discharged at the last
  concluded attempt, while outcome-unknown or durably admitted
  conclusions block it.
- A non-owning retry's send window closed before its 202 republish, so
  an owning release landing in between dropped the joined entry and the
  replace silently no-oped; the window now closes after the ledger
  decision.
- The dispatch guard probed the executor, so a saturated-but-running
  pool was misclassified outcome-unknown; send() now dispatches with
  sendAsync and maps the synchronous RejectedExecutionException to a
  known-outcome DaemonTransportException, keeping post-dispatch
  rejections outcome-unknown.

Each fix carries a pinning test, and the three pre-dispatch escape tests
move their injection seam from the removed executor-state probe to the
executor's execute().

* test(cli): settle fresh process rows before identity-judged ledger sweeps (#13314)

The deterministic gate rejected the previous round on
managed-runtime-ledger.test.ts: on some hosts ps reports a just-born
process with the negative-elapsed wraparound (e.g. 441077234-00:18:40,
which parsePsElapsed rightly rejects) until it ages past the glitch, so
an unwitnessed sweep judged the live fixture 'unknown' and held it
unproven instead of killing it. settledGroupTable now also waits for
the leader's row to carry a datable elapsed column, and the five
live-spawn sweep tests that never settled now settle before sweeping.
Assertions unchanged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): report a close-aborted parked SSE read as a transport failure (#13314)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore(sdk-java): re-verify #13314 on a tree clean of generated Maven artifacts

The deterministic gate rejected 253326ce58 because repo-wide
'npm run lint' (eslint flat config lints .js repo-wide) swept up the
JaCoCo report resources that local 'mvn test' generates under
packages/sdk-java/*/target/site/jacoco/ — 139 errors in
jacoco-resources/prettify.js and sort.js. Those files are git-ignored
build output, not committed content; the committed tree is lint-clean.

This commit carries no tree change: the repair is the cleaned working
tree, with 'npm run lint' now passing, and build/typecheck plus the
focused R15-1 contract tests (HarnessEventStreamCloseTest and the four
HostedHarnessClientTest idle-timeout/close cases) re-verified green.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk-java): close hosted harness ledger and SSE close races

- Classify a parked SSE read's raced close as DaemonTransportException,
  matching the interrupted-read signal, with the idle-timeout half kept
  distinct; the programming-error type stays reserved for callers
  arriving after the close.
- Keep the admission ledger on every refused close, on a settled release
  whose entry is releaseBlocked, and for a code-less unfenced 409, which
  is outcome-unknown rather than a definitive refusal.
- Retire the marker an owning same-identity replay just planted when the
  promptId's terminal event was already observed, via the settled-checked
  release path.
- Read the getStatus marker clock before settledness and re-check both
  inside the bin-locked clear, so a retry's beginSend landing between the
  snapshot reads cannot be cleared under its open send window.
- Retire only the observed attachment on a by-id close, and self-retire
  an attachment whose heartbeat meets a definitive
  hosted_session_not_found 404 instead of beating against the dead
  session forever.

* chore(sdk-java): repair round verification on a clean working tree

The deterministic gate rejected 4884dc8197 on npm run lint: the previous
round's mvn test had generated the git-ignored JaCoCo HTML report under
packages/sdk-java/qwencode/target/site/jacoco/jacoco-resources/*.js, and
the flat eslint config lints git-ignored-but-present .js files (it does
not import .gitignore), failing on the vendored prettify.js/sort.js. The
root eslint config is outside this PR's footprint, so the repair removes
the untracked build output from the working tree. No source change; the
rejected commit's content is preserved as-is.

* fix(sdk-java): retire hosted admission state on journal replays and deleting lifecycle detaches (#13314)

* docs(sdk-java): state the digest canonical key order the code implements (#13314)

The published digest contract claimed object keys are sorted
recursively, but JsonSupport.ownKeyOrder and the route's
sha256(JSON.stringify(prompt)) re-derivation use ECMAScript own-key
order: array-index keys ascend numerically first, then the remaining
keys in insertion order. A second-language client built on the
published sentence computes a different digest and is refused with
invalid_hosted_prompt.

* fix(sdk-java): keep teardown retirement off live state and durable history (#13314)

A detach or a DELETE closes only the live Hosted session: the durable
journal survives it, and after a re-load the prompt route answers a
settled promptId's same-identity replay with a 202 that owes no terminal
event. Retiring the terminal-event record on teardown therefore left
that replay's marker vetoing every later prompt. No teardown retires the
record any more; the map is bounded instead (least recently used past
4096 sessions), and an evicted session's replay marker is cleared by the
busy-turn status probe.

The headerless lifecycle detach also removed the admission marker
unconditionally after its attachment snapshot, so a re-load that
registered a new attachment and admitted a turn inside that window lost
its marker and a different prompt reached the wire. Every definitive
teardown answer (detach 204 on all three entrances, the headerless
tolerated 404, DELETE 204 on both overloads) now retires through one
bin-locked decision that reads the current attachment under the
marker's lock: a marker the current attachment's identity planted, or
joined with a same-identity retry, stays; an abandoned identity's marker
retires. The identity-scoped tolerated-404 retirement keeps a marker
another identity's retry joined, under the same lock.

Tests: the teardown cases are parameterized over all five entrances;
fixtures whose vetoed submit hit an unrouted /status now answer the
busy-turn probe instead of waiting out the request timeout (the class
drops from ~320 s to ~20 s), and the probe's generation-change and
send-clock behavior are pinned.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: qwen-code[bot] <qwen-code[bot]@users.noreply.github.com>
2026-10-16 21:06:22 +08:00

28 KiB

Qwen Code Java SDK

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 >= 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 API: org.slf4j:slf4j-api (choose an SLF4J provider in your application)
  • Utilities: org.apache.commons:commons-lang3
  • JSON Processing: Fastjson2 for encoding and Jackson Core for strict decoding
  • Testing: JUnit 5 (org.junit.jupiter:junit-jupiter)

Installation

Add the following dependency to your Maven pom.xml:

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>qwencode-sdk</artifactId>
    <version>0.1.0-alpha</version>
</dependency>

Or if using Gradle, add to your build.gradle:

implementation 'com.alibaba:qwencode-sdk:0.1.0-alpha'

Building and Running

Build Commands

# Compile the project
mvn compile

# Run tests
mvn test

# Package the JAR
mvn package

# Install to local repository
mvn install

Real daemon E2E from source

Run the real-daemon Java integration tests from the repository root after building both the workspaces and the root CLI bundle:

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.

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, the per-epoch terminal guarantee from #7400, restart-safe event cursor epochs from #7458, 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.

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());
}

Callers that need to allocate the session identity before creation can pass an RFC UUID v1-v5. The SDK checks session_id_override before the mutation and reports a different returned ID as SessionCreationOutcomeUnknownException:

CreateSessionRequest request = CreateSessionRequest.builder()
        .sessionId("550E8400-E29B-41D4-A716-446655440000")
        .build();

try (DaemonSessionClient session = daemon.createSession(request)) {
    System.out.println(session.getSession().getSessionId());
}

The daemon normalizes the ID to lowercase and creates a new thread session. This is not an idempotent attach; after an ambiguous create outcome, recover with the known ID rather than retrying creation.

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, pairs the cursor with X-Qwen-Event-Epoch when the daemon supplies an epoch, validates framing and event IDs, deduplicates replay, and reconnects only the SSE GET. It learns an epoch from the validated prompt admission or SSE response headers and fails closed if the epoch changes during prompt observation. Older daemons that omit both surfaces remain compatible but retain their numeric-only stale-cursor detection. 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 a caller supplies a session ID, the SDK additionally requires session_id_override before sending the mutation. 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.

Hosted Harness private API

HostedHarnessClient is the Java control-plane transport for qwen serve --profile hosted-harness. Construction performs capability negotiation, verifies the deployment capability digest, and pins the process bootId. Every Session request then carries protocol and generation fencing automatically. This API keeps model-loop events raw; product services remain responsible for public Session, Turn, Event, tenant, and workspace authorization state.

Map<String, Object> text = Map.of("type", "text", "text", "hello");
String payloadDigest = SubmitHarnessTurn.computePayloadDigest(List.of(text));

try (HostedHarnessClient harness = HostedHarnessClient.builder()
        .baseUri(URI.create("http://127.0.0.1:4170"))
        .bearerToken(System.getenv("QWEN_SERVER_TOKEN"))
        .capabilityDigest(System.getenv(
                "QWEN_HOSTED_HARNESS_CAPABILITY_DIGEST"))
        .build()) {
    HarnessSessionRef session = harness.createSession(
            CreateHarnessSession.builder()
                    .harnessSessionId(UUID.randomUUID().toString())
                    .build());
    PromptReceipt receipt = harness.submitTurn(
            SubmitHarnessTurn.builder()
                    .session(session)
                    .promptId(UUID.randomUUID().toString())
                    .addContent(text)
                    .payloadDigest(payloadDigest)
                    .build());
    try (HarnessEventStream events = harness.streamEvents(
            StreamHarnessEvents.builder()
                    .session(session)
                    .lastEventId(receipt.getLastEventId())
                    .eventEpoch(receipt.getEventEpoch())
                    .build())) {
        for (DaemonEvent event = events.next(); event != null;
                event = events.next()) {
            // Persist/project the raw Harness event in the Java product service.
        }
    }
    harness.detachSession(session);
}

The client permits only one active prompt identity per Session while allowing different Sessions to run concurrently. A retry after PromptAdmissionUnknownException must reuse the same promptId, payload digest, prompt content, and deadline. The payload digest is a cross-language contract: it is the SHA-256 of a canonical form of the prompt content, sent on the wire as sha256:<64 lowercase hex> (object members appear in JavaScript JSON.stringify own-key order — array-index keys ("0" through "4294967294", no leading zero) ascending numerically first, then the remaining keys in insertion order — applied recursively, array order preserved), the client emits as the wire prompt exactly the key order its digest covers, and the server re-derives the identical value by re-serializing the parsed prompt member with JavaScript JSON.stringify semantics (compact separators, literal non-ASCII). The canonical form is versioned by the SDK: the digest is reproducible across JVM processes for a given release, but a release that changes it changes every persisted digest whose stored key order was not already canonical, so the client accepts a persisted digest computed over the as-built key order when the canonical recomputation does not match, and re-sends the prompt in exactly the shape that digest covers — a legacy row reaches its replay path instead of failing the client-side self-check. That compatibility arm covers one generation: it rescues a row written before the form changed, not a row this release writes, so a second consecutive form change still needs the stored rows quiesced. Do not rewrite a stored digest or a stored input to make it match. SSE boot, epoch, or sequence changes fail closed for transcript/status reconciliation. close() releases local HTTP, heartbeat, timer, and stream resources only; it never destroys remote Sessions implicitly. Event streams are bounded by a 45-second idle watchdog that measures peer silence while a consumer is parked in next() — time between next() calls is not charged (see Builder.sseIdleTimeout; Duration.ZERO disables it for callers that own the deadline). The watchdog checks every half-budget but no more often than every 100 ms, so an idle abort lands between the budget and the budget plus max(100 ms, half the budget) — 45 to ~67.5 seconds at the default — and any outer supervisor deadline should sit above that upper bound. The hosted events route emits keepalive comments every 15 seconds so legitimately silent phases — long tool calls, slow model phases, approval waits — do not trip the bound, which a custom sseIdleTimeout should therefore stay comfortably above.

Legacy stdio API

The existing com.alibaba.qwen.code.cli API remains available:

public static void runSimpleExample() {
    List<String> result = QwenCodeCli.simpleQuery("hello world");
    result.forEach(logger::info);
}

For more advanced usage with custom transport options:

public static void runTransportOptionsExample() {
    TransportOptions options = new TransportOptions()
            .setModel("qwen3-coder-flash")
            .setPermissionMode(PermissionMode.AUTO_EDIT)
            .setCwd("./")
            .setEnv(new HashMap<String, String>() {{put("CUSTOM_VAR", "value");}})
            .setIncludePartialMessages(true)
            .setTurnTimeout(new Timeout(120L, TimeUnit.SECONDS))
            .setMessageTimeout(new Timeout(90L, TimeUnit.SECONDS))
            .setAllowedTools(Arrays.asList("read_file", "write_file", "glob"));

    List<String> result = QwenCodeCli.simpleQuery("who are you, what are your capabilities?", options);
    result.forEach(logger::info);
}

For streaming content handling with custom content consumers:

public static void runStreamingExample() {
    QwenCodeCli.simpleQuery("who are you, what are your capabilities?",
            new TransportOptions().setMessageTimeout(new Timeout(10L, TimeUnit.SECONDS)), new AssistantContentSimpleConsumers() {

                @Override
                public void onText(Session session, TextAssistantContent textAssistantContent) {
                    logger.info("Text content received: {}", textAssistantContent.getText());
                }

                @Override
                public void onThinking(Session session, ThinkingAssistantContent thinkingAssistantContent) {
                    logger.info("Thinking content received: {}", thinkingAssistantContent.getThinking());
                }

                @Override
                public void onToolUse(Session session, ToolUseAssistantContent toolUseContent) {
                    logger.info("Tool use content received: {} with arguments: {}",
                            toolUseContent, toolUseContent.getInput());
                }

                @Override
                public void onToolResult(Session session, ToolResultAssistantContent toolResultContent) {
                    logger.info("Tool result content received: {}", toolResultContent.getContent());
                }

                @Override
                public void onOtherContent(Session session, AssistantContent<?> other) {
                    logger.info("Other content received: {}", other);
                }

                @Override
                public void onUsage(Session session, AssistantUsage assistantUsage) {
                    logger.info("Usage information received: Input tokens: {}, Output tokens: {}",
                            assistantUsage.getUsage().getInputTokens(), assistantUsage.getUsage().getOutputTokens());
                }
            }.setDefaultPermissionOperation(Operation.allow));
    logger.info("Streaming example completed.");
}

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 detects a daemon event-epoch change during an observed prompt but does not automatically recover from it. 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 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.

The daemon implementation does not reuse the legacy process transport, session model, DTOs, or global executor.

Legacy stdio features

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.
  • auto: An LLM classifier approves tool calls.
  • yolo: All tools execute automatically without confirmation.

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

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: A non-empty allowlist of core tools. An omitted or empty list is treated as unspecified; an empty list does not disable tools. MCP and other non-core tools have separate registration and permission rules. Use excludeTools to exclude named tools explicitly; allowedTools controls approval rather than registration.
  • 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: true
  • Rejected Execution Handler: CallerRunsPolicy

Tasks still running or queued in the default pool are abandoned when the JVM exits. Callers that require completion must wait for those tasks explicitly.

Error Handling

The SDK provides specific exception types for different error scenarios:

  • SessionControlException: Thrown when there's an issue with session control, including attempting to use a closed or unavailable session. Session construction and start() can throw it directly; QwenCodeCli.newSession() wraps lower-level creation and initialization failures in a RuntimeException.
  • SessionSendPromptException: Thrown when there's an issue sending a prompt or receiving a response

FAQ / Troubleshooting

Q: What Java versions are supported?

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?

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.

License

Apache-2.0 - see LICENSE for details.