clients/kotlin: v0.2 multi-turn Session API
- Session : Closeable; AtomicBoolean idempotent close (rollback on transient)
- forge.session(opts) { s -> ... } block helper preferred
- ForgeClient.createSession / listSessions / getSession
- Per-call HTTP timeout on /sessions/{id}/turn (audit-fix 3c77ef5 pattern)
- Per-session Mutex serializes concurrent turns
- TurnResult.text() helper, Session.toString redacts client
- SessionTest.kt: ~14 tests covering block/idempotency/concurrent/timeout/list/state/404/redaction/regression
- README "Multi-turn / Sessions (v0.2)" section
v0.1 surface unchanged. Ktor 2.3.13 preserved.
Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
parent
0f091771d3
commit
8479725513
5 changed files with 1099 additions and 2 deletions
|
|
@ -25,7 +25,7 @@ repositories {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.clawdforge:clawdforge:0.1.0")
|
||||
implementation("com.clawdforge:clawdforge:0.2.0")
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -68,6 +68,96 @@ suspend fun main() {
|
|||
}
|
||||
```
|
||||
|
||||
## Multi-turn / Sessions (v0.2)
|
||||
|
||||
v0.2 adds a parallel `/sessions/*` surface for multi-turn conversations.
|
||||
v0.1's `/run` still works exactly as before — sessions are purely additive.
|
||||
|
||||
The preferred entry point is the `session { … }` block helper, which
|
||||
auto-closes the server-side session on block exit, even on throw:
|
||||
|
||||
```kotlin
|
||||
import com.clawdforge.ForgeClient
|
||||
import com.clawdforge.SessionOptions
|
||||
|
||||
suspend fun main() {
|
||||
val forge = ForgeClient("http://localhost:8800", System.getenv("CLAWDFORGE_TOKEN"))
|
||||
forge.use {
|
||||
forge.session(SessionOptions(agent = "claude")) { s ->
|
||||
val r1 = s.turn("Read README.md and summarize")
|
||||
println(r1.text())
|
||||
|
||||
val r2 = s.turn("Now look at the auth flow", files = listOf("ff_xyz"))
|
||||
println(r2.text())
|
||||
}
|
||||
// session is closed here; DELETE /sessions/{id} has fired
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Session` implements `java.io.Closeable`, so it also composes with `use`:
|
||||
|
||||
```kotlin
|
||||
forge.createSession().use { s ->
|
||||
val r = s.turn("hello")
|
||||
println(r.text())
|
||||
} // auto-closed
|
||||
```
|
||||
|
||||
For long-lived handles you can drive the lifecycle yourself:
|
||||
|
||||
```kotlin
|
||||
val s = forge.createSession(SessionOptions(agent = "claude"))
|
||||
try {
|
||||
val r = s.turn("hello")
|
||||
} finally {
|
||||
s.close()
|
||||
}
|
||||
```
|
||||
|
||||
Listing and inspection:
|
||||
|
||||
```kotlin
|
||||
val sessions: List<SessionState> = forge.listSessions()
|
||||
val state: SessionState = forge.getSession(s.id)
|
||||
```
|
||||
|
||||
### Behaviours worth flagging
|
||||
|
||||
- **Idempotent close.** `Session.close()` only fires `DELETE /sessions/{id}`
|
||||
the first time it's called; later invocations short-circuit on an
|
||||
internal `AtomicBoolean`. If the DELETE fails on the first attempt the
|
||||
flag is rolled back so a transient transport blip can be retried.
|
||||
- **Concurrent turns are serialized.** Two coroutines calling `s.turn()`
|
||||
on the same `Session` won't race on the wire — an internal coroutine
|
||||
`Mutex` orders them, so the server only ever sees one in-flight turn
|
||||
per session. (Multiple sessions, of course, run in parallel.)
|
||||
- **Per-call HTTP timeout.** `Session.turn(prompt, files, timeoutSecs)`
|
||||
applies the same per-call timeout pattern as audit-fix `3c77ef5` for
|
||||
`ForgeClient.run`: a `timeoutSecs` larger than `ForgeOptions.defaultTimeout`
|
||||
extends the HTTP request window so the client doesn't disconnect while
|
||||
the server is still doing useful work.
|
||||
- **`Session.toString()` redacts the embedded client.** The `ForgeClient`
|
||||
reference (which carries the bearer) is intentionally omitted. Same
|
||||
hazard / same pattern as `AppToken.toString`.
|
||||
- **Cross-token access is 404.** `forge.getSession(id)` against a session
|
||||
owned by another token surfaces as `ForgeApiException(statusCode=404)`
|
||||
— the server returns 404 (not 403) to avoid leaking session existence.
|
||||
|
||||
### Turn output
|
||||
|
||||
`TurnResult.events` is the structured event list (text, thinking,
|
||||
tool_call, …). For the common case where you want the model's textual
|
||||
reply, use `result.text()`:
|
||||
|
||||
```kotlin
|
||||
val r = s.turn("Explain in two sentences")
|
||||
println(r.text()) // concatenated content of every type=="text" event
|
||||
```
|
||||
|
||||
`TurnEvent.args` and `TurnEvent.result` are `JsonElement?` — narrow them
|
||||
with the standard `kotlinx.serialization.json` extensions.
|
||||
|
||||
## Public API
|
||||
|
||||
All methods are `suspend` — call them from a coroutine scope.
|
||||
|
|
@ -80,6 +170,10 @@ All methods are `suspend` — call them from a coroutine scope.
|
|||
| `createToken(CreateTokenRequest)` | `POST /admin/tokens` | Admin bootstrap token only. |
|
||||
| `listTokens()` | `GET /admin/tokens` | Admin only. |
|
||||
| `revokeToken(name)` | `DELETE /admin/tokens/{name}` | Admin only. |
|
||||
| `createSession(SessionOptions)` | `POST /sessions` | v0.2; returns a `Session`. |
|
||||
| `session(SessionOptions) { s -> … }` | — | v0.2 block helper; auto-closes. |
|
||||
| `listSessions()` | `GET /sessions` | v0.2; returns `List<SessionState>`. |
|
||||
| `getSession(id)` | `GET /sessions/{id}` | v0.2; cross-token = 404. |
|
||||
| `close()` | — | Disposes the underlying Ktor client. |
|
||||
|
||||
### Constructor
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ plugins {
|
|||
}
|
||||
|
||||
group = "com.clawdforge"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
|
|
|
|||
|
|
@ -254,6 +254,154 @@ public class ForgeClient @JvmOverloads public constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// v0.2: multi-turn sessions
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /sessions`. Creates a server-side session and returns a
|
||||
* [Session] handle bound to this client.
|
||||
*
|
||||
* Prefer the [session] block helper, which auto-closes the returned
|
||||
* handle even when the body throws. Use this method directly when you
|
||||
* need a session whose lifetime crosses suspending boundaries that
|
||||
* make a block helper inconvenient — wrap in `try { … } finally {
|
||||
* s.close() }` (or `s.use { … }`) to avoid leaking server-side state.
|
||||
*
|
||||
* @param options agent / metadata; defaults are fine for the common case.
|
||||
*/
|
||||
public suspend fun createSession(options: SessionOptions = SessionOptions()): Session =
|
||||
wrapTransport("POST /sessions") {
|
||||
val resp = http.post("$baseUrl/sessions") {
|
||||
authHeader()
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(SessionCreateBody(agent = options.agent, meta = options.meta))
|
||||
}
|
||||
val body = decodeOrThrow<SessionCreateResponse>(resp)
|
||||
Session(
|
||||
client = this,
|
||||
id = body.sessionId,
|
||||
agent = body.agent,
|
||||
createdAt = body.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block-helper around [createSession] / [Session.close]. Creates a
|
||||
* session, runs [block] with it, and closes the server-side session
|
||||
* even when [block] throws:
|
||||
*
|
||||
* ```kotlin
|
||||
* forge.session(SessionOptions(agent = "claude")) { s ->
|
||||
* val r = s.turn("hello")
|
||||
* println(r.text())
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The close exception (if any) is swallowed — the body's outcome is
|
||||
* what propagates. If you want to observe close failures, use
|
||||
* [createSession] + `try/finally` directly.
|
||||
*/
|
||||
public suspend fun <T> session(
|
||||
options: SessionOptions = SessionOptions(),
|
||||
block: suspend (Session) -> T,
|
||||
): T {
|
||||
val s = createSession(options)
|
||||
return try {
|
||||
block(s)
|
||||
} finally {
|
||||
try {
|
||||
s.close()
|
||||
} catch (ce: CancellationException) {
|
||||
throw ce
|
||||
} catch (_: Throwable) {
|
||||
// Mirror the audit pattern: don't override an in-flight
|
||||
// body exception with a close-time failure. Callers that
|
||||
// need close-failure visibility can drive createSession()
|
||||
// + try/finally themselves.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /sessions`. Lists the calling token's sessions (newest-first,
|
||||
* per the server contract).
|
||||
*/
|
||||
public suspend fun listSessions(): List<SessionState> = wrapTransport("GET /sessions") {
|
||||
val resp = http.get("$baseUrl/sessions") { authHeader() }
|
||||
decodeOrThrow<SessionList>(resp).sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /sessions/{id}`. A session belonging to a different token
|
||||
* surfaces as [ForgeApiException] with `statusCode = 404` — the
|
||||
* server intentionally returns 404 (not 403) for cross-token access
|
||||
* to avoid leaking session existence.
|
||||
*/
|
||||
public suspend fun getSession(id: String): SessionState {
|
||||
require(id.isNotEmpty()) { "id must not be empty" }
|
||||
return wrapTransport("GET /sessions/{id}") {
|
||||
val resp = http.get("$baseUrl/sessions/${id.encodeURLPathPart()}") {
|
||||
authHeader()
|
||||
}
|
||||
decodeOrThrow(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: `POST /sessions/{id}/turn`. Don't call directly — go
|
||||
* through [Session.turn], which holds the per-session mutex.
|
||||
*
|
||||
* Applies the audit-fix `3c77ef5` per-call HTTP timeout pattern: a
|
||||
* caller-supplied `timeoutSecs` larger than [ForgeOptions.defaultTimeout]
|
||||
* extends the HTTP request window so we don't disconnect while the
|
||||
* server is still doing useful work.
|
||||
*/
|
||||
internal suspend fun sessionTurnInternal(
|
||||
id: String,
|
||||
prompt: String,
|
||||
files: List<String>?,
|
||||
timeoutSecs: Int?,
|
||||
): TurnResult {
|
||||
val effectiveTimeoutSecs = timeoutSecs
|
||||
?: options.defaultTimeout.inWholeSeconds.toInt()
|
||||
val callTimeoutMs = (effectiveTimeoutSecs * 1000L) + options.requestMargin.inWholeMilliseconds
|
||||
val encodedId = id.encodeURLPathPart()
|
||||
return wrapTransport("POST /sessions/{id}/turn") {
|
||||
val resp = http.post("$baseUrl/sessions/$encodedId/turn") {
|
||||
timeout {
|
||||
requestTimeoutMillis = callTimeoutMs
|
||||
socketTimeoutMillis = callTimeoutMs
|
||||
}
|
||||
authHeader()
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
SessionTurnBody(
|
||||
prompt = prompt,
|
||||
files = files?.takeIf { it.isNotEmpty() },
|
||||
timeoutSecs = timeoutSecs,
|
||||
),
|
||||
)
|
||||
}
|
||||
decodeOrThrow(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: `DELETE /sessions/{id}`. Don't call directly — go through
|
||||
* [Session.close], which guards with an [java.util.concurrent.atomic.AtomicBoolean]
|
||||
* so the DELETE only fires once per session handle.
|
||||
*/
|
||||
internal suspend fun sessionCloseInternal(id: String) {
|
||||
val encodedId = id.encodeURLPathPart()
|
||||
wrapTransport("DELETE /sessions/{id}") {
|
||||
val resp = http.delete("$baseUrl/sessions/$encodedId") { authHeader() }
|
||||
if (!resp.status.isSuccess()) {
|
||||
throwForStatus(resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Disposes the underlying Ktor client. After this, the instance is unusable. */
|
||||
override fun close() {
|
||||
http.close()
|
||||
|
|
|
|||
279
clients/kotlin/src/main/kotlin/com/clawdforge/Sessions.kt
Normal file
279
clients/kotlin/src/main/kotlin/com/clawdforge/Sessions.kt
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
package com.clawdforge
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import java.io.Closeable
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Options for [ForgeClient.createSession] / [ForgeClient.session].
|
||||
*
|
||||
* Pass an empty instance for defaults:
|
||||
*
|
||||
* ```kotlin
|
||||
* forge.session { s -> s.turn("hi") } // defaults
|
||||
* forge.session(SessionOptions(agent = "claude")) { s -> ... } // explicit
|
||||
* ```
|
||||
*
|
||||
* @property agent agent slug the server should bind this session to.
|
||||
* Defaults to `"claude"`.
|
||||
* @property meta opaque per-session metadata; serialised verbatim under the
|
||||
* `meta` key in the create-body. `null` is omitted from the wire.
|
||||
*/
|
||||
public data class SessionOptions(
|
||||
public val agent: String = "claude",
|
||||
public val meta: JsonElement? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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
|
||||
* [type] (`"text"`, `"thinking"`, `"tool_call"`, ...); the type-specific
|
||||
* payload fields are pass-through and may be absent for non-applicable
|
||||
* types.
|
||||
*
|
||||
* Forward-compat: unknown types decode normally with absent fields left
|
||||
* `null`. Callers should switch on [type] and defensively handle `null`
|
||||
* payload fields.
|
||||
*
|
||||
* @property type event type, e.g. `"text"`, `"thinking"`, `"tool_call"`.
|
||||
* @property content text content for `text` / `thinking` events; null otherwise.
|
||||
* @property name tool name for `tool_call` events; null otherwise.
|
||||
* @property args tool-call arguments for `tool_call` events; null otherwise.
|
||||
* @property result tool-call result for `tool_call` events; null otherwise.
|
||||
*/
|
||||
@Serializable
|
||||
public data class TurnEvent(
|
||||
public val type: String,
|
||||
public val content: String? = null,
|
||||
public val name: String? = null,
|
||||
public val args: JsonElement? = null,
|
||||
public val result: JsonElement? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parsed body of `POST /sessions/{id}/turn`.
|
||||
*
|
||||
* Use [text] to concatenate the `text` events into a single string for the
|
||||
* common case where you just want "what did the model say?".
|
||||
*
|
||||
* @property ok true on a successful turn.
|
||||
* @property sessionId server session id (also acpx's id).
|
||||
* @property turnIndex zero-based turn ordinal within this session.
|
||||
* @property events structured events emitted by the turn, in order.
|
||||
* @property stopReason reason the turn ended (`"end_turn"`, `"max_tokens"`, …).
|
||||
* @property durationMs server-side wall-clock duration in milliseconds.
|
||||
*/
|
||||
@Serializable
|
||||
public data class TurnResult(
|
||||
public val ok: Boolean,
|
||||
@SerialName("session_id") public val sessionId: String,
|
||||
@SerialName("turn_index") public val turnIndex: Int,
|
||||
public val events: List<TurnEvent>,
|
||||
@SerialName("stop_reason") public val stopReason: String,
|
||||
@SerialName("duration_ms") public val durationMs: Long,
|
||||
) {
|
||||
/**
|
||||
* Concatenates the [TurnEvent.content] of every `type == "text"` event
|
||||
* in [events] order. Skips events whose content is `null`.
|
||||
*/
|
||||
public fun text(): String =
|
||||
events.asSequence()
|
||||
.filter { it.type == "text" }
|
||||
.mapNotNull { it.content }
|
||||
.joinToString("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Server's view of one session — returned by `GET /sessions/{id}` and as
|
||||
* one row of `GET /sessions`.
|
||||
*
|
||||
* @property sessionId server session id.
|
||||
* @property agent agent slug this session is bound to.
|
||||
* @property appName name of the app token that owns the session; may be
|
||||
* absent on responses where the server elects not to
|
||||
* include it.
|
||||
* @property createdAt unix-second creation timestamp.
|
||||
* @property lastTurnAt unix-second timestamp of the most recent turn, or
|
||||
* `null` if no turn has been sent yet.
|
||||
* @property turnCount number of turns sent so far.
|
||||
* @property closedAt unix-second close timestamp, or `null` if the
|
||||
* session is still open.
|
||||
*/
|
||||
@Serializable
|
||||
public data class SessionState(
|
||||
@SerialName("session_id") public val sessionId: String,
|
||||
public val agent: String,
|
||||
@SerialName("app_name") public val appName: String? = null,
|
||||
@SerialName("created_at") public val createdAt: Long,
|
||||
@SerialName("last_turn_at") public val lastTurnAt: Long? = null,
|
||||
@SerialName("turn_count") public val turnCount: Int,
|
||||
@SerialName("closed_at") public val closedAt: Long? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for `GET /sessions`. */
|
||||
@Serializable
|
||||
internal data class SessionList(
|
||||
val sessions: List<SessionState> = emptyList(),
|
||||
)
|
||||
|
||||
/** Wrapper for `POST /sessions`. */
|
||||
@Serializable
|
||||
internal data class SessionCreateBody(
|
||||
val agent: String? = null,
|
||||
val meta: JsonElement? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for `POST /sessions` response. */
|
||||
@Serializable
|
||||
internal data class SessionCreateResponse(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
val agent: String = "claude",
|
||||
@SerialName("created_at") val createdAt: Long = 0,
|
||||
)
|
||||
|
||||
/** Wire body for `POST /sessions/{id}/turn`. */
|
||||
@Serializable
|
||||
internal data class SessionTurnBody(
|
||||
val prompt: String,
|
||||
val files: List<String>? = null,
|
||||
@SerialName("timeout_secs") val timeoutSecs: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle to a server-side multi-turn session.
|
||||
*
|
||||
* Construct via [ForgeClient.createSession] (or grab one from inside the
|
||||
* [ForgeClient.session] block helper, which auto-closes on exit).
|
||||
*
|
||||
* Implements [Closeable], so it composes with `use { }`:
|
||||
*
|
||||
* ```kotlin
|
||||
* forge.createSession().use { s ->
|
||||
* val r = s.turn("hello")
|
||||
* println(r.text())
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* ### Thread safety
|
||||
*
|
||||
* [turn] is `suspend` and serialises concurrent calls on the same instance
|
||||
* via an internal [Mutex] — two coroutines calling `turn()` at once will
|
||||
* be ordered (FIFO with respect to mutex acquisition), so the server only
|
||||
* ever sees one in-flight turn per session at a time.
|
||||
*
|
||||
* [close] is idempotent: the first invocation flips an [AtomicBoolean] and
|
||||
* issues `DELETE /sessions/{id}`; subsequent invocations short-circuit
|
||||
* without an HTTP round-trip. If the DELETE fails on the first attempt the
|
||||
* flag is rolled back so the caller can retry — a transient transport blip
|
||||
* shouldn't leave the client thinking a still-live session was reaped.
|
||||
*
|
||||
* ### Bearer-leak hardening
|
||||
*
|
||||
* The embedded [client] reference is intentionally omitted from
|
||||
* [toString] — including it would risk leaking the bearer token into logs
|
||||
* (see [AppToken.toString] for the parallel pattern).
|
||||
*
|
||||
* @property id server-assigned session id (also acpx's id).
|
||||
* @property agent agent slug this session is bound to.
|
||||
* @property createdAt unix-second creation timestamp recorded by the server.
|
||||
*/
|
||||
public class Session internal constructor(
|
||||
private val client: ForgeClient,
|
||||
public val id: String,
|
||||
public val agent: String,
|
||||
public val createdAt: Long,
|
||||
) : Closeable {
|
||||
|
||||
private val closedFlag = AtomicBoolean(false)
|
||||
private val turnMutex = Mutex()
|
||||
|
||||
/**
|
||||
* `true` once [close] has succeeded at least once on this client. Note
|
||||
* this is the client's view: the server may have reaped the session
|
||||
* out from under us (TTL sweeper, manual revoke) without our knowing.
|
||||
*/
|
||||
public val isClosed: Boolean get() = closedFlag.get()
|
||||
|
||||
/**
|
||||
* Sends one turn on this session.
|
||||
*
|
||||
* Concurrent calls on the same [Session] instance are serialised via an
|
||||
* internal mutex — the second caller suspends until the first one
|
||||
* returns. The server is therefore never asked to handle two turns on
|
||||
* the same session at the same time.
|
||||
*
|
||||
* @param prompt prompt text. Must not be empty.
|
||||
* @param files optional list of `ff_…` file tokens to attach.
|
||||
* `null` or empty omits the field.
|
||||
* @param timeoutSecs optional per-turn subprocess budget in seconds.
|
||||
* Tracks the audit-fix [pattern](file://3c77ef5):
|
||||
* overrides the default HTTP request timeout so a
|
||||
* value larger than [ForgeOptions.defaultTimeout]
|
||||
* does not produce an HTTP-disconnect mid-turn.
|
||||
* @throws IllegalStateException if [isClosed] is `true`.
|
||||
* @throws IllegalArgumentException if [prompt] is empty.
|
||||
* @throws ForgeAuthException on HTTP 401/403.
|
||||
* @throws ForgeApiException on any other non-2xx response.
|
||||
* @throws ForgeTransportException on transport / decoding failure.
|
||||
*/
|
||||
public suspend fun turn(
|
||||
prompt: String,
|
||||
files: List<String>? = null,
|
||||
timeoutSecs: Int? = null,
|
||||
): TurnResult {
|
||||
check(!isClosed) { "session $id is closed" }
|
||||
require(prompt.isNotEmpty()) { "prompt must not be empty" }
|
||||
return turnMutex.withLock {
|
||||
client.sessionTurnInternal(id, prompt, files, timeoutSecs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current server-side state for this session — equivalent
|
||||
* to `forge.getSession(id)`.
|
||||
*/
|
||||
public suspend fun state(): SessionState = client.getSession(id)
|
||||
|
||||
/**
|
||||
* Closes the server-side session.
|
||||
*
|
||||
* Idempotent on the client: a second call short-circuits without an
|
||||
* HTTP round-trip via [AtomicBoolean.compareAndSet]. The server's
|
||||
* `DELETE /sessions/{id}` is itself idempotent (returns
|
||||
* `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.
|
||||
*
|
||||
* Implemented synchronously via [runBlocking] because [Closeable.close]
|
||||
* is not a `suspend` function — the body is one DELETE so the blocking
|
||||
* call is bounded by the configured request timeout.
|
||||
*/
|
||||
override fun close() {
|
||||
if (closedFlag.compareAndSet(false, true)) {
|
||||
try {
|
||||
runBlocking { client.sessionCloseInternal(id) }
|
||||
} catch (e: Throwable) {
|
||||
closedFlag.set(false) // allow retry on transient failure
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders this session **without** the embedded [client] reference,
|
||||
* which holds the bearer token. Same hazard pattern as
|
||||
* [AppToken.toString] — the standard data-class auto-`toString` would
|
||||
* print every component.
|
||||
*/
|
||||
override fun toString(): String =
|
||||
"Session(id=$id, agent=$agent, closed=${closedFlag.get()})"
|
||||
}
|
||||
576
clients/kotlin/src/test/kotlin/com/clawdforge/SessionTest.kt
Normal file
576
clients/kotlin/src/test/kotlin/com/clawdforge/SessionTest.kt
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
package com.clawdforge
|
||||
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.MockRequestHandleScope
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.engine.mock.respondError
|
||||
import io.ktor.client.request.HttpRequestData
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* v0.2 multi-turn / Session API tests. All interactions go through Ktor's
|
||||
* [MockEngine] — no real network.
|
||||
*/
|
||||
class SessionTest {
|
||||
|
||||
private val jsonContentType = headersOf(HttpHeaders.ContentType, "application/json")
|
||||
|
||||
/** Per-test request hit counts keyed by `"<METHOD> <path-pattern>"`. */
|
||||
private class Hits {
|
||||
private val map = mutableMapOf<String, AtomicInteger>()
|
||||
|
||||
operator fun get(key: String): Int = map[key]?.get() ?: 0
|
||||
|
||||
fun bump(key: String) {
|
||||
map.getOrPut(key) { AtomicInteger() }.incrementAndGet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun client(
|
||||
defaultTimeout: kotlin.time.Duration = 60.seconds,
|
||||
requestMargin: kotlin.time.Duration = 30.seconds,
|
||||
token: String = "cf_test",
|
||||
handler: suspend MockRequestHandleScope.(HttpRequestData) -> io.ktor.client.request.HttpResponseData,
|
||||
): ForgeClient {
|
||||
val engine = MockEngine { req -> handler(req) }
|
||||
return ForgeClient(
|
||||
baseUrl = "http://forge.test",
|
||||
token = token,
|
||||
options = ForgeOptions(
|
||||
engine = engine,
|
||||
defaultTimeout = defaultTimeout,
|
||||
requestMargin = requestMargin,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up a minimal /sessions surface with hit counters keyed by
|
||||
* route pattern. Returns the [Hits] instance for assertions.
|
||||
*/
|
||||
private fun minimalSessionRoutes(
|
||||
sessionId: String = "sess_abc",
|
||||
): Pair<Hits, suspend MockRequestHandleScope.(HttpRequestData) -> io.ktor.client.request.HttpResponseData> {
|
||||
val hits = Hits()
|
||||
val turnIdx = AtomicInteger()
|
||||
val handler: suspend MockRequestHandleScope.(HttpRequestData) -> io.ktor.client.request.HttpResponseData =
|
||||
{ req ->
|
||||
val path = req.url.encodedPath
|
||||
when {
|
||||
req.method == HttpMethod.Post && path == "/sessions" -> {
|
||||
hits.bump("POST /sessions")
|
||||
respond(
|
||||
"""{"session_id":"$sessionId","agent":"claude","created_at":1700000000}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
req.method == HttpMethod.Get && path == "/sessions" -> {
|
||||
hits.bump("GET /sessions")
|
||||
respond("""{"sessions":[]}""", HttpStatusCode.OK, jsonContentType)
|
||||
}
|
||||
req.method == HttpMethod.Post && path.endsWith("/turn") -> {
|
||||
hits.bump("POST /sessions/{id}/turn")
|
||||
val idx = turnIdx.getAndIncrement()
|
||||
respond(
|
||||
"""{"ok":true,"session_id":"$sessionId","turn_index":$idx,""" +
|
||||
""""events":[{"type":"text","content":"hi"}],""" +
|
||||
""""stop_reason":"end_turn","duration_ms":42}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
req.method == HttpMethod.Delete && path.startsWith("/sessions/") -> {
|
||||
hits.bump("DELETE /sessions/{id}")
|
||||
respond("""{"ok":true}""", HttpStatusCode.OK, jsonContentType)
|
||||
}
|
||||
req.method == HttpMethod.Get && path.startsWith("/sessions/") -> {
|
||||
hits.bump("GET /sessions/{id}")
|
||||
respond(
|
||||
"""{"session_id":"$sessionId","agent":"claude","app_name":"app1",""" +
|
||||
""""created_at":1700000000,"last_turn_at":null,""" +
|
||||
""""turn_count":0,"closed_at":null}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
else -> respondError(HttpStatusCode.MethodNotAllowed)
|
||||
}
|
||||
}
|
||||
return hits to handler
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun sessionBlockHelperAutoCloses() = runTest {
|
||||
val (hits, handler) = minimalSessionRoutes("sess_auto")
|
||||
client(handler = handler).use { c ->
|
||||
c.session { s ->
|
||||
assertEquals("sess_auto", s.id)
|
||||
assertEquals("claude", s.agent)
|
||||
assertEquals(1700000000L, s.createdAt)
|
||||
assertFalse(s.isClosed)
|
||||
val r = s.turn("hi")
|
||||
assertTrue(r.ok)
|
||||
}
|
||||
}
|
||||
assertEquals(1, hits["POST /sessions"])
|
||||
assertEquals(1, hits["POST /sessions/{id}/turn"])
|
||||
assertEquals(1, hits["DELETE /sessions/{id}"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBlockHelperClosesOnException() = runTest {
|
||||
val (hits, handler) = minimalSessionRoutes("sess_throw")
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
client(handler = handler).use { c ->
|
||||
c.session<Nothing> { _ ->
|
||||
error("boom")
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("boom", ex.message)
|
||||
assertEquals(1, hits["POST /sessions"])
|
||||
assertEquals(
|
||||
1,
|
||||
hits["DELETE /sessions/{id}"],
|
||||
"DELETE must fire on exception unwind",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCloseIdempotent() = runTest {
|
||||
val (hits, handler) = minimalSessionRoutes("sess_idem")
|
||||
client(handler = handler).use { c ->
|
||||
val s = c.createSession()
|
||||
s.close()
|
||||
assertTrue(s.isClosed)
|
||||
s.close() // short-circuit
|
||||
s.close() // and again
|
||||
assertEquals(
|
||||
1,
|
||||
hits["DELETE /sessions/{id}"],
|
||||
"DELETE must hit ONCE across multiple close() calls",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionTurnRoundTrip() = runTest {
|
||||
var sentBody: String? = null
|
||||
var sentPath: String? = null
|
||||
val c = client { req ->
|
||||
val path = req.url.encodedPath
|
||||
when {
|
||||
req.method == HttpMethod.Post && path == "/sessions" -> respond(
|
||||
"""{"session_id":"sess_rt","agent":"claude","created_at":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
req.method == HttpMethod.Post && path.endsWith("/turn") -> {
|
||||
sentPath = path
|
||||
sentBody = (req.body as io.ktor.http.content.TextContent).text
|
||||
respond(
|
||||
"""{"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}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
req.method == HttpMethod.Delete -> respond(
|
||||
"""{"ok":true}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
else -> respondError(HttpStatusCode.MethodNotAllowed)
|
||||
}
|
||||
}
|
||||
c.use { client ->
|
||||
client.session { s ->
|
||||
val r = s.turn(prompt = "Read README", files = listOf("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())
|
||||
}
|
||||
}
|
||||
assertNotNull(sentBody)
|
||||
assertContains(sentBody!!, "\"prompt\":\"Read README\"")
|
||||
assertContains(sentBody!!, "\"files\":[\"ff_abc\",\"ff_def\"]")
|
||||
assertEquals("/sessions/sess_rt/turn", sentPath)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionTurnAfterCloseThrows() = runTest {
|
||||
val (_, handler) = minimalSessionRoutes("sess_dead")
|
||||
client(handler = handler).use { c ->
|
||||
val s = c.createSession()
|
||||
s.close()
|
||||
val ex = assertFailsWith<IllegalStateException> {
|
||||
s.turn("anybody home?")
|
||||
}
|
||||
assertContains(ex.message ?: "", "closed")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionTurnHttpTimeoutHonorsPerCallTimeoutSecs() = runTest {
|
||||
// Audit-fix 3c77ef5 regression mirror: per-call timeout overrides
|
||||
// the default. Global floor: defaultTimeout=5s + requestMargin=1s = 6s.
|
||||
// Server delays 8s before responding; caller passes timeoutSecs=20.
|
||||
// Without the per-call override: HttpRequestTimeoutException at 6s.
|
||||
// With the override: succeeds.
|
||||
val c = client(defaultTimeout = 5.seconds, requestMargin = 1.seconds) { req ->
|
||||
val path = req.url.encodedPath
|
||||
when {
|
||||
req.method == HttpMethod.Post && path == "/sessions" -> respond(
|
||||
"""{"session_id":"sess_to","agent":"claude","created_at":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
req.method == HttpMethod.Post && path.endsWith("/turn") -> {
|
||||
delay(8.seconds)
|
||||
respond(
|
||||
"""{"ok":true,"session_id":"sess_to","turn_index":0,""" +
|
||||
""""events":[{"type":"text","content":"done"}],""" +
|
||||
""""stop_reason":"end_turn","duration_ms":8000}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
req.method == HttpMethod.Delete -> respond(
|
||||
"""{"ok":true}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
else -> respondError(HttpStatusCode.MethodNotAllowed)
|
||||
}
|
||||
}
|
||||
c.use { client ->
|
||||
client.session { s ->
|
||||
val r = s.turn("long-running", timeoutSecs = 20)
|
||||
assertEquals("done", r.text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun turnResultTextConcatenates() {
|
||||
val r = TurnResult(
|
||||
ok = true,
|
||||
sessionId = "s",
|
||||
turnIndex = 0,
|
||||
events = listOf(
|
||||
TurnEvent(type = "thinking", content = "ignored"),
|
||||
TurnEvent(type = "text", content = "alpha "),
|
||||
TurnEvent(
|
||||
type = "tool_call",
|
||||
name = "Read",
|
||||
args = buildJsonObject { put("p", "x") },
|
||||
result = JsonPrimitive("result"),
|
||||
),
|
||||
TurnEvent(type = "text", content = "beta"),
|
||||
TurnEvent(type = "text", content = null), // skipped
|
||||
),
|
||||
stopReason = "end_turn",
|
||||
durationMs = 10L,
|
||||
)
|
||||
assertEquals("alpha beta", r.text())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions() = runTest {
|
||||
val c = client { req ->
|
||||
assertEquals(HttpMethod.Get, req.method)
|
||||
assertEquals("/sessions", req.url.encodedPath)
|
||||
respond(
|
||||
"""{"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}""" +
|
||||
"""]}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
c.use {
|
||||
val ss = it.listSessions()
|
||||
assertEquals(2, ss.size)
|
||||
assertEquals("a", ss[0].sessionId)
|
||||
assertEquals("app1", ss[0].appName)
|
||||
assertEquals(150L, ss[0].lastTurnAt)
|
||||
assertNull(ss[0].closedAt)
|
||||
assertEquals(3, ss[0].turnCount)
|
||||
assertEquals("b", ss[1].sessionId)
|
||||
assertNull(ss[1].lastTurnAt)
|
||||
assertEquals(250L, ss[1].closedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getSession() = runTest {
|
||||
val c = client { req ->
|
||||
assertEquals(HttpMethod.Get, req.method)
|
||||
assertEquals("/sessions/sess_g", req.url.encodedPath)
|
||||
respond(
|
||||
"""{"session_id":"sess_g","agent":"claude","app_name":"app1",""" +
|
||||
""""created_at":100,"last_turn_at":120,"turn_count":1,""" +
|
||||
""""closed_at":null}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
c.use {
|
||||
val st = it.getSession("sess_g")
|
||||
assertEquals("sess_g", st.sessionId)
|
||||
assertEquals("claude", st.agent)
|
||||
assertEquals("app1", st.appName)
|
||||
assertEquals(100L, st.createdAt)
|
||||
assertEquals(120L, st.lastTurnAt)
|
||||
assertEquals(1, st.turnCount)
|
||||
assertNull(st.closedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crossTokenIs404() = runTest {
|
||||
val c = client(token = "cf_other") { req ->
|
||||
assertEquals(HttpMethod.Get, req.method)
|
||||
respondError(HttpStatusCode.NotFound, """{"detail":"no such session"}""")
|
||||
}
|
||||
c.use {
|
||||
val ex = assertFailsWith<ForgeApiException> {
|
||||
it.getSession("sess_owned_by_a")
|
||||
}
|
||||
assertEquals(404, ex.statusCode)
|
||||
assertContains(ex.body, "no such session")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionToStringDoesNotLeakToken() = runTest {
|
||||
val (_, handler) = minimalSessionRoutes("sess_redact")
|
||||
val c = ForgeClient(
|
||||
baseUrl = "http://forge.test",
|
||||
token = "cf_super_secret_do_not_log",
|
||||
options = ForgeOptions(engine = MockEngine { req -> handler(req) }),
|
||||
)
|
||||
c.use { client ->
|
||||
client.session { s ->
|
||||
val repr = s.toString()
|
||||
assertFalse(
|
||||
repr.contains("cf_super_secret_do_not_log"),
|
||||
"Session.toString() must not include the bearer; got: $repr",
|
||||
)
|
||||
assertFalse(
|
||||
repr.contains("ForgeClient"),
|
||||
"Session.toString() should not embed the client; got: $repr",
|
||||
)
|
||||
assertFalse(
|
||||
repr.lowercase().contains("token"),
|
||||
"Session.toString() should not even hint at a token field; got: $repr",
|
||||
)
|
||||
assertContains(repr, "id=sess_redact")
|
||||
assertContains(repr, "closed=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun concurrentTurnsAreSerialized() = runTest {
|
||||
// Two coroutines call s.turn() concurrently. The mutex on Session
|
||||
// must serialize them so the server only ever sees one in-flight
|
||||
// turn at a time. We assert: max-observed in-flight == 1, and both
|
||||
// turns complete.
|
||||
val inFlight = AtomicInteger()
|
||||
val maxInFlight = AtomicInteger()
|
||||
val firstTurnReleased = CompletableDeferred<Unit>()
|
||||
val firstTurnEntered = CompletableDeferred<Unit>()
|
||||
val turnCounter = AtomicInteger()
|
||||
|
||||
val c = client { req ->
|
||||
val path = req.url.encodedPath
|
||||
when {
|
||||
req.method == HttpMethod.Post && path == "/sessions" -> respond(
|
||||
"""{"session_id":"sess_par","agent":"claude","created_at":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
req.method == HttpMethod.Post && path.endsWith("/turn") -> {
|
||||
val now = inFlight.incrementAndGet()
|
||||
maxInFlight.updateAndGet { kotlin.math.max(it, now) }
|
||||
val idx = turnCounter.getAndIncrement()
|
||||
if (idx == 0) {
|
||||
// First turn: signal entry, then block until released.
|
||||
firstTurnEntered.complete(Unit)
|
||||
firstTurnReleased.await()
|
||||
}
|
||||
inFlight.decrementAndGet()
|
||||
respond(
|
||||
"""{"ok":true,"session_id":"sess_par","turn_index":$idx,""" +
|
||||
""""events":[{"type":"text","content":"r$idx"}],""" +
|
||||
""""stop_reason":"end_turn","duration_ms":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
req.method == HttpMethod.Delete -> respond(
|
||||
"""{"ok":true}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
else -> respondError(HttpStatusCode.MethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
c.use { client ->
|
||||
client.session { s ->
|
||||
coroutineScope {
|
||||
val a = async { s.turn("first") }
|
||||
// Wait until the first turn has actually entered the
|
||||
// mock handler before kicking off the second; otherwise
|
||||
// we could race past the mutex acquisition itself.
|
||||
firstTurnEntered.await()
|
||||
val b = async { s.turn("second") }
|
||||
// Now release the first; the second is queued behind it.
|
||||
firstTurnReleased.complete(Unit)
|
||||
val results = awaitAll(a, b)
|
||||
assertEquals("r0", results[0].text())
|
||||
assertEquals("r1", results[1].text())
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(2, turnCounter.get())
|
||||
assertEquals(
|
||||
1,
|
||||
maxInFlight.get(),
|
||||
"concurrent turns on a Session must be serialized via the per-session mutex",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createSessionSendsAgentAndMeta() = runTest {
|
||||
var sentBody: String? = null
|
||||
val c = client { req ->
|
||||
assertEquals(HttpMethod.Post, req.method)
|
||||
assertEquals("/sessions", req.url.encodedPath)
|
||||
sentBody = (req.body as io.ktor.http.content.TextContent).text
|
||||
respond(
|
||||
"""{"session_id":"sess_meta","agent":"claude","created_at":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
c.use {
|
||||
val opts = SessionOptions(
|
||||
agent = "claude",
|
||||
meta = buildJsonObject { put("trace", "abc-123") },
|
||||
)
|
||||
val s = it.createSession(opts)
|
||||
assertEquals("sess_meta", s.id)
|
||||
}
|
||||
assertNotNull(sentBody)
|
||||
assertContains(sentBody!!, "\"agent\":\"claude\"")
|
||||
assertContains(sentBody!!, "\"trace\":\"abc-123\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closeOnTransientFailureClearsFlag() = runTest {
|
||||
val deletes = AtomicInteger()
|
||||
val c = client { req ->
|
||||
val path = req.url.encodedPath
|
||||
when {
|
||||
req.method == HttpMethod.Post && path == "/sessions" -> respond(
|
||||
"""{"session_id":"sess_retry","agent":"claude","created_at":1}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
req.method == HttpMethod.Delete -> {
|
||||
val n = deletes.incrementAndGet()
|
||||
if (n == 1) {
|
||||
respondError(HttpStatusCode.InternalServerError, """{"detail":"transient"}""")
|
||||
} else {
|
||||
respond("""{"ok":true}""", HttpStatusCode.OK, jsonContentType)
|
||||
}
|
||||
}
|
||||
else -> respondError(HttpStatusCode.MethodNotAllowed)
|
||||
}
|
||||
}
|
||||
c.use { client ->
|
||||
val s = client.createSession()
|
||||
assertFailsWith<ForgeApiException> { s.close() }
|
||||
assertFalse(s.isClosed, "transient failure must clear the closed flag for retry")
|
||||
// Retry succeeds.
|
||||
s.close()
|
||||
assertTrue(s.isClosed)
|
||||
assertEquals(2, deletes.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v01RunUnchanged() = runTest {
|
||||
// Regression: /run on a v0.2-equipped client must behave exactly
|
||||
// as v0.1, including snake_case body shape and JsonElement result.
|
||||
var captured: String? = null
|
||||
val c = client { req ->
|
||||
assertEquals(HttpMethod.Post, req.method)
|
||||
assertEquals("/run", req.url.encodedPath)
|
||||
captured = (req.body as io.ktor.http.content.TextContent).text
|
||||
respond(
|
||||
"""{"ok":true,"result":{"hello":"world"},""" +
|
||||
""""duration_ms":1234,"stop_reason":"end_turn"}""",
|
||||
HttpStatusCode.OK,
|
||||
jsonContentType,
|
||||
)
|
||||
}
|
||||
c.use {
|
||||
val r = it.run(RunRequest(prompt = "hi", model = "sonnet", timeoutSecs = 60))
|
||||
assertTrue(r.ok)
|
||||
assertEquals(1234L, r.durationMs)
|
||||
assertEquals("end_turn", r.stopReason)
|
||||
val obj: JsonObject = r.result.jsonObject
|
||||
assertEquals("world", (obj["hello"] as JsonPrimitive).content)
|
||||
}
|
||||
assertNotNull(captured)
|
||||
assertContains(captured!!, "\"timeout_secs\":60")
|
||||
assertContains(captured!!, "\"model\":\"sonnet\"")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue