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:
parent
5737903217
commit
33b9ed5e22
9 changed files with 1241 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
201
clients/java/src/main/java/com/clawdforge/Session.java
Normal file
201
clients/java/src/main/java/com/clawdforge/Session.java
Normal 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() + "}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
clients/java/src/main/java/com/clawdforge/SessionState.java
Normal file
43
clients/java/src/main/java/com/clawdforge/SessionState.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
46
clients/java/src/main/java/com/clawdforge/TurnEvent.java
Normal file
46
clients/java/src/main/java/com/clawdforge/TurnEvent.java
Normal 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 {
|
||||
}
|
||||
}
|
||||
80
clients/java/src/main/java/com/clawdforge/TurnOptions.java
Normal file
80
clients/java/src/main/java/com/clawdforge/TurnOptions.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
62
clients/java/src/main/java/com/clawdforge/TurnResult.java
Normal file
62
clients/java/src/main/java/com/clawdforge/TurnResult.java
Normal 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(""));
|
||||
}
|
||||
}
|
||||
439
clients/java/src/test/java/com/clawdforge/SessionTest.java
Normal file
439
clients/java/src/test/java/com/clawdforge/SessionTest.java
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue