diff --git a/clients/php/README.md b/clients/php/README.md index ca96d8f..63d473a 100644 --- a/clients/php/README.md +++ b/clients/php/README.md @@ -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 + 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. diff --git a/clients/php/src/Client.php b/clients/php/src/Client.php index 749c7b8..94284a6 100644 --- a/clients/php/src/Client.php +++ b/clients/php/src/Client.php @@ -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|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|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 + * + * @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|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 + * + * @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 -------------------------------------------------------- /** diff --git a/clients/php/src/Exception/ClosedSessionException.php b/clients/php/src/Exception/ClosedSessionException.php new file mode 100644 index 0000000..d0cfbcd --- /dev/null +++ b/clients/php/src/Exception/ClosedSessionException.php @@ -0,0 +1,21 @@ +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|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(), + ) + ); + } + } +} diff --git a/clients/php/src/SessionState.php b/clients/php/src/SessionState.php new file mode 100644 index 0000000..2966e98 --- /dev/null +++ b/clients/php/src/SessionState.php @@ -0,0 +1,71 @@ + $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 $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 : [], + ); + } +} diff --git a/clients/php/src/TurnEvent.php b/clients/php/src/TurnEvent.php new file mode 100644 index 0000000..e675144 --- /dev/null +++ b/clients/php/src/TurnEvent.php @@ -0,0 +1,58 @@ + $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, + ); + } +} diff --git a/clients/php/src/TurnResult.php b/clients/php/src/TurnResult.php new file mode 100644 index 0000000..7e9271c --- /dev/null +++ b/clients/php/src/TurnResult.php @@ -0,0 +1,97 @@ + $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 $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), + ); + } +} diff --git a/clients/php/tests/SessionTest.php b/clients/php/tests/SessionTest.php new file mode 100644 index 0000000..be74e01 --- /dev/null +++ b/clients/php/tests/SessionTest.php @@ -0,0 +1,594 @@ + $responses + * @param array}> $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); + } +}