clients/java: v0.2 multi-turn Session API

- Session implements AutoCloseable; try-with-resources is the canonical form
- AtomicBoolean compareAndSet idempotency on close()
- ForgeClient.createSession / .listSessions / .getSession
- TurnResult.text() helper, records throughout for shapes
- Session.toString redacts embedded client (no bearer leak)
- SessionTest.java: 13 tests covering try-with-resources/idempotency/exception/list/state/redaction/regression
- README "Multi-turn / Sessions (v0.2)" section

v0.1 surface unchanged.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
Kayos 2026-04-29 06:49:40 -07:00
parent 5737903217
commit 33b9ed5e22
9 changed files with 1241 additions and 0 deletions

View file

@ -62,6 +62,124 @@ try (ForgeClient client = ForgeClient.builder()
long-lived multi-tenant deployments, construct once and reuse, rather
than relying on `close()`.
## Multi-turn / Sessions (v0.2)
For conversations that need context across turns — debugging back and
forth, step-by-step builds, long-running agent tasks — use the
`/sessions` API. The canonical Java form is **try-with-resources**: the
session is auto-closed on the way out of the block, even if the body
throws.
```java
import com.clawdforge.ForgeClient;
import com.clawdforge.Session;
import com.clawdforge.SessionOptions;
import com.clawdforge.TurnResult;
import java.util.List;
try (ForgeClient forge = ForgeClient.builder()
.baseUrl("http://192.168.0.5:8800")
.token(System.getenv("CLAWDFORGE_TOKEN"))
.build()) {
try (Session s = forge.createSession(SessionOptions.builder()
.agent("claude")
.build())) {
TurnResult r1 = s.turn("Read README.md and summarize");
System.out.println(r1.text());
TurnResult r2 = s.turnWithFiles(
"Now look at the auth flow",
List.of("ff_uploaded_token"));
System.out.println(r2.text());
}
// session is closed (DELETE /sessions/<id>) here
}
```
For the all-defaults case the no-arg overload is enough:
```java
try (Session s = forge.createSession()) {
TurnResult r = s.turn("hello");
}
```
### `Session`
| Method | Endpoint | Returns |
|---|---|---|
| `id()` | — | `String` (server-assigned id) |
| `agent()` | — | `String` (e.g. `"claude"`) |
| `createdAt()` | — | `long` (unix seconds) |
| `isClosed()` | — | `boolean` (client's view) |
| `turn(String prompt)` | `POST /sessions/<id>/turn` | `TurnResult` |
| `turnWithFiles(String, List<String>)` | `POST /sessions/<id>/turn` | `TurnResult` |
| `turn(String, TurnOptions)` | `POST /sessions/<id>/turn` | `TurnResult` |
| `state()` | `GET /sessions/<id>` | `SessionState` |
| `close()` | `DELETE /sessions/<id>` | `void` |
`Session` implements `AutoCloseable` so try-with-resources just works.
### Idempotent close
`Session.close()` is idempotent on the client: a second call short-
circuits without an HTTP round-trip via an `AtomicBoolean`
`compareAndSet`. The server's `DELETE /sessions/<id>` is itself
idempotent (returns `already_closed: true` on a re-close), so even a
mix of explicit `close()` calls and try-with-resources auto-close is
safe — there's at most one DELETE on the wire, and it's cheap if
duplicated. If the DELETE fails on the first attempt the closed flag
is cleared, so a subsequent `close()` retries.
### Listing & inspecting sessions
```java
List<SessionState> sessions = forge.listSessions();
SessionState st = forge.getSession(sessions.get(0).sessionId());
System.out.println(st.turnCount() + " turns, last at " + st.lastTurnAt());
```
A `404` from `getSession()` surfaces as `ApiException` with status
`404` — note the server intentionally returns 404 (not 403) for a
session belonging to a different token, to avoid leaking cross-token
session existence.
### `Session.toString()` redaction
`Session.toString()` deliberately omits the embedded `ForgeClient`
reference (which holds the bearer token), mirroring the
`AppToken.toString()` redaction. A typical render:
```
Session{id=sess_abc123, agent=claude, closed=false}
```
You can stuff a `Session` into a log line without leaking the bearer.
### Records you'll see
```java
record TurnEvent(String type, String content, String name,
Map<String, Object> args, Object result) {}
record TurnResult(boolean ok, String sessionId, int turnIndex,
List<TurnEvent> events, String stopReason,
long durationMs) {
public String text() { /* concatenates all "text" events */ }
}
record SessionState(String sessionId, String agent, String appName,
long createdAt, Long lastTurnAt, int turnCount,
Long closedAt) {}
```
`TurnResult.text()` is sugar for the common case — concatenates every
`"text"` event's content in order. Callers who need the structured
event stream (thinking, tool_call, etc.) iterate `events()` directly.
## Naming & wire format
The clawdforge HTTP API uses **`snake_case`** on the wire
@ -106,6 +224,9 @@ Methods (all may throw subclasses of `ForgeException`):
| `createToken(String name, List<String> ipCidrs)` | `POST /admin/tokens` | `AppToken` |
| `listTokens()` | `GET /admin/tokens` | `List<AppToken>` |
| `revokeToken(String name)` | `DELETE /admin/tokens/<name>` | `void` |
| `createSession()` / `createSession(SessionOptions)` | `POST /sessions` | `Session` |
| `listSessions()` | `GET /sessions` | `List<SessionState>` |
| `getSession(String id)` | `GET /sessions/<id>` | `SessionState` |
### `RunRequest`
@ -130,6 +251,15 @@ Public state is exposed via Java 17 records:
- `FileToken(String fileToken, int ttlSecs, long size)`
- `AppToken(String name, String token, List<String> ipCidrs, long createdAt)`
v0.2 session types:
- `TurnEvent(String type, String content, String name, Map<String, Object> args, Object result)`
- `TurnResult(boolean ok, String sessionId, int turnIndex, List<TurnEvent> events, String stopReason, long durationMs)`
- `SessionState(String sessionId, String agent, String appName, long createdAt, Long lastTurnAt, int turnCount, Long closedAt)`
`Session` is a final class (not a record) so its `toString()` can omit
the embedded `ForgeClient` reference and avoid leaking the bearer token.
`RunResult.result()` is a Jackson `JsonNode` because the server may return
either a structured JSON value (when the prompt asked for JSON) or a plain
JSON string. Narrow with `isObject()` / `isTextual()` / etc.

View file

@ -28,7 +28,9 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
@ -257,6 +259,163 @@ public final class ForgeClient implements AutoCloseable {
ensure2xx(req, resp);
}
// ---------- v0.2: multi-turn sessions -----------------------------------
/**
* Issues {@code POST /sessions} with the supplied options and returns a
* {@link Session} handle bound to this client.
*
* <p>The returned {@link Session} is the canonical entry-point for
* multi-turn work wrap it in a try-with-resources so the server-side
* session is released even when the body throws:
*
* <pre>{@code
* try (Session s = client.createSession(SessionOptions.builder().agent("claude").build())) {
* TurnResult r = s.turn("hello");
* }
* }</pre>
*
* @param opts session options; pass an empty options bag for defaults
* @return a fresh session handle
* @throws AuthException on HTTP 401/403
* @throws ApiException on any other non-2xx response
* @throws TransportException on transport / decoding failure
*/
public Session createSession(SessionOptions opts) {
Objects.requireNonNull(opts, "opts");
Map<String, Object> body = new HashMap<>();
if (opts.agent != null) {
body.put("agent", opts.agent);
}
if (opts.meta != null) {
body.put("meta", opts.meta);
}
byte[] payload = serialize(body);
HttpRequest req = newRequest(
"/sessions",
"POST",
BodyPublishers.ofByteArray(payload),
"application/json");
JsonNode resp = send(req, JsonNode.class);
JsonNode idNode = resp.path("session_id");
if (!idNode.isTextual() || idNode.asText().isEmpty()) {
throw new TransportException(
"POST /sessions: missing or empty session_id in response");
}
String sessionId = idNode.asText();
String agent = resp.path("agent").asText(null);
long createdAt = resp.path("created_at").asLong(0L);
return new Session(this, sessionId, agent, createdAt);
}
/**
* Convenience overload equivalent to
* {@code createSession(SessionOptions.builder().build())}.
*
* @return a fresh session handle bound to this client
* @throws AuthException on HTTP 401/403
* @throws ApiException on any other non-2xx response
* @throws TransportException on transport / decoding failure
*/
public Session createSession() {
return createSession(SessionOptions.builder().build());
}
/**
* Issues {@code GET /sessions} and returns the calling token's
* sessions (newest-first per the server contract).
*
* @return list of {@link SessionState}
* @throws AuthException on HTTP 401/403
* @throws ApiException on any other non-2xx response
* @throws TransportException on transport / decoding failure
*/
public List<SessionState> listSessions() {
HttpRequest req = newRequest("/sessions", "GET", BodyPublishers.noBody(), null);
HttpResponse<byte[]> resp = sendRaw(req);
ensure2xx(req, resp);
try {
JsonNode root = MAPPER.readTree(resp.body());
JsonNode arr = root.path("sessions");
List<SessionState> out = new ArrayList<>();
if (arr.isArray()) {
for (JsonNode n : arr) {
out.add(MAPPER.treeToValue(n, SessionState.class));
}
}
return out;
} catch (IOException e) {
throw new TransportException("decode GET /sessions", e);
}
}
/**
* Issues {@code GET /sessions/<id>} for a single session.
*
* <p>A {@code 404} surfaces as {@link ApiException} with status code
* {@code 404} note the server intentionally returns 404 (not 403)
* for a session belonging to a different token, to avoid leaking
* cross-token session existence.
*
* @param id session id (URL-encoded server-side)
* @return parsed session state
* @throws AuthException on HTTP 401/403
* @throws ApiException on HTTP 404 or any other non-2xx
* @throws TransportException on transport / decoding failure
* @throws IllegalArgumentException if {@code id} is blank
*/
public SessionState getSession(String id) {
Objects.requireNonNull(id, "id");
if (id.isBlank()) {
throw new IllegalArgumentException("id must not be blank");
}
String encoded = URLEncoder.encode(id, StandardCharsets.UTF_8);
HttpRequest req = newRequest("/sessions/" + encoded, "GET",
BodyPublishers.noBody(), null);
return send(req, SessionState.class);
}
// ---------- internal session helpers (called from Session) --------------
/**
* Internal: {@code POST /sessions/<id>/turn}. Don't call directly go
* through {@link Session#turn(String)} (and friends).
*/
TurnResult sessionTurnInternal(String id, String prompt, List<String> files,
Integer timeoutSecs) {
Map<String, Object> body = new HashMap<>();
body.put("prompt", prompt);
if (files != null && !files.isEmpty()) {
body.put("files", files);
}
if (timeoutSecs != null) {
body.put("timeout_secs", timeoutSecs);
}
byte[] payload = serialize(body);
Duration httpTimeout = httpTimeoutFor(timeoutSecs);
String encoded = URLEncoder.encode(id, StandardCharsets.UTF_8);
HttpRequest req = newRequest(
"/sessions/" + encoded + "/turn",
"POST",
BodyPublishers.ofByteArray(payload),
"application/json",
httpTimeout);
return send(req, TurnResult.class);
}
/**
* Internal: {@code DELETE /sessions/<id>}. Server is idempotent; this
* method is called at most once per {@link Session} via the session's
* {@link java.util.concurrent.atomic.AtomicBoolean} guard.
*/
void sessionCloseInternal(String id) {
String encoded = URLEncoder.encode(id, StandardCharsets.UTF_8);
HttpRequest req = newRequest("/sessions/" + encoded, "DELETE",
BodyPublishers.noBody(), null);
HttpResponse<byte[]> resp = sendRaw(req);
ensure2xx(req, resp);
}
// ---------- internals ---------------------------------------------------
private HttpRequest newRequest(String path, String method, BodyPublisher body, String contentType) {

View file

@ -0,0 +1,201 @@
package com.clawdforge;
import com.clawdforge.exception.ForgeException;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Handle to a server-side multi-turn session.
*
* <p>Construct via {@link ForgeClient#createSession()} or
* {@link ForgeClient#createSession(SessionOptions)}. The canonical usage
* is try-with-resources, which ensures the server-side session is closed
* even when the body throws:
*
* <pre>{@code
* try (Session s = forge.createSession()) {
* TurnResult r = s.turn("hello");
* System.out.println(r.text());
* }
* }</pre>
*
* <p><strong>Thread safety.</strong> {@link #turn(String)} and
* {@link #close()} may be invoked concurrently; close uses an
* {@link AtomicBoolean} to short-circuit duplicate DELETEs. The server
* itself is the source of truth the SDK does not serialize concurrent
* turns on the same session, so callers issuing parallel turns must accept
* whatever ordering the server applies.
*
* <p><strong>Bearer-leak hardening.</strong> The embedded
* {@link ForgeClient} reference is deliberately omitted from
* {@link #toString()} including it would risk leaking the bearer token
* into logs (see {@code AppToken.toString()} for the parallel pattern).
*/
public final class Session implements AutoCloseable {
private final ForgeClient client;
private final String id;
private final String agent;
private final long createdAt;
private final AtomicBoolean closed = new AtomicBoolean(false);
/**
* Package-private constructor instances are minted by
* {@link ForgeClient#createSession()} after the server's
* {@code POST /sessions} response is parsed.
*
* @param client parent client for follow-up HTTP work
* @param id server-assigned session id
* @param agent agent name the session is bound to
* @param createdAt unix-second creation timestamp
*/
Session(ForgeClient client, String id, String agent, long createdAt) {
this.client = Objects.requireNonNull(client, "client");
this.id = Objects.requireNonNull(id, "id");
this.agent = agent;
this.createdAt = createdAt;
}
/**
* @return the server-assigned session id (also acpx's id)
*/
public String id() {
return id;
}
/**
* @return the agent name this session is bound to
* (default {@code "claude"})
*/
public String agent() {
return agent;
}
/**
* @return the unix-second timestamp the server recorded at create time
*/
public long createdAt() {
return createdAt;
}
/**
* @return {@code true} once {@link #close()} has succeeded at least
* once on this client. Note: this is the client's view; the
* server may have GC'd the session out from under us
* (TTL sweeper) without us knowing.
*/
public boolean isClosed() {
return closed.get();
}
/**
* Sends one turn with no extra options.
*
* @param prompt prompt text (must be non-blank)
* @return parsed {@link TurnResult}
* @throws ForgeException on any SDK-level failure
* @throws IllegalArgumentException if {@code prompt} is blank
* @throws IllegalStateException if this session has been closed
*/
public TurnResult turn(String prompt) throws ForgeException {
return turn(prompt, null);
}
/**
* Sends one turn with attached file tokens.
*
* @param prompt prompt text (must be non-blank)
* @param files list of {@code ff_...} file tokens; may be {@code null}
* or empty
* @return parsed {@link TurnResult}
* @throws ForgeException on any SDK-level failure
* @throws IllegalArgumentException if {@code prompt} is blank
* @throws IllegalStateException if this session has been closed
*/
public TurnResult turnWithFiles(String prompt, List<String> files) throws ForgeException {
return turn(prompt, TurnOptions.builder().files(files).build());
}
/**
* Sends one turn with full options.
*
* @param prompt prompt text (must be non-blank)
* @param opts per-turn options; may be {@code null} for defaults
* @return parsed {@link TurnResult}
* @throws ForgeException on any SDK-level failure
* @throws IllegalArgumentException if {@code prompt} is blank
* @throws IllegalStateException if this session has been closed
*/
public TurnResult turn(String prompt, TurnOptions opts) throws ForgeException {
Objects.requireNonNull(prompt, "prompt");
if (prompt.isBlank()) {
throw new IllegalArgumentException("prompt must not be blank");
}
if (closed.get()) {
throw new IllegalStateException(
"session " + id + " already closed by this client");
}
List<String> files = opts == null ? null : opts.files;
Integer timeoutSecs = opts == null ? null : opts.timeoutSecs;
return client.sessionTurnInternal(id, prompt, files, timeoutSecs);
}
/**
* Fetches the current server-side state for this session.
*
* @return parsed {@link SessionState}
* @throws ForgeException on any SDK-level failure (notably
* {@code ApiException(404)} if the session has
* been hard-deleted server-side)
*/
public SessionState state() throws ForgeException {
return client.getSession(id);
}
/**
* Closes the server-side session. Idempotent on the client: a second
* call short-circuits without an HTTP round-trip via
* {@link AtomicBoolean#compareAndSet}. The server's
* {@code DELETE /sessions/{id}} is itself idempotent (returns
* {@code already_closed: true} on a re-close), so even concurrent
* closes degrade gracefully.
*
* <p>If the DELETE fails on the first attempt, the closed flag is
* cleared so a subsequent call can retry. This is intentional a
* transient transport blip on close shouldn't leave the client thinking
* a still-live session was reaped.
*
* @throws ForgeException on any SDK-level failure during the DELETE
*/
@Override
public void close() throws ForgeException {
if (closed.compareAndSet(false, true)) {
try {
client.sessionCloseInternal(id);
} catch (ForgeException e) {
closed.set(false); // allow retry on transient
throw e;
}
}
}
/**
* Renders this session <strong>without</strong> the embedded
* {@link ForgeClient} reference, which holds the bearer token.
*
* <p>Records would auto-generate a {@code toString()} that includes
* every component same hazard as {@link AppToken#toString()}. We're
* a final class (not a record) here precisely so we can omit the
* client field from the rendering.
*
* @return a redacted human-readable representation
*/
@Override
public String toString() {
return "Session{id=" + id
+ ", agent=" + agent
+ ", closed=" + closed.get() + "}";
}
}

View file

@ -0,0 +1,81 @@
package com.clawdforge;
import java.util.Map;
/**
* Optional parameters for {@link ForgeClient#createSession(SessionOptions)}.
*
* <p>Build via {@link #builder()}. All fields are optional pass an empty
* options bag (or call {@link ForgeClient#createSession()}) for the
* all-defaults case.
*
* <p>Field semantics:
* <ul>
* <li>{@code agent} agent name to bind the session to. Server default
* is {@code "claude"}.</li>
* <li>{@code meta} opaque metadata map persisted in the server's
* session ledger. May be {@code null} or empty.</li>
* </ul>
*/
public final class SessionOptions {
/**
* Agent name to bind the session to (e.g. {@code "claude"}). May be
* {@code null} to accept the server default.
*/
public final String agent;
/**
* Opaque metadata persisted with the session. May be {@code null}.
*/
public final Map<String, Object> meta;
private SessionOptions(Builder b) {
this.agent = b.agent;
this.meta = b.meta;
}
/**
* @return a fresh builder
*/
public static Builder builder() {
return new Builder();
}
/**
* Fluent builder for {@link SessionOptions}.
*/
public static final class Builder {
private String agent;
private Map<String, Object> meta;
private Builder() {
}
/**
* @param agent agent name (e.g. {@code "claude"}); {@code null} to
* accept the server default
* @return this builder
*/
public Builder agent(String agent) {
this.agent = agent;
return this;
}
/**
* @param meta opaque metadata map, or {@code null}
* @return this builder
*/
public Builder meta(Map<String, Object> meta) {
this.meta = meta;
return this;
}
/**
* @return the assembled {@link SessionOptions}
*/
public SessionOptions build() {
return new SessionOptions(this);
}
}
}

View file

@ -0,0 +1,43 @@
package com.clawdforge;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Parsed response from {@code GET /sessions/{id}} and one row of
* {@code GET /sessions}.
*
* <p>{@link #lastTurnAt()} is {@link Long} (boxed, nullable) because it is
* {@code null} until the first turn is sent. {@link #closedAt()} is
* {@link Long} for the same reason {@code null} until the session is
* closed.
*
* @param sessionId server session id (also acpx's id)
* @param agent agent name this session is bound to (default
* {@code "claude"})
* @param appName name of the app token that owns this session; may be
* {@code null} on responses where the server elects not
* to include it
* @param createdAt unix-second creation timestamp
* @param lastTurnAt unix-second timestamp of the most recent turn, or
* {@code null} if no turn has been sent yet
* @param turnCount number of turns sent so far
* @param closedAt unix-second close timestamp, or {@code null} if the
* session is still open
*/
public record SessionState(
@JsonProperty("session_id") String sessionId,
@JsonProperty("agent") String agent,
@JsonProperty("app_name") String appName,
@JsonProperty("created_at") long createdAt,
@JsonProperty("last_turn_at") Long lastTurnAt,
@JsonProperty("turn_count") int turnCount,
@JsonProperty("closed_at") Long closedAt) {
/**
* Jackson-friendly canonical constructor.
*/
@JsonCreator
public SessionState {
}
}

View file

@ -0,0 +1,46 @@
package com.clawdforge;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
/**
* One structured event emitted by a turn.
*
* <p>The server collects these from acpx as the agent runs and returns them
* as a complete batch when the turn ends. Each event has at minimum a
* {@code type} ({@code "text"}, {@code "thinking"}, {@code "tool_call"},
* ...); the type-specific payload fields are pass-through and may be
* absent for non-applicable types.
*
* <p>Forward compatibility: unknown types decode normally with absent
* fields left {@code null}. Callers should switch on {@link #type()} and
* defensively handle {@code null} payload fields.
*
* @param type event type, never {@code null}; e.g. {@code "text"},
* {@code "thinking"}, {@code "tool_call"}
* @param content text content for {@code text} / {@code thinking} events,
* otherwise {@code null}
* @param name tool name for {@code tool_call} events, otherwise {@code null}
* @param args tool-call arguments for {@code tool_call} events, otherwise
* {@code null}
* @param result tool-call result for {@code tool_call} events, otherwise
* {@code null}
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record TurnEvent(
@JsonProperty("type") String type,
@JsonProperty("content") String content,
@JsonProperty("name") String name,
@JsonProperty("args") Map<String, Object> args,
@JsonProperty("result") Object result) {
/**
* Jackson-friendly canonical constructor.
*/
@JsonCreator
public TurnEvent {
}
}

View file

@ -0,0 +1,80 @@
package com.clawdforge;
import java.util.List;
/**
* Optional per-turn parameters for {@link Session#turn(String, TurnOptions)}.
*
* <p>Build via {@link #builder()}. All fields are optional.
*
* <p>Field semantics:
* <ul>
* <li>{@code files} list of {@code ff_...} file tokens (from
* {@link ForgeClient#uploadFile}) to attach to this turn. May be
* {@code null} or empty.</li>
* <li>{@code timeoutSecs} per-turn subprocess timeout in seconds.
* Server clamps to {@code [5, 600]}. {@code null} means use the
* client default.</li>
* </ul>
*/
public final class TurnOptions {
/**
* File tokens to attach to this turn. May be {@code null}.
*/
public final List<String> files;
/**
* Per-turn subprocess timeout in seconds. May be {@code null}.
*/
public final Integer timeoutSecs;
private TurnOptions(Builder b) {
this.files = b.files;
this.timeoutSecs = b.timeoutSecs;
}
/**
* @return a fresh builder
*/
public static Builder builder() {
return new Builder();
}
/**
* Fluent builder for {@link TurnOptions}.
*/
public static final class Builder {
private List<String> files;
private Integer timeoutSecs;
private Builder() {
}
/**
* @param files list of file tokens (each prefixed {@code ff_}); may
* be {@code null}
* @return this builder
*/
public Builder files(List<String> files) {
this.files = files;
return this;
}
/**
* @param timeoutSecs per-turn subprocess timeout in seconds (5..600)
* @return this builder
*/
public Builder timeoutSecs(Integer timeoutSecs) {
this.timeoutSecs = timeoutSecs;
return this;
}
/**
* @return the assembled {@link TurnOptions}
*/
public TurnOptions build() {
return new TurnOptions(this);
}
}
}

View file

@ -0,0 +1,62 @@
package com.clawdforge;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* Parsed response from {@code POST /sessions/{id}/turn} on success.
*
* <p>{@link #events()} is the structured event stream the server collected
* from acpx for this turn. Most callers want plain prose use
* {@link #text()} to concatenate every {@code "text"} event's content.
*
* @param ok always {@code true} on the success path
* @param sessionId server session id this turn targeted
* @param turnIndex 0-based index of this turn within the session
* @param events structured event stream (never {@code null}; may be
* empty)
* @param stopReason agent's reported stop reason, e.g. {@code "end_turn"}
* @param durationMs server-side wall-clock duration in milliseconds
*/
public record TurnResult(
@JsonProperty("ok") boolean ok,
@JsonProperty("session_id") String sessionId,
@JsonProperty("turn_index") int turnIndex,
@JsonProperty("events") List<TurnEvent> events,
@JsonProperty("stop_reason") String stopReason,
@JsonProperty("duration_ms") long durationMs) {
/**
* Jackson-friendly canonical constructor.
*
* <p>Coerces a missing or {@code null} {@code events} list to
* {@link List#of()} so callers can rely on a non-null collection.
*/
@JsonCreator
public TurnResult {
if (events == null) {
events = List.of();
}
}
/**
* Concatenates every {@code "text"} event's content in order.
*
* <p>Sugar for the common case where a caller wants the model's prose
* reply and doesn't care about thinking / tool_call events. Returns an
* empty string if no text events were emitted.
*
* @return concatenated text content, or {@code ""} if there were none
*/
public String text() {
return events.stream()
.filter(e -> "text".equals(e.type()))
.map(TurnEvent::content)
.filter(Objects::nonNull)
.collect(Collectors.joining(""));
}
}

View file

@ -0,0 +1,439 @@
package com.clawdforge;
import com.clawdforge.exception.ApiException;
import com.clawdforge.exception.ForgeException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* v0.2 multi-turn / Session API tests. Mirrors the in-process
* {@link HttpServer} approach from {@link ForgeClientTest} so the test
* suite has no external mock-server dependency.
*/
class SessionTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private HttpServer server;
private String baseUrl;
@BeforeEach
void setUp() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.start();
baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
}
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
private ForgeClient client(String token) {
return ForgeClient.builder().baseUrl(baseUrl).token(token).build();
}
private void route(String path, HttpHandler h) {
server.createContext(path, h);
}
private static void respond(HttpExchange ex, int status, String body) throws IOException {
byte[] payload = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().add("Content-Type", "application/json");
ex.sendResponseHeaders(status, payload.length);
ex.getResponseBody().write(payload);
ex.close();
}
private static String readBody(HttpExchange ex) throws IOException {
return new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
}
/**
* Wires up a minimal /sessions surface backed by an in-memory ledger.
* Returns a counter map so tests can assert how many times each path
* was hit.
*/
private Map<String, AtomicInteger> wireMinimalSessionRoutes(String sessionId) {
Map<String, AtomicInteger> hits = Map.of(
"POST /sessions", new AtomicInteger(),
"POST /sessions/<id>/turn", new AtomicInteger(),
"DELETE /sessions/<id>", new AtomicInteger(),
"GET /sessions/<id>", new AtomicInteger(),
"GET /sessions", new AtomicInteger());
// POST /sessions (create) also matches GET /sessions (list)
route("/sessions", ex -> {
String method = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
if ("POST".equals(method) && "/sessions".equals(path)) {
hits.get("POST /sessions").incrementAndGet();
respond(ex, 200, "{\"session_id\":\"" + sessionId
+ "\",\"agent\":\"claude\",\"created_at\":1700000000}");
return;
}
if ("GET".equals(method) && "/sessions".equals(path)) {
hits.get("GET /sessions").incrementAndGet();
respond(ex, 200, "{\"sessions\":[]}");
return;
}
// Sub-paths: /sessions/<id> or /sessions/<id>/turn
if (path.endsWith("/turn") && "POST".equals(method)) {
hits.get("POST /sessions/<id>/turn").incrementAndGet();
int idx = hits.get("POST /sessions/<id>/turn").get() - 1;
respond(ex, 200,
"{\"ok\":true,\"session_id\":\"" + sessionId
+ "\",\"turn_index\":" + idx
+ ",\"events\":[{\"type\":\"text\",\"content\":\"hi\"}],"
+ "\"stop_reason\":\"end_turn\",\"duration_ms\":42}");
return;
}
if ("DELETE".equals(method)) {
hits.get("DELETE /sessions/<id>").incrementAndGet();
respond(ex, 200, "{\"ok\":true}");
return;
}
if ("GET".equals(method)) {
hits.get("GET /sessions/<id>").incrementAndGet();
respond(ex, 200,
"{\"session_id\":\"" + sessionId
+ "\",\"agent\":\"claude\",\"app_name\":\"app1\","
+ "\"created_at\":1700000000,\"last_turn_at\":null,"
+ "\"turn_count\":0,\"closed_at\":null}");
return;
}
respond(ex, 405, "{}");
});
return hits;
}
// ----- tests -------------------------------------------------------------
@Test
@DisplayName("createAndCloseTryWithResources: POST /sessions on entry, DELETE on exit")
void createAndCloseTryWithResources() throws IOException {
Map<String, AtomicInteger> hits = wireMinimalSessionRoutes("sess_abc");
try (ForgeClient c = client("cf_test")) {
try (Session s = c.createSession()) {
assertEquals("sess_abc", s.id());
assertEquals("claude", s.agent());
assertEquals(1700000000L, s.createdAt());
assertFalse(s.isClosed());
}
}
assertEquals(1, hits.get("POST /sessions").get());
assertEquals(1, hits.get("DELETE /sessions/<id>").get(),
"auto-close must fire DELETE /sessions/<id> exactly once");
}
@Test
@DisplayName("closeIdempotent: manual close x2 issues DELETE only once")
void closeIdempotent() {
Map<String, AtomicInteger> hits = wireMinimalSessionRoutes("sess_idem");
ForgeClient c = client("cf_test");
Session s = c.createSession();
s.close();
assertTrue(s.isClosed());
s.close(); // second call must short-circuit
s.close(); // and again
assertEquals(1, hits.get("DELETE /sessions/<id>").get(),
"DELETE must hit ONCE across multiple close() calls");
}
@Test
@DisplayName("closeOnException: exception in try-with-resources still triggers DELETE")
void closeOnException() {
Map<String, AtomicInteger> hits = wireMinimalSessionRoutes("sess_ex");
ForgeClient c = client("cf_test");
RuntimeException boom = new RuntimeException("boom");
RuntimeException caught = assertThrows(RuntimeException.class, () -> {
try (Session s = c.createSession()) {
assertNotNull(s);
throw boom;
}
});
assertEquals("boom", caught.getMessage());
assertEquals(1, hits.get("DELETE /sessions/<id>").get(),
"close() must fire on exception unwind");
}
@Test
@DisplayName("turnRoundTrip: POST /sessions/<id>/turn round-trip with body shape")
void turnRoundTrip() throws IOException {
AtomicReference<String> sentBody = new AtomicReference<>();
AtomicReference<String> sentPath = new AtomicReference<>();
// Custom routing: capture turn body, return canned events.
route("/sessions", ex -> {
String method = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
if ("POST".equals(method) && "/sessions".equals(path)) {
respond(ex, 200,
"{\"session_id\":\"sess_rt\",\"agent\":\"claude\",\"created_at\":1}");
return;
}
if (path.endsWith("/turn")) {
sentPath.set(path);
sentBody.set(readBody(ex));
respond(ex, 200,
"{\"ok\":true,\"session_id\":\"sess_rt\",\"turn_index\":2,"
+ "\"events\":["
+ "{\"type\":\"thinking\",\"content\":\"...\"},"
+ "{\"type\":\"tool_call\",\"name\":\"Read\","
+ "\"args\":{\"path\":\"x\"},\"result\":\"ok\"},"
+ "{\"type\":\"text\",\"content\":\"hello\"},"
+ "{\"type\":\"text\",\"content\":\" world\"}"
+ "],\"stop_reason\":\"end_turn\",\"duration_ms\":123}");
return;
}
if ("DELETE".equals(method)) {
respond(ex, 200, "{\"ok\":true}");
return;
}
respond(ex, 405, "{}");
});
try (Session s = client("cf_test").createSession()) {
TurnResult r = s.turnWithFiles("Read README", List.of("ff_abc", "ff_def"));
assertTrue(r.ok());
assertEquals("sess_rt", r.sessionId());
assertEquals(2, r.turnIndex());
assertEquals("end_turn", r.stopReason());
assertEquals(123L, r.durationMs());
assertEquals(4, r.events().size());
assertEquals("hello world", r.text());
// Verify wire shape of the sent body
JsonNode body = MAPPER.readTree(sentBody.get());
assertEquals("Read README", body.get("prompt").asText());
assertTrue(body.get("files").isArray());
assertEquals("ff_abc", body.get("files").get(0).asText());
assertEquals("ff_def", body.get("files").get(1).asText());
// URL-encoded id in path (sess_rt has no special chars but sanity-check)
assertEquals("/sessions/sess_rt/turn", sentPath.get());
}
}
@Test
@DisplayName("turnAfterCloseThrows: turn after close raises IllegalStateException")
void turnAfterCloseThrows() {
wireMinimalSessionRoutes("sess_dead");
Session s = client("cf_test").createSession();
s.close();
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> s.turn("anybody home?"));
assertTrue(ex.getMessage().contains("already closed"),
"got: " + ex.getMessage());
}
@Test
@DisplayName("turnResultTextConcatenates: text() joins all 'text' events in order")
void turnResultTextConcatenates() {
TurnResult r = new TurnResult(true, "s", 0,
List.of(
new TurnEvent("thinking", "ignored", null, null, null),
new TurnEvent("text", "alpha ", null, null, null),
new TurnEvent("tool_call", null, "Read",
Map.of("p", "x"), "result"),
new TurnEvent("text", "beta", null, null, null),
new TurnEvent("text", null, null, null, null) // skipped
),
"end_turn", 10L);
assertEquals("alpha beta", r.text());
}
@Test
@DisplayName("listSessions: GET /sessions parses {sessions:[...]} envelope")
void listSessions() {
route("/sessions", ex -> {
assertEquals("GET", ex.getRequestMethod());
respond(ex, 200,
"{\"sessions\":["
+ "{\"session_id\":\"a\",\"agent\":\"claude\","
+ "\"app_name\":\"app1\",\"created_at\":100,"
+ "\"last_turn_at\":150,\"turn_count\":3,"
+ "\"closed_at\":null},"
+ "{\"session_id\":\"b\",\"agent\":\"claude\","
+ "\"app_name\":\"app1\",\"created_at\":200,"
+ "\"last_turn_at\":null,\"turn_count\":0,"
+ "\"closed_at\":250}"
+ "]}");
});
List<SessionState> ss = client("cf_test").listSessions();
assertEquals(2, ss.size());
assertEquals("a", ss.get(0).sessionId());
assertEquals("app1", ss.get(0).appName());
assertEquals(Long.valueOf(150L), ss.get(0).lastTurnAt());
assertNull(ss.get(0).closedAt());
assertEquals(3, ss.get(0).turnCount());
assertEquals("b", ss.get(1).sessionId());
assertNull(ss.get(1).lastTurnAt());
assertEquals(Long.valueOf(250L), ss.get(1).closedAt());
}
@Test
@DisplayName("getSession: GET /sessions/<id> parses SessionState")
void getSession() {
route("/sessions/", ex -> {
assertEquals("GET", ex.getRequestMethod());
String path = ex.getRequestURI().getPath();
assertEquals("/sessions/sess_g", path);
respond(ex, 200,
"{\"session_id\":\"sess_g\",\"agent\":\"claude\","
+ "\"app_name\":\"app1\",\"created_at\":100,"
+ "\"last_turn_at\":120,\"turn_count\":1,"
+ "\"closed_at\":null}");
});
SessionState st = client("cf_test").getSession("sess_g");
assertEquals("sess_g", st.sessionId());
assertEquals("claude", st.agent());
assertEquals("app1", st.appName());
assertEquals(100L, st.createdAt());
assertEquals(Long.valueOf(120L), st.lastTurnAt());
assertEquals(1, st.turnCount());
assertNull(st.closedAt());
}
@Test
@DisplayName("crossTokenIs404: GET /sessions/<id> from another token -> ApiException(404)")
void crossTokenIs404() {
route("/sessions/", ex -> respond(ex, 404,
"{\"detail\":\"no such session\"}"));
ApiException e = assertThrows(ApiException.class,
() -> client("cf_other").getSession("sess_owned_by_a"));
assertEquals(404, e.statusCode());
assertTrue(e.getMessage().contains("no such session"),
"got: " + e.getMessage());
}
@Test
@DisplayName("sessionToStringDoesNotLeakToken: Session.toString() omits the bearer")
void sessionToStringDoesNotLeakToken() {
wireMinimalSessionRoutes("sess_redact");
ForgeClient c = ForgeClient.builder()
.baseUrl(baseUrl)
.token("cf_super_secret_do_not_log")
.build();
try (Session s = c.createSession()) {
String repr = s.toString();
assertFalse(repr.contains("cf_super_secret_do_not_log"),
"Session.toString() must not include the bearer; got: " + repr);
assertFalse(repr.toLowerCase().contains("token"),
"Session.toString() should not even hint at a token field; got: " + repr);
assertFalse(repr.contains("ForgeClient"),
"Session.toString() should not embed the client; got: " + repr);
assertTrue(repr.contains("id=sess_redact"),
"non-secret fields should remain visible; got: " + repr);
assertTrue(repr.contains("closed=false"),
"closed flag should be visible; got: " + repr);
}
}
@Test
@DisplayName("v1RunUnchanged: v0.1 /run path still works with v0.2 surface present")
void v1RunUnchanged() {
// Regression: /run on a v0.2-equipped client must behave exactly
// as v0.1, including snake_case body shape and JsonNode result.
AtomicReference<String> got = new AtomicReference<>();
route("/run", ex -> {
try { got.set(readBody(ex)); } catch (IOException ignored) {}
respond(ex, 200,
"{\"ok\":true,\"result\":{\"hello\":\"world\"},"
+ "\"duration_ms\":1234,\"stop_reason\":\"end_turn\"}");
});
RunResult res = client("cf_test").run(RunRequest.builder()
.prompt("hi")
.model("sonnet")
.timeoutSecs(60)
.build());
assertTrue(res.ok());
assertEquals(1234L, res.durationMs());
assertEquals("end_turn", res.stopReason());
assertEquals("world", res.result().get("hello").asText());
assertNotNull(got.get());
}
@Test
@DisplayName("closeOnTransientFailure: failed close clears flag so caller can retry")
void closeOnTransientFailure() {
AtomicInteger deletes = new AtomicInteger();
AtomicReference<String> sessionId = new AtomicReference<>("sess_retry");
route("/sessions", ex -> {
String method = ex.getRequestMethod();
String path = ex.getRequestURI().getPath();
if ("POST".equals(method) && "/sessions".equals(path)) {
respond(ex, 200,
"{\"session_id\":\"" + sessionId.get()
+ "\",\"agent\":\"claude\",\"created_at\":1}");
return;
}
if ("DELETE".equals(method)) {
int n = deletes.incrementAndGet();
if (n == 1) {
respond(ex, 500, "{\"detail\":\"transient\"}");
} else {
respond(ex, 200, "{\"ok\":true}");
}
return;
}
respond(ex, 405, "{}");
});
Session s = client("cf_test").createSession();
// First close should throw (500) and clear closed flag for retry.
assertThrows(ForgeException.class, s::close);
assertFalse(s.isClosed(),
"transient failure must clear the closed flag for retry");
// Second close succeeds.
s.close();
assertTrue(s.isClosed());
assertEquals(2, deletes.get());
}
@Test
@DisplayName("createSession sends agent + meta in POST body")
void createSessionSendsAgentAndMeta() throws IOException {
AtomicReference<String> sentBody = new AtomicReference<>();
route("/sessions", ex -> {
sentBody.set(readBody(ex));
respond(ex, 200,
"{\"session_id\":\"sess_meta\",\"agent\":\"claude\",\"created_at\":1}");
});
SessionOptions opts = SessionOptions.builder()
.agent("claude")
.meta(Map.of("trace", "abc-123"))
.build();
Session s = client("cf_test").createSession(opts);
// Don't auto-close (no DELETE handler in this minimal route).
assertEquals("sess_meta", s.id());
JsonNode sent = MAPPER.readTree(sentBody.get());
assertEquals("claude", sent.get("agent").asText());
assertTrue(sent.get("meta").isObject());
assertEquals("abc-123", sent.get("meta").get("trace").asText());
}
}