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

- Session class with try/finally pattern + Client::session(callable) block helper
- Idempotent Session::close (in-memory + server idempotent)
- __destruct best-effort close fallback (errors logged via error_log, never raised)
- Client::createSession / ::session / ::listSessions / ::getSession
- TurnResult::text() helper concatenating text events
- TurnEvent value object preserves tool_call / thinking / text uniformly
- SessionState value object for list/get responses
- ClosedSessionException for use-after-close guard
- Session::__debugInfo redacts forge back-reference (no bearer in var_dump)
- tests/SessionTest.php: 15 tests covering create/close/turn/idempotency/exception/list/state/text/destruct/regression
- README "Multi-turn / Sessions (v0.2)" section + try/finally + block-form
  + __destruct + __debugInfo notes; exception tree updated

v0.1 surface unchanged — 44 existing ClientTest tests still green, +15 new
v0.2 tests in SessionTest. Client.php diff is purely additive (285 lines
appended, zero v0.1 lines touched).

Naming follows the existing PHP SDK convention: Clawdforge\Client (not the
spec's generic Sulkta\Clawdforge\Forge), with the new types living in the
same Clawdforge\ namespace and Exception\ClosedSessionException slotted
under the existing ForgeException tree.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
This commit is contained in:
Kayos 2026-04-29 06:50:42 -07:00
parent 33b9ed5e22
commit 42b1516bc3
8 changed files with 1479 additions and 1 deletions

View file

@ -81,6 +81,10 @@ if (is_array($result->result)) {
| `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. | | `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. |
| `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file from disk, return a `FileToken`. | | `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file from disk, return a `FileToken`. |
| `Client::uploadStream(StreamInterface $stream, string $filename, int $ttlSecs = 3600)` | `POST /files` | Stream-upload from a PSR-7 stream (use this when the bytes don't live at a path). | | `Client::uploadStream(StreamInterface $stream, string $filename, int $ttlSecs = 3600)` | `POST /files` | Stream-upload from a PSR-7 stream (use this when the bytes don't live at a path). |
| `Client::createSession(string $agent = 'claude', ?array $meta = null)` | `POST /sessions` | Create a multi-turn session, return a `Session`. **(v0.2)** |
| `Client::session(callable $fn, string $agent = 'claude', ?array $meta = null)` | block helper | Create + auto-close around a callable. **(v0.2)** |
| `Client::listSessions()` | `GET /sessions` | List sessions for the calling token. **(v0.2)** |
| `Client::getSession(string $id)` | `GET /sessions/{id}` | Fetch one session's state. **(v0.2)** |
| `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). | | `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). |
| `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). | | `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). |
| `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). | | `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). |
@ -114,6 +118,143 @@ function ingest(UploadedFileInterface $file, \Clawdforge\Client $forge): void {
} }
``` ```
### Multi-turn / Sessions (v0.2)
v0.2 adds a parallel `/sessions/*` surface for multi-turn conversations,
backed server-side by [acpx](https://github.com/openclaw/acpx). The single-
turn `Client::run()` path is unchanged — sessions are purely additive.
Use the **block-form helper** for the common case — it auto-closes the
session on exit, including when the callable throws:
```php
use Clawdforge\Client;
use Clawdforge\Session;
$forge = new Client('http://localhost:8800', 'cf_xxxxxxxx');
$reply = $forge->session(function (Session $s): string {
$r1 = $s->turn('Read README.md and summarize the auth flow.');
$r2 = $s->turn('Now look at the file-upload path. Where are the bounds checks?');
return $r2->text();
}, agent: 'claude');
echo $reply, "\n";
// Session is closed here — even if the callable threw.
```
For lifecycles that have to outlive a single function (e.g. a session
parked on a job-queue worker), use **manual lifecycle** with
`try/finally`:
```php
$s = $forge->createSession(agent: 'claude');
try {
$r = $s->turn('Read README.md and summarize');
echo $r->text();
$r = $s->turn('Now look at the auth flow');
echo $r->text();
} finally {
$s->close(); // idempotent — safe to call from finally
}
```
`Session::close()` is idempotent on both sides:
- Server: `DELETE /sessions/{id}` returns 200 with `already_closed: true`
on a re-close.
- Client: subsequent calls short-circuit without an HTTP round-trip.
If `close()` itself fails (transport blip, 5xx) the in-memory `closed`
flag is rolled back so callers can retry against the same `Session`
the server's idempotency means a retry cannot land you in a half-closed
state.
#### Destructor as best-effort fallback
If a `Session` goes out of scope without an explicit `close()` (forgotten
in a long function, or an exception that skipped the `finally`), the
destructor fires `DELETE /sessions/{id}` as a best-effort cleanup.
Failures during destructor close are swallowed and surfaced via PHP's
`error_log()` — raising from `__destruct` is undefined behaviour and
would mask the surrounding exception path.
The destructor is **not** a substitute for the block-form helper or
`try/finally`. PHP's GC is reference-counted and the precise timing of
destructor invocation depends on whether anything else still holds a
reference to the Session (closures, framework DI containers, test
mocks). Always prefer explicit close.
#### Listing and inspecting sessions
```php
foreach ($forge->listSessions() as $row) {
echo "{$row->sessionId} — turns={$row->turnCount} live={$row->live}\n";
}
$state = $forge->getSession($sessionId);
echo $state->turnCount, "\n";
```
`Client::listSessions()` is scoped to the bearer token making the call —
per-app isolation is enforced server-side. A `getSession()` against an
id that belongs to a different token surfaces as `ApiException` with
`statusCode === 404` (the server returns 404 not 403 to avoid leaking
session existence across token boundaries).
#### TurnResult and events
```php
final readonly class TurnResult {
public bool $ok;
public string $sessionId;
public int $turnIndex;
public array $events; // list<TurnEvent>
public string $stopReason;
public int $durationMs;
public function text(): string; // concatenates 'text' events' content
}
```
`TurnResult::text()` is sugar for the common "just give me the prose" case
— it concatenates the `content` of every `type === 'text'` event and
ignores `thinking` / `tool_call` events. For richer access, walk
`$result->events` directly:
```php
foreach ($result->events as $e) {
if ($e->type === 'tool_call') {
echo "tool: {$e->name} args=", json_encode($e->args), "\n";
}
}
```
#### Token redaction in dumps
`Session::__debugInfo()` returns only `id`, `agent`, `closed`,
`createdAt` — the parent `Client` back-reference (which holds the
bearer) is intentionally omitted so `var_dump($s)`, `print_r($s)`,
framework error reflectors, and DI-container introspection cannot leak
the token via the back-reference. Same redaction discipline as
`Client::__debugInfo()` itself.
#### Exceptions
```
Clawdforge\Exception\ForgeException
├── Clawdforge\Exception\ApiException
│ └── Clawdforge\Exception\AuthException
├── Clawdforge\Exception\TransportException
├── Clawdforge\Exception\MalformedResponseException
└── Clawdforge\Exception\ClosedSessionException (v0.2)
```
`ClosedSessionException` is raised by `Session::turn()` when the caller
holds a stale reference to a session this client has already closed.
The client never makes the HTTP call in that case — it's a guard
against use-after-close, not a server response.
### Naming convention ### Naming convention
PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside `RunRequest::toWire()` and the response object factories. PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside `RunRequest::toWire()` and the response object factories.
@ -126,6 +267,15 @@ PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversio
| `RunResult::$durationMs` | `duration_ms` | | `RunResult::$durationMs` | `duration_ms` |
| `RunResult::$stopReason` | `stop_reason` | | `RunResult::$stopReason` | `stop_reason` |
| `AppToken::$ipCidrs` | `ip_cidrs` | | `AppToken::$ipCidrs` | `ip_cidrs` |
| `Session::$id` | `session_id` |
| `Session::$createdAt` | `created_at` |
| `TurnResult::$turnIndex` | `turn_index` |
| `TurnResult::$durationMs` | `duration_ms` |
| `TurnResult::$stopReason` | `stop_reason` |
| `SessionState::$appName` | `app_name` |
| `SessionState::$lastTurnAt` | `last_turn_at` |
| `SessionState::$turnCount` | `turn_count` |
| `SessionState::$closedAt` | `closed_at` |
### Exceptions ### Exceptions
@ -134,7 +284,8 @@ Clawdforge\Exception\ForgeException (abstract base)
├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded) ├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded)
│ └── Clawdforge\Exception\AuthException (401/403) │ └── Clawdforge\Exception\AuthException (401/403)
├── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS) ├── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS)
└── Clawdforge\Exception\MalformedResponseException (2xx with a body the SDK couldn't parse) ├── Clawdforge\Exception\MalformedResponseException (2xx with a body the SDK couldn't parse)
└── Clawdforge\Exception\ClosedSessionException (v0.2 — Session used after close())
``` ```
A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish. A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish.

