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

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

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

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

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

View file

@ -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);
}
}