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