View file

@ -48,6 +48,8 @@ final class Client
public const HTTP_TIMEOUT_MARGIN_SECS = 30; public const HTTP_TIMEOUT_MARGIN_SECS = 30;
public const HEALTHZ_TIMEOUT_SECS = 10; public const HEALTHZ_TIMEOUT_SECS = 10;
public const ADMIN_TIMEOUT_SECS = 10; public const ADMIN_TIMEOUT_SECS = 10;
/** Timeout for /sessions create/list/get/close — short, no subprocess work. */
public const SESSION_ADMIN_TIMEOUT_SECS = 15;
private readonly string $baseUrl; private readonly string $baseUrl;
private readonly ClientInterface $http; private readonly ClientInterface $http;
@ -334,6 +336,289 @@ final class Client
); );
} }
// --- /sessions (v0.2) -------------------------------------------------
//
// The session surface is purely additive on top of v0.1. v0.1 callers
// never touch /sessions and never see Session/TurnResult. The block
// form `Client::session(callable)` is the preferred entry point — it
// auto-closes on exit (including on exceptions) and matches the
// cross-language idiom in the v0.2 spec.
/**
* `POST /sessions` create a multi-turn session.
*
* Manual lifecycle. Caller is responsible for `$s->close()` usually
* via `try/finally`. For most use cases, prefer the block form
* {@see self::session()} which auto-closes.
*
* @param string $agent which agent the session binds to (defaults to `'claude'`)
* @param array<string, mixed>|null $meta optional caller metadata persisted server-side in the session ledger
*
* @throws ApiException server returned 4xx/5xx
* @throws AuthException 401/403
* @throws TransportException connection-level failure
* @throws MalformedResponseException 200 with a body that isn't a JSON object
*/
public function createSession(string $agent = 'claude', ?array $meta = null): Session
{
$body = ['agent' => $agent];
if ($meta !== null) {
$body['meta'] = $meta;
}
// /sessions create is bounded by acpx session-create handshake —
// use the run-style budget so we don't bail mid-handshake.
$httpTimeout = $this->defaultTimeoutSecs + $this->httpTimeoutMargin;
$resp = $this->send('POST', '/sessions', json: $body, timeoutSecs: $httpTimeout);
$decoded = $this->decode($resp);
$this->ensureSuccess($resp, $decoded);
if (!is_array($decoded) || !isset($decoded['session_id']) || !is_string($decoded['session_id'])) {
throw new MalformedResponseException(
'unexpected /sessions response (missing session_id)',
$resp->getStatusCode(),
(string) $resp->getBody(),
);
}
$createdAt = $decoded['created_at'] ?? 0;
return new Session(
$this,
$decoded['session_id'],
isset($decoded['agent']) && is_string($decoded['agent']) ? $decoded['agent'] : $agent,
is_numeric($createdAt) ? (int) $createdAt : 0,
);
}
/**
* Block-form helper: create a Session, hand it to `$fn`, auto-close.
*
* Preferred form. Auto-closes the Session on exit including when
* `$fn` throws, in which case the exception is rethrown after the
* close fires. Close failures during teardown are swallowed so they
* cannot mask a real exception unwinding out of `$fn`.
*
* $forge->session(function (Session $s) {
* $r = $s->turn('hello');
* echo $r->text();
* }, agent: 'claude');
*
* @template T
*
* @param callable(Session): T $fn the body to execute with the live session
* @param string $agent agent for the underlying createSession call
* @param array<string, mixed>|null $meta optional metadata for the session
*
* @return T whatever `$fn` returned
*
* @throws \Throwable rethrows any exception from `$fn` after teardown
*/
public function session(callable $fn, string $agent = 'claude', ?array $meta = null): mixed
{
$s = $this->createSession($agent, $meta);
try {
return $fn($s);
} finally {
try {
$s->close();
} catch (\Throwable $e) {
// Already at end of block — swallowing the close error
// here matters when $fn itself threw, because PHP would
// otherwise replace the original exception with this
// teardown one. Logging via error_log() so the close
// failure doesn't disappear silently in production.
error_log(
sprintf(
'clawdforge: session %s close failed during block-helper teardown: %s',
$s->id,
$e->getMessage(),
)
);
}
}
}
/**
* `GET /sessions` list sessions for the calling token.
*
* Server returns sessions newest-first, scoped to the bearer token
* making the call (per-app isolation enforced server-side).
*
* @return list<SessionState>
*
* @throws ApiException server returned 4xx/5xx
* @throws AuthException 401/403
* @throws TransportException connection-level failure
* @throws MalformedResponseException 200 with a shape we don't recognise
*/
public function listSessions(): array
{
$resp = $this->send('GET', '/sessions', timeoutSecs: self::SESSION_ADMIN_TIMEOUT_SECS);
$decoded = $this->decode($resp);
$this->ensureSuccess($resp, $decoded);
if (!is_array($decoded) || !isset($decoded['sessions']) || !is_array($decoded['sessions'])) {
throw new MalformedResponseException(
'unexpected /sessions list response',
$resp->getStatusCode(),
(string) $resp->getBody(),
);
}
$out = [];
foreach ($decoded['sessions'] as $row) {
if (is_array($row)) {
$out[] = SessionState::fromResponse($row);
}
}
return $out;
}
/**
* `GET /sessions/{id}` fetch fresh state for one session.
*
* A 404 here means either the id is unknown OR it belongs to a
* different bearer token the server intentionally returns 404 not
* 403 to avoid leaking session existence across token boundaries.
*
* @throws InvalidArgumentException empty id
* @throws ApiException 4xx/5xx notably 404 (cross-token / unknown)
* @throws AuthException 401/403
* @throws TransportException connection-level failure
* @throws MalformedResponseException 200 with a body that isn't a JSON object
*/
public function getSession(string $id): SessionState
{
if ($id === '') {
throw new InvalidArgumentException('session id is required');
}
$resp = $this->send(
'GET',
'/sessions/' . rawurlencode($id),
timeoutSecs: self::SESSION_ADMIN_TIMEOUT_SECS,
);
$decoded = $this->decode($resp);
$this->ensureSuccess($resp, $decoded);
if (!is_array($decoded)) {
throw new MalformedResponseException(
'unexpected /sessions/{id} response (not a JSON object)',
$resp->getStatusCode(),
(string) $resp->getBody(),
);
}
return SessionState::fromResponse($decoded);
}
/**
* Internal: `POST /sessions/{id}/turn`.
*
* Don't call directly go through {@see Session::turn()}, which is
* the supported public entry point and adds the closed-session guard.
* Public so the Session class (a separate file in this namespace) can
* reach it; PHP has no package-private visibility.
*
* @internal
*
* @param list<string>|null $files
*/
public function sessionTurnInternal(
string $id,
string $prompt,
?array $files,
?int $timeoutSecs,
): TurnResult {
if ($id === '') {
throw new InvalidArgumentException('session id is required');
}
if ($prompt === '') {
throw new InvalidArgumentException('prompt must be non-empty');
}
if ($timeoutSecs !== null && ($timeoutSecs < 5 || $timeoutSecs > 600)) {
throw new InvalidArgumentException('timeoutSecs must be in range 5..600');
}
if ($files !== null) {
foreach ($files as $token) {
if (!is_string($token) || $token === '') {
throw new InvalidArgumentException('files must be a list of non-empty strings');
}
}
}
$body = ['prompt' => $prompt];
if ($files !== null && $files !== []) {
$body['files'] = array_values($files);
}
if ($timeoutSecs !== null) {
$body['timeout_secs'] = $timeoutSecs;
}
$effectiveRunTimeout = $timeoutSecs ?? $this->defaultTimeoutSecs;
$httpTimeout = $effectiveRunTimeout + $this->httpTimeoutMargin;
$resp = $this->send(
'POST',
'/sessions/' . rawurlencode($id) . '/turn',
json: $body,
timeoutSecs: $httpTimeout,
);
$decoded = $this->decode($resp);
$this->ensureSuccess($resp, $decoded);
if (!is_array($decoded)) {
throw new MalformedResponseException(
'unexpected /sessions/{id}/turn response (not a JSON object)',
$resp->getStatusCode(),
(string) $resp->getBody(),
);
}
return TurnResult::fromResponse($decoded);
}
/**
* Internal: `DELETE /sessions/{id}`. Idempotent server-side.
*
* Don't call directly go through {@see Session::close()}, which
* adds the in-memory short-circuit and rollback-on-failure. Public
* so the Session class can reach it; PHP has no package-private
* visibility.
*
* Returns the decoded server payload (`{ok: true, already_closed?: bool}`)
* for callers that want to distinguish first-close from re-close.
*
* @internal
*
* @return array<string, mixed>
*
* @throws ApiException server returned 4xx/5xx (NOT 404 on a stale id
* the server treats DELETE on a vanished id as a no-op success)
* @throws AuthException 401/403
* @throws TransportException connection-level failure
* @throws MalformedResponseException 200 with a body that isn't a JSON object
*/
public function sessionCloseInternal(string $id): array
{
if ($id === '') {
throw new InvalidArgumentException('session id is required');
}
$resp = $this->send(
'DELETE',
'/sessions/' . rawurlencode($id),
timeoutSecs: self::SESSION_ADMIN_TIMEOUT_SECS,
);
$decoded = $this->decode($resp);
$this->ensureSuccess($resp, $decoded);
if (!is_array($decoded)) {
throw new MalformedResponseException(
'unexpected /sessions/{id} delete response (not a JSON object)',
$resp->getStatusCode(),
(string) $resp->getBody(),
);
}
return $decoded;
}
// --- internals -------------------------------------------------------- // --- internals --------------------------------------------------------
/** /**

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Exception;
/**
* Raised when a caller attempts to use a {@see \Clawdforge\Session} after
* it has already been closed by this client.
*
* The server's DELETE /sessions/{id} is itself idempotent (returns 200 with
* `already_closed: true` on a re-close), so this exception is purely a
* client-side guard against latent references typically a Session that
* was passed out of a `try/finally` block and reused later.
*
* Sub-class of {@see ForgeException}, so a `catch (ForgeException $e)`
* still catches it alongside the rest of the SDK's failure modes.
*/
final class ClosedSessionException extends ForgeException
{
}

