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:
parent
33b9ed5e22
commit
42b1516bc3
8 changed files with 1479 additions and 1 deletions
|
|
@ -81,6 +81,10 @@ if (is_array($result->result)) {
|
|||
| `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::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::listTokens()` | `GET /admin/tokens` | List known app tokens (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
|
||||
|
||||
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::$stopReason` | `stop_reason` |
|
||||
| `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
|
||||
|
||||
|
|
@ -134,7 +284,8 @@ Clawdforge\Exception\ForgeException (abstract base)
|
|||
├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded)
|
||||
│ └── Clawdforge\Exception\AuthException (401/403)
|
||||
├── 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.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ final class Client
|
|||
public const HTTP_TIMEOUT_MARGIN_SECS = 30;
|
||||
public const HEALTHZ_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 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 --------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
21
clients/php/src/Exception/ClosedSessionException.php
Normal file
21
clients/php/src/Exception/ClosedSessionException.php
Normal 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
201
clients/php/src/Session.php
Normal 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(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
71
clients/php/src/SessionState.php
Normal file
71
clients/php/src/SessionState.php
Normal 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 : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
clients/php/src/TurnEvent.php
Normal file
58
clients/php/src/TurnEvent.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
97
clients/php/src/TurnResult.php
Normal file
97
clients/php/src/TurnResult.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
594
clients/php/tests/SessionTest.php
Normal file
594
clients/php/tests/SessionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue