- 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
594 lines
22 KiB
PHP
594 lines
22 KiB
PHP
<?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);
|
|
}
|
|
}
|