201
clients/php/src/Session.php Normal file
View file

@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
use Clawdforge\Exception\ClosedSessionException;
use Throwable;
/**
* Handle to a server-side multi-turn session (clawdforge v0.2).
*
* Construct via {@see Client::createSession()} (manual lifecycle) or via
* the block-form helper {@see Client::session()} which auto-closes on exit
* the preferred entry point for the common case.
*
* Manual lifecycle uses try/finally:
*
* $s = $forge->createSession(agent: 'claude');
* try {
* $r = $s->turn('hello');
* echo $r->text();
* } finally {
* $s->close();
* }
*
* `close()` is idempotent both because the server's
* `DELETE /sessions/{id}` returns 200 with `already_closed: true` on a
* re-close and because this client short-circuits after the first
* successful close. Safe to call from `finally` blocks without try/catch.
*
* The destructor is a best-effort fallback for the case where a Session
* goes out of scope without an explicit `close()` (forgotten in a long
* function, raised exception that skipped the finally, etc.). Failures
* during destructor close are swallowed and logged via `error_log()`
* raising from `__destruct` is undefined behaviour in PHP and would mask
* the original exception path.
*
* The bearer-bearing parent {@see Client} is intentionally omitted from
* {@see self::__debugInfo()} so `var_dump($s)`, `print_r($s)`, framework
* error reflectors, and DI-container introspection cannot leak the token
* via the back-reference.
*
* @api
*/
final class Session
{
private bool $closed = false;
public function __construct(
private readonly Client $forge,
public readonly string $id,
public readonly string $agent,
public readonly int $createdAt,
) {
}
/**
* Send one turn. Returns a {@see TurnResult} once the server has
* collected every event acpx produced for this turn.
*
* @param list<string>|null $files optional list of `ff_...` file tokens
* from {@see Client::uploadFile()}; resolved server-side to
* staged paths visible inside the session's cwd
* @param int|null $timeoutSecs per-turn subprocess timeout (5..600);
* falls back to the parent Client's `defaultTimeoutSecs`
*
* @throws ClosedSessionException the session was already closed by this
* client (latent reference / use-after-close)
* @throws \Clawdforge\Exception\ApiException server returned 4xx/5xx
* notably 404 (cross-token access or session vanished) and
* 410 (session is closed / no longer live in the server)
* @throws \Clawdforge\Exception\AuthException 401/403
* @throws \Clawdforge\Exception\TransportException connection-level failure
* @throws \Clawdforge\Exception\MalformedResponseException 200 with a
* body that doesn't match the documented turn-response shape
*/
public function turn(string $prompt, ?array $files = null, ?int $timeoutSecs = null): TurnResult
{
if ($this->closed) {
throw new ClosedSessionException(
"session {$this->id} is closed (close() was already called on this client)"
);
}
return $this->forge->sessionTurnInternal($this->id, $prompt, $files, $timeoutSecs);
}
/**
* Fetch fresh server-side state for this session.
*
* Round-trips to `GET /sessions/{id}` every call there is no client-
* side caching, so callers can watch `$state->turnCount` advance as
* background work happens.
*/
public function state(): SessionState
{
return $this->forge->getSession($this->id);
}
/**
* Close the session server-side. Idempotent.
*
* Safe to call from `finally` blocks without a try/catch wrapper. On
* the first call we fire `DELETE /sessions/{id}`, mark the session
* closed locally, and return. Subsequent calls short-circuit without
* an HTTP round-trip.
*
* If the underlying HTTP DELETE fails (transport blip, 5xx, etc.) the
* `$closed` flag is rolled back so a retry against this object is
* possible. The server's DELETE is itself idempotent so a follow-up
* call cannot land us in a half-closed state.
*
* @throws Throwable any exception from the underlying transport /
* API call typically {@see \Clawdforge\Exception\TransportException}
* or {@see \Clawdforge\Exception\ApiException}
*/
public function close(): void
{
if ($this->closed) {
return;
}
$this->closed = true;
try {
$this->forge->sessionCloseInternal($this->id);
} catch (Throwable $e) {
// Roll back so the caller can retry against this Session
// object on a transient failure.
$this->closed = false;
throw $e;
}
}
/**
* True after the first successful {@see self::close()}.
*
* Note: this is the *client's* view of close-state. The server may
* have GC'd the session out from under us (TTL sweeper) without us
* knowing a subsequent {@see self::turn()} would surface that as
* `ApiException(410)` or `ApiException(404)`.
*/
public function isClosed(): bool
{
return $this->closed;
}
/**
* Redact the parent Client back-reference from var_dump / print_r /
* error reporters.
*
* Without this override, PHP's default reflection would walk into
* `$forge` and surface the bearer token via the Client's properties
* (or even after Client::__debugInfo redacts via reflection-based
* dumpers that bypass __debugInfo on nested values). Returning a
* minimal array avoids the question entirely.
*
* @return array{id: string, agent: string, closed: bool, createdAt: int}
*/
public function __debugInfo(): array
{
return [
'id' => $this->id,
'agent' => $this->agent,
'closed' => $this->closed,
'createdAt' => $this->createdAt,
];
}
/**
* Best-effort fallback close on object destruction.
*
* Fires `DELETE /sessions/{id}` if the caller forgot an explicit
* `close()`. Errors are swallowed (logged via `error_log()`) raising
* from `__destruct` is undefined behaviour in PHP and would mask the
* surrounding exception path.
*
* NOT a substitute for the try/finally pattern: PHP's GC is reference-
* counted with circular-collector top-up, so the precise timing of
* destructor invocation depends on whether anything else still holds
* a reference to this Session (closures, framework DI containers,
* test mocks). Always prefer {@see Client::session()} block form or
* an explicit `try/finally`.
*/
public function __destruct()
{
if ($this->closed) {
return;
}
try {
$this->close();
} catch (Throwable $e) {
// PHP destructor: must not propagate.
error_log(
sprintf(
'clawdforge: session %s auto-close in __destruct failed: %s',
$this->id,
$e->getMessage(),
)
);
}
}
}

