diff --git a/clients/java/README.md b/clients/java/README.md index 76ba36f..2e1af82 100644 --- a/clients/java/README.md +++ b/clients/java/README.md @@ -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/) 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//turn` | `TurnResult` | +| `turnWithFiles(String, List)` | `POST /sessions//turn` | `TurnResult` | +| `turn(String, TurnOptions)` | `POST /sessions//turn` | `TurnResult` | +| `state()` | `GET /sessions/` | `SessionState` | +| `close()` | `DELETE /sessions/` | `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/` 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 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 args, Object result) {} + +record TurnResult(boolean ok, String sessionId, int turnIndex, + List 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 ipCidrs)` | `POST /admin/tokens` | `AppToken` | | `listTokens()` | `GET /admin/tokens` | `List` | | `revokeToken(String name)` | `DELETE /admin/tokens/` | `void` | +| `createSession()` / `createSession(SessionOptions)` | `POST /sessions` | `Session` | +| `listSessions()` | `GET /sessions` | `List` | +| `getSession(String id)` | `GET /sessions/` | `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 ipCidrs, long createdAt)` +v0.2 session types: + +- `TurnEvent(String type, String content, String name, Map args, Object result)` +- `TurnResult(boolean ok, String sessionId, int turnIndex, List 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. diff --git a/clients/java/src/main/java/com/clawdforge/ForgeClient.java b/clients/java/src/main/java/com/clawdforge/ForgeClient.java index 529d9f6..057bb0e 100644 --- a/clients/java/src/main/java/com/clawdforge/ForgeClient.java +++ b/clients/java/src/main/java/com/clawdforge/ForgeClient.java @@ -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. + * + *

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: + * + *

{@code
+     * try (Session s = client.createSession(SessionOptions.builder().agent("claude").build())) {
+     *     TurnResult r = s.turn("hello");
+     * }
+     * }
+ * + * @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 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 listSessions() { + HttpRequest req = newRequest("/sessions", "GET", BodyPublishers.noBody(), null); + HttpResponse resp = sendRaw(req); + ensure2xx(req, resp); + try { + JsonNode root = MAPPER.readTree(resp.body()); + JsonNode arr = root.path("sessions"); + List 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/} for a single session. + * + *

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//turn}. Don't call directly — go + * through {@link Session#turn(String)} (and friends). + */ + TurnResult sessionTurnInternal(String id, String prompt, List files, + Integer timeoutSecs) { + Map 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/}. 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 resp = sendRaw(req); + ensure2xx(req, resp); + } + // ---------- internals --------------------------------------------------- private HttpRequest newRequest(String path, String method, BodyPublisher body, String contentType) { diff --git a/clients/java/src/main/java/com/clawdforge/Session.java b/clients/java/src/main/java/com/clawdforge/Session.java new file mode 100644 index 0000000..31e5e6d --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/Session.java @@ -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. + * + *

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: + * + *

{@code
+ * try (Session s = forge.createSession()) {
+ *     TurnResult r = s.turn("hello");
+ *     System.out.println(r.text());
+ * }
+ * }
+ * + *

Thread safety. {@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. + * + *

Bearer-leak hardening. 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 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 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. + * + *

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 without the embedded + * {@link ForgeClient} reference, which holds the bearer token. + * + *

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() + "}"; + } +} diff --git a/clients/java/src/main/java/com/clawdforge/SessionOptions.java b/clients/java/src/main/java/com/clawdforge/SessionOptions.java new file mode 100644 index 0000000..ab41e1b --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/SessionOptions.java @@ -0,0 +1,81 @@ +package com.clawdforge; + +import java.util.Map; + +/** + * Optional parameters for {@link ForgeClient#createSession(SessionOptions)}. + * + *

Build via {@link #builder()}. All fields are optional — pass an empty + * options bag (or call {@link ForgeClient#createSession()}) for the + * all-defaults case. + * + *

Field semantics: + *

    + *
  • {@code agent} — agent name to bind the session to. Server default + * is {@code "claude"}.
  • + *
  • {@code meta} — opaque metadata map persisted in the server's + * session ledger. May be {@code null} or empty.
  • + *
+ */ +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 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 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 meta) { + this.meta = meta; + return this; + } + + /** + * @return the assembled {@link SessionOptions} + */ + public SessionOptions build() { + return new SessionOptions(this); + } + } +} diff --git a/clients/java/src/main/java/com/clawdforge/SessionState.java b/clients/java/src/main/java/com/clawdforge/SessionState.java new file mode 100644 index 0000000..3f21ae7 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/SessionState.java @@ -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}. + * + *

{@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 { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/TurnEvent.java b/clients/java/src/main/java/com/clawdforge/TurnEvent.java new file mode 100644 index 0000000..df25329 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/TurnEvent.java @@ -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. + * + *

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. + * + *

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 args, + @JsonProperty("result") Object result) { + + /** + * Jackson-friendly canonical constructor. + */ + @JsonCreator + public TurnEvent { + } +} diff --git a/clients/java/src/main/java/com/clawdforge/TurnOptions.java b/clients/java/src/main/java/com/clawdforge/TurnOptions.java new file mode 100644 index 0000000..55ab176 --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/TurnOptions.java @@ -0,0 +1,80 @@ +package com.clawdforge; + +import java.util.List; + +/** + * Optional per-turn parameters for {@link Session#turn(String, TurnOptions)}. + * + *

Build via {@link #builder()}. All fields are optional. + * + *

Field semantics: + *

    + *
  • {@code files} — list of {@code ff_...} file tokens (from + * {@link ForgeClient#uploadFile}) to attach to this turn. May be + * {@code null} or empty.
  • + *
  • {@code timeoutSecs} — per-turn subprocess timeout in seconds. + * Server clamps to {@code [5, 600]}. {@code null} means use the + * client default.
  • + *
+ */ +public final class TurnOptions { + + /** + * File tokens to attach to this turn. May be {@code null}. + */ + public final List 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 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 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); + } + } +} diff --git a/clients/java/src/main/java/com/clawdforge/TurnResult.java b/clients/java/src/main/java/com/clawdforge/TurnResult.java new file mode 100644 index 0000000..0863f6c --- /dev/null +++ b/clients/java/src/main/java/com/clawdforge/TurnResult.java @@ -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. + * + *

{@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 events, + @JsonProperty("stop_reason") String stopReason, + @JsonProperty("duration_ms") long durationMs) { + + /** + * Jackson-friendly canonical constructor. + * + *

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. + * + *

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("")); + } +} diff --git a/clients/java/src/test/java/com/clawdforge/SessionTest.java b/clients/java/src/test/java/com/clawdforge/SessionTest.java new file mode 100644 index 0000000..2d7c43e --- /dev/null +++ b/clients/java/src/test/java/com/clawdforge/SessionTest.java @@ -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 wireMinimalSessionRoutes(String sessionId) { + Map hits = Map.of( + "POST /sessions", new AtomicInteger(), + "POST /sessions//turn", new AtomicInteger(), + "DELETE /sessions/", new AtomicInteger(), + "GET /sessions/", 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/ or /sessions//turn + if (path.endsWith("/turn") && "POST".equals(method)) { + hits.get("POST /sessions//turn").incrementAndGet(); + int idx = hits.get("POST /sessions//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/").incrementAndGet(); + respond(ex, 200, "{\"ok\":true}"); + return; + } + if ("GET".equals(method)) { + hits.get("GET /sessions/").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 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/").get(), + "auto-close must fire DELETE /sessions/ exactly once"); + } + + @Test + @DisplayName("closeIdempotent: manual close x2 issues DELETE only once") + void closeIdempotent() { + Map 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/").get(), + "DELETE must hit ONCE across multiple close() calls"); + } + + @Test + @DisplayName("closeOnException: exception in try-with-resources still triggers DELETE") + void closeOnException() { + Map 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/").get(), + "close() must fire on exception unwind"); + } + + @Test + @DisplayName("turnRoundTrip: POST /sessions//turn round-trip with body shape") + void turnRoundTrip() throws IOException { + AtomicReference sentBody = new AtomicReference<>(); + AtomicReference 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 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/ 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/ 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 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 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 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()); + } +}