View file

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
/**
* Snapshot of server-side state for one session.
*
* Returned by {@see Client::getSession()} and (one row per session) by
* {@see Client::listSessions()}. Mirrors the columns clawdforge stores in
* the per-app sessions ledger, plus a derived `live` flag.
*
* `$rawMeta` exposes whatever caller-supplied metadata was attached at
* session-create time, decoded from the server's stored JSON. Empty array
* (not null) when no meta was set, so callers can `foreach` without a
* null-guard.
*/
final readonly class SessionState
{
/**
* @param array<string, mixed> $rawMeta
*/
public function __construct(
public string $sessionId,
public string $appName,
public string $agent,
public ?string $cwd,
public int $createdAt,
public ?int $lastTurnAt,
public int $turnCount,
public ?int $closedAt,
public bool $live,
public array $rawMeta,
) {
}
/**
* Build a SessionState from one decoded row of `/sessions` or the body
* of `/sessions/{id}`.
*
* Tolerant of missing fields the v0.2 server is the source of truth
* for the shape and we'd rather forward-evolve than break on a new
* column. Anything unknown gets dropped.
*
* @param array<string, mixed> $payload
*/
public static function fromResponse(array $payload): self
{
$meta = $payload['meta'] ?? [];
return new self(
sessionId: (string) ($payload['session_id'] ?? ''),
appName: (string) ($payload['app_name'] ?? ''),
agent: (string) ($payload['agent'] ?? ''),
cwd: isset($payload['cwd']) && is_string($payload['cwd']) ? $payload['cwd'] : null,
createdAt: (int) ($payload['created_at'] ?? 0),
lastTurnAt: isset($payload['last_turn_at']) && is_int($payload['last_turn_at'])
? $payload['last_turn_at']
: (isset($payload['last_turn_at']) && is_numeric($payload['last_turn_at'])
? (int) $payload['last_turn_at']
: null),
turnCount: (int) ($payload['turn_count'] ?? 0),
closedAt: isset($payload['closed_at']) && is_numeric($payload['closed_at'])
? (int) $payload['closed_at']
: null,
live: (bool) ($payload['live'] ?? ($payload['closed_at'] === null)),
rawMeta: is_array($meta) ? $meta : [],
);
}
}

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
/**
* One event emitted by the server during a `/sessions/{id}/turn` call.
*
* The server collects every event acpx produces for the turn (text chunks,
* tool calls, thinking blocks, ...) and returns them as a flat list once the
* turn finishes there is no streaming in v0.2. Each TurnEvent has a
* {@see self::$type} discriminator plus the type-specific fields:
*
* - `type === 'text'` `content` holds the text chunk; the rest are null
* - `type === 'thinking'` `content` holds the thinking block; rest null
* - `type === 'tool_call'` `name` is the tool name, `args` the input,
* `result` whatever the tool returned (string, array, null, ...)
*
* Unknown event types are still passed through verbatim so callers can
* forward-evolve without an SDK bump.
*/
final readonly class TurnEvent
{
public function __construct(
public string $type,
public ?string $content = null,
public ?string $name = null,
public ?array $args = null,
public mixed $result = null,
) {
}
/**
* Build a TurnEvent from one entry of the server's `events` array.
*
* Tolerant of missing fields only `type` is required, and even that
* defaults to `'unknown'` rather than raising, so a server-side
* vocabulary change can't crash the client mid-iteration.
*
* @param array<string, mixed> $payload single event dict from the wire
*/
public static function fromResponse(array $payload): self
{
$type = $payload['type'] ?? null;
$content = $payload['content'] ?? null;
$name = $payload['name'] ?? null;
$args = $payload['args'] ?? null;
return new self(
type: is_string($type) ? $type : 'unknown',
content: is_string($content) ? $content : null,
name: is_string($name) ? $name : null,
args: is_array($args) ? $args : null,
result: $payload['result'] ?? null,
);
}
}

View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Clawdforge;
use Clawdforge\Exception\MalformedResponseException;
/**
* Result of a successful `POST /sessions/{id}/turn`.
*
* `$events` is the structured event stream the server collected from acpx
* for this turn. Each entry is a {@see TurnEvent} with a `type`
* discriminator (`'text'`, `'thinking'`, `'tool_call'`, ...). Use
* {@see self::text()} to grab just the prose reply when you don't care
* about thinking / tool calls.
*
* `$ok` is always `true` for a TurnResult the server signals failure as a
* 4xx/5xx and {@see Client} raises {@see \Clawdforge\Exception\ApiException}
* before this object is built.
*/
final readonly class TurnResult
{
/**
* @param list<TurnEvent> $events
*/
public function __construct(
public bool $ok,
public string $sessionId,
public int $turnIndex,
public array $events,
public string $stopReason,
public int $durationMs,
) {
}
/**
* Concatenate every `type === 'text'` event's `content` field.
*
* 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.
*/
public function text(): string
{
$parts = [];
foreach ($this->events as $e) {
if ($e->type === 'text' && $e->content !== null) {
$parts[] = $e->content;
}
}
return implode('', $parts);
}
/**
* Build a TurnResult from the decoded `/sessions/{id}/turn` 200 body.
*
* @param array<string, mixed> $payload
*
* @throws MalformedResponseException the body parsed but does not match
* the server's documented turn-response shape
*/
public static function fromResponse(array $payload): self
{
if (!isset($payload['session_id']) || !is_string($payload['session_id'])) {
throw new MalformedResponseException(
'malformed /sessions/{id}/turn response: missing or non-string session_id',
200,
(string) json_encode($payload),
);
}
$eventsRaw = $payload['events'] ?? [];
if (!is_array($eventsRaw)) {
throw new MalformedResponseException(
'malformed /sessions/{id}/turn response: events is not a list',
200,
(string) json_encode($payload),
);
}
$events = [];
foreach ($eventsRaw as $row) {
if (is_array($row)) {
$events[] = TurnEvent::fromResponse($row);
}
}
$stopReason = $payload['stop_reason'] ?? '';
return new self(
ok: (bool) ($payload['ok'] ?? true),
sessionId: $payload['session_id'],
turnIndex: (int) ($payload['turn_index'] ?? 0),
events: $events,
stopReason: is_string($stopReason) ? $stopReason : '',
durationMs: (int) ($payload['duration_ms'] ?? 0),
);
}
}

View file

@ -0,0 +1,594 @@
<?php
declare(strict_types=1);
namespace Clawdforge\Tests;
use Clawdforge\Client;
use Clawdforge\Exception\ApiException;
use Clawdforge\Exception\ClosedSessionException;
use Clawdforge\Exception\ForgeException;
use Clawdforge\RunRequest;
use Clawdforge\Session;
use Clawdforge\SessionState;
use Clawdforge\TurnEvent;
use Clawdforge\TurnResult;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use RuntimeException;
/**
* Tests for the v0.2 multi-turn Session API.
*
* Mocks Guzzle via `MockHandler` same approach as the v0.1 ClientTest.
* Each test enqueues exactly the responses it expects to consume, so a
* spurious extra HTTP call shows up as `MockHandler::append` exhaustion
* rather than silently passing.
*/
final class SessionTest extends TestCase
{
private const BASE_URL = 'http://localhost:8800';
private const TOKEN = 'cf_test_v02_xxxxxxxxxxxxxxxxxxxxxxxxxx';
private const SESSION_ID = 'sess_abc_123';
/**
* @param list<Response|\Throwable> $responses
* @param array<int, array{0: \Psr\Http\Message\RequestInterface, 1: array<string, mixed>}> $history
*/
private function makeClient(array $responses, array &$history = []): Client
{
$mock = new MockHandler($responses);
$stack = HandlerStack::create($mock);
$stack->push(Middleware::history($history));
$guzzle = new GuzzleClient(['handler' => $stack, 'http_errors' => false]);
return new Client(self::BASE_URL, self::TOKEN, $guzzle);
}
/**
* Canned `POST /sessions` body keeps the per-test setup short.
*
* @return string JSON-encoded
*/
private static function sessionCreateBody(): string
{
return (string) json_encode([
'session_id' => self::SESSION_ID,
'agent' => 'claude',
'created_at' => 1700000000,
]);
}
/**
* Canned successful close body.
*/
private static function sessionCloseBody(bool $alreadyClosed = false): string
{
$payload = ['ok' => true];
if ($alreadyClosed) {
$payload['already_closed'] = true;
}
return (string) json_encode($payload);
}
/**
* @return string JSON-encoded turn body
*/
private static function turnBody(int $turnIndex = 1): string
{
return (string) json_encode([
'ok' => true,
'session_id' => self::SESSION_ID,
'turn_index' => $turnIndex,
'events' => [
['type' => 'thinking', 'content' => 'reasoning...'],
['type' => 'text', 'content' => 'Hello, '],
['type' => 'text', 'content' => 'world!'],
],
'stop_reason' => 'end_turn',
'duration_ms' => 1234,
]);
}
// ---------------------------------------------------------------------
// T1 — create + close round-trip
// ---------------------------------------------------------------------
public function testCreateAndCloseRoundTrip(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
], $history);
$s = $client->createSession(agent: 'claude');
self::assertInstanceOf(Session::class, $s);
self::assertSame(self::SESSION_ID, $s->id);
self::assertSame('claude', $s->agent);
self::assertSame(1700000000, $s->createdAt);
self::assertFalse($s->isClosed());
$s->close();
self::assertTrue($s->isClosed());
self::assertCount(2, $history);
/** @var \Psr\Http\Message\RequestInterface $req0 */
$req0 = $history[0]['request'];
self::assertSame('POST', $req0->getMethod());
self::assertSame('/sessions', $req0->getUri()->getPath());
self::assertSame('Bearer ' . self::TOKEN, $req0->getHeaderLine('Authorization'));
$sentCreate = json_decode((string) $req0->getBody(), true);
self::assertSame(['agent' => 'claude'], $sentCreate);
/** @var \Psr\Http\Message\RequestInterface $req1 */
$req1 = $history[1]['request'];
self::assertSame('DELETE', $req1->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID, $req1->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T2 — block-form auto-closes
// ---------------------------------------------------------------------
public function testSessionBlockFormAutoCloses(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::turnBody()),
new Response(200, [], self::sessionCloseBody()),
], $history);
$captured = null;
$ret = $client->session(function (Session $s) use (&$captured) {
$captured = $s;
$r = $s->turn('hello');
return $r->text();
}, agent: 'claude');
self::assertSame('Hello, world!', $ret);
self::assertNotNull($captured);
self::assertTrue($captured->isClosed(), 'session must be closed by block teardown');
self::assertCount(3, $history);
/** @var \Psr\Http\Message\RequestInterface $req2 */
$req2 = $history[2]['request'];
self::assertSame('DELETE', $req2->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID, $req2->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T3 — block-form closes on exception
// ---------------------------------------------------------------------
public function testSessionBlockClosesOnException(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
], $history);
try {
$client->session(function (Session $s): void {
throw new RuntimeException('boom inside block');
});
self::fail('expected the block-thrown exception to propagate');
} catch (RuntimeException $e) {
self::assertSame('boom inside block', $e->getMessage());
}
// DELETE still hit even though the block threw.
self::assertCount(2, $history);
/** @var \Psr\Http\Message\RequestInterface $req1 */
$req1 = $history[1]['request'];
self::assertSame('DELETE', $req1->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID, $req1->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T4 — close is idempotent
// ---------------------------------------------------------------------
public function testCloseIdempotent(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
// No second close response queued — if the client wrongly fires
// a second DELETE, MockHandler will throw "queue empty".
], $history);
$s = $client->createSession();
$s->close();
// Idempotent: second call must short-circuit, NOT hit the wire.
$s->close();
$s->close();
self::assertTrue($s->isClosed());
self::assertCount(2, $history, 'DELETE must fire exactly once');
}
// ---------------------------------------------------------------------
// T5 — turn round-trip
// ---------------------------------------------------------------------
public function testTurnRoundTrip(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::turnBody(turnIndex: 3)),
new Response(200, [], self::sessionCloseBody()),
], $history);
$s = $client->createSession();
try {
$r = $s->turn('Read README and summarize', files: ['ff_xyz'], timeoutSecs: 60);
} finally {
$s->close();
}
self::assertInstanceOf(TurnResult::class, $r);
self::assertTrue($r->ok);
self::assertSame(self::SESSION_ID, $r->sessionId);
self::assertSame(3, $r->turnIndex);
self::assertSame('end_turn', $r->stopReason);
self::assertSame(1234, $r->durationMs);
self::assertCount(3, $r->events);
self::assertContainsOnlyInstancesOf(TurnEvent::class, $r->events);
// Wire body shape — files / timeout_secs serialized snake_case.
/** @var \Psr\Http\Message\RequestInterface $reqTurn */
$reqTurn = $history[1]['request'];
self::assertSame('POST', $reqTurn->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID . '/turn', $reqTurn->getUri()->getPath());
$sent = json_decode((string) $reqTurn->getBody(), true);
self::assertSame([
'prompt' => 'Read README and summarize',
'files' => ['ff_xyz'],
'timeout_secs' => 60,
], $sent);
}
// ---------------------------------------------------------------------
// T6 — turn after close throws ClosedSessionException
// ---------------------------------------------------------------------
public function testTurnAfterCloseThrowsClosedSessionError(): void
{
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
// No third response — turn() must NOT make an HTTP call.
]);
$s = $client->createSession();
$s->close();
try {
$s->turn('would be turn');
self::fail('expected ClosedSessionException');
} catch (ClosedSessionException $e) {
self::assertStringContainsString(self::SESSION_ID, $e->getMessage());
self::assertInstanceOf(ForgeException::class, $e);
}
}
// ---------------------------------------------------------------------
// T7 — list sessions
// ---------------------------------------------------------------------
public function testListSessions(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], (string) json_encode([
'sessions' => [
[
'session_id' => 'sess_a',
'app_name' => 'cauldron',
'agent' => 'claude',
'cwd' => '/tmp/sess_a',
'created_at' => 100,
'last_turn_at' => 150,
'turn_count' => 2,
'closed_at' => null,
'live' => true,
'meta' => ['hint' => 'audit'],
],
[
'session_id' => 'sess_b',
'app_name' => 'cauldron',
'agent' => 'claude',
'cwd' => null,
'created_at' => 90,
'last_turn_at' => null,
'turn_count' => 0,
'closed_at' => 95,
'live' => false,
'meta' => null,
],
],
])),
], $history);
$list = $client->listSessions();
self::assertCount(2, $list);
self::assertContainsOnlyInstancesOf(SessionState::class, $list);
self::assertSame('sess_a', $list[0]->sessionId);
self::assertSame('cauldron', $list[0]->appName);
self::assertSame('/tmp/sess_a', $list[0]->cwd);
self::assertSame(2, $list[0]->turnCount);
self::assertNull($list[0]->closedAt);
self::assertTrue($list[0]->live);
self::assertSame(['hint' => 'audit'], $list[0]->rawMeta);
self::assertSame('sess_b', $list[1]->sessionId);
self::assertNull($list[1]->cwd);
self::assertSame(95, $list[1]->closedAt);
self::assertFalse($list[1]->live);
self::assertSame([], $list[1]->rawMeta);
/** @var \Psr\Http\Message\RequestInterface $req */
$req = $history[0]['request'];
self::assertSame('GET', $req->getMethod());
self::assertSame('/sessions', $req->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T8 — get one session
// ---------------------------------------------------------------------
public function testGetSession(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], (string) json_encode([
'session_id' => self::SESSION_ID,
'app_name' => 'cauldron',
'agent' => 'claude',
'cwd' => '/tmp/sess',
'created_at' => 1700000000,
'last_turn_at' => 1700000100,
'turn_count' => 5,
'closed_at' => null,
'live' => true,
'meta' => [],
])),
], $history);
$state = $client->getSession(self::SESSION_ID);
self::assertInstanceOf(SessionState::class, $state);
self::assertSame(self::SESSION_ID, $state->sessionId);
self::assertSame('cauldron', $state->appName);
self::assertSame(5, $state->turnCount);
self::assertSame(1700000100, $state->lastTurnAt);
self::assertTrue($state->live);
/** @var \Psr\Http\Message\RequestInterface $req */
$req = $history[0]['request'];
self::assertSame('GET', $req->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID, $req->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T9 — cross-token 404 surfaces as ApiException
// ---------------------------------------------------------------------
public function testCrossTokenIs404(): void
{
$client = $this->makeClient([
new Response(404, [], (string) json_encode(['detail' => 'no such session'])),
]);
try {
$client->getSession('sess_belongs_to_other_token');
self::fail('expected ApiException(404)');
} catch (ApiException $e) {
self::assertSame(404, $e->statusCode);
self::assertInstanceOf(ForgeException::class, $e);
// server intentionally does NOT return 403 — see spec:
// "avoid leaking session existence across token boundaries"
self::assertNotSame(403, $e->statusCode);
}
}
// ---------------------------------------------------------------------
// T10 — TurnResult::text() concatenates only text events
// ---------------------------------------------------------------------
public function testTurnResultTextConcatenates(): void
{
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], (string) json_encode([
'ok' => true,
'session_id' => self::SESSION_ID,
'turn_index' => 1,
'events' => [
['type' => 'thinking', 'content' => 'noise that should NOT show up'],
['type' => 'text', 'content' => 'Part A. '],
['type' => 'tool_call', 'name' => 'Read', 'args' => ['path' => '/x'], 'result' => 'inner'],
['type' => 'text', 'content' => 'Part B.'],
],
'stop_reason' => 'end_turn',
'duration_ms' => 100,
])),
new Response(200, [], self::sessionCloseBody()),
]);
$s = $client->createSession();
try {
$r = $s->turn('mix it up');
self::assertSame('Part A. Part B.', $r->text());
self::assertCount(4, $r->events);
// tool_call event preserved in full
$toolCalls = array_filter($r->events, fn (TurnEvent $e) => $e->type === 'tool_call');
self::assertCount(1, $toolCalls);
$tc = array_values($toolCalls)[0];
self::assertSame('Read', $tc->name);
self::assertSame(['path' => '/x'], $tc->args);
self::assertSame('inner', $tc->result);
} finally {
$s->close();
}
}
// ---------------------------------------------------------------------
// T11 — __debugInfo does not leak the bearer
// ---------------------------------------------------------------------
public function testSessionDebugInfoDoesNotLeakToken(): void
{
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
]);
$s = $client->createSession();
try {
// print_r honours __debugInfo() — what most framework error
// reflectors (Whoops, VarDumper, Ignition, ...) hit.
$printed = print_r($s, true);
self::assertStringNotContainsString(self::TOKEN, $printed);
// The forge back-reference must NOT leak. We check for any
// sign of bearer-bearing material rather than the literal
// word "forge", which appears benignly inside the
// namespace prefix `Clawdforge\Session` in the dump header.
self::assertStringNotContainsString('Bearer', $printed);
self::assertStringNotContainsString('baseUrl', $printed);
self::assertStringNotContainsString('[forge]', $printed);
// But the safe identity fields are still visible.
self::assertStringContainsString(self::SESSION_ID, $printed);
self::assertStringContainsString('claude', $printed);
ob_start();
var_dump($s);
$dumped = (string) ob_get_clean();
self::assertStringNotContainsString(self::TOKEN, $dumped);
$info = $s->__debugInfo();
self::assertSame(self::SESSION_ID, $info['id']);
self::assertSame('claude', $info['agent']);
self::assertFalse($info['closed']);
self::assertArrayNotHasKey('forge', $info);
} finally {
$s->close();
}
}
// ---------------------------------------------------------------------
// T12 — v0.1 /run is unchanged regression guard
// ---------------------------------------------------------------------
public function testV1RunUnchanged(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], (string) json_encode([
'ok' => true,
'result' => 'plain reply',
'duration_ms' => 50,
'stop_reason' => 'end_turn',
])),
], $history);
$r = $client->run(new RunRequest(prompt: 'hi'));
self::assertSame('plain reply', $r->result);
self::assertSame(50, $r->durationMs);
// Critically: /run hits POST /run, not /sessions — proves we did
// not accidentally rewrite v0.1 to go through the session path.
/** @var \Psr\Http\Message\RequestInterface $req */
$req = $history[0]['request'];
self::assertSame('POST', $req->getMethod());
self::assertSame('/run', $req->getUri()->getPath());
}
// ---------------------------------------------------------------------
// T13 — __destruct fires the auto-close fallback
// ---------------------------------------------------------------------
public function testDestructFiresAutoClose(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
], $history);
$s = $client->createSession();
// Drop the only reference. PHP's refcount-based destructor fires
// synchronously here because Session has no circular refs and the
// outer `$s` is the sole owner.
unset($s);
self::assertCount(2, $history, 'DELETE must fire from __destruct');
/** @var \Psr\Http\Message\RequestInterface $req */
$req = $history[1]['request'];
self::assertSame('DELETE', $req->getMethod());
self::assertSame('/sessions/' . self::SESSION_ID, $req->getUri()->getPath());
}
// ---------------------------------------------------------------------
// Extra coverage — close error rolls back so retry is possible
// ---------------------------------------------------------------------
public function testCloseFailureRollsBackClosedFlag(): void
{
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(500, [], (string) json_encode(['error' => 'transient'])),
new Response(200, [], self::sessionCloseBody()),
]);
$s = $client->createSession();
try {
$s->close();
self::fail('expected ApiException on first close');
} catch (ApiException $e) {
self::assertSame(500, $e->statusCode);
}
// The 500 left isClosed() == false so a retry is possible.
self::assertFalse($s->isClosed());
$s->close();
self::assertTrue($s->isClosed());
}
// ---------------------------------------------------------------------
// Extra coverage — meta is forwarded to the wire on createSession
// ---------------------------------------------------------------------
public function testCreateSessionForwardsMeta(): void
{
$history = [];
$client = $this->makeClient([
new Response(200, [], self::sessionCreateBody()),
new Response(200, [], self::sessionCloseBody()),
], $history);
$s = $client->createSession(agent: 'claude', meta: ['source' => 'cauldron-cli']);
$s->close();
/** @var \Psr\Http\Message\RequestInterface $req */
$req = $history[0]['request'];
$sent = json_decode((string) $req->getBody(), true);
self::assertSame([
'agent' => 'claude',
'meta' => ['source' => 'cauldron-cli'],
], $sent);
}
}