new Response(1, new CallToolResult([new TextContent('done')]))); $expected = $handler->handle(self::request(), self::session()); $answer = $this->drive($handler, $expected, self::session()); $this->assertSame('done', self::text($answer)); } #[TestDox('an ask is sent to the client and the handler re-entered with the answer')] public function testOneRoundTrip(): void { $entries = 0; $handler = self::handler(static function (Request $request, SessionInterface $session) use (&$entries): Response { ++$entries; $answer = self::inputContext($session)?->elicitResult('who'); if (null === $answer) { return new Response(1, new InputRequiredResult(['who' => self::ask()])); } return new Response(1, new CallToolResult([new TextContent('Hello, '.($answer->content['name'] ?? '?').'!')])); }); $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']]), ]); $this->assertSame('Hello, Ada!', self::text($answer)); $this->assertSame(2, $entries, 're-entry is re-execution: the handler runs once per round'); } #[TestDox('a client that declines is an answer, and the handler decides what it means')] public function testDeclineReachesTheHandler(): void { $handler = self::handler(static function (Request $request, SessionInterface $session): Response { $answer = self::inputContext($session)?->elicitResult('who'); if (null === $answer) { return new Response(1, new InputRequiredResult(['who' => self::ask()])); } return new Response(1, new CallToolResult([new TextContent($answer->isDeclined() ? 'declined' : 'accepted')])); }); $answer = $this->drive($handler, $handler->handle(self::request(), $session = self::session()), $session, [ new Response(7, ['action' => 'decline']), ]); $this->assertSame('declined', self::text($answer)); } #[TestDox('a handler that never stops asking is failed rather than looped forever')] public function testRoundLimit(): void { $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); $answer = $this->drive( $handler, $handler->handle(self::request(), $session = self::session()), $session, array_fill(0, 3, new Response(7, ['action' => 'accept', 'content' => ['name' => 'Ada']])), new InputRequiredShim(maxRounds: 2), ); $this->assertInstanceOf(Error::class, $answer); $this->assertStringContainsString('more than 2 times', $answer->message); } #[TestDox('an ask the client never declared it could answer is refused, and nothing is sent')] public function testUndeclaredCapabilityIsRefused(): void { $handler = self::handler(static fn (): Response => new Response(1, new InputRequiredResult(['who' => self::ask()]))); // No `elicitation` in the session's declared capabilities. $session = self::session(declares: []); $answer = $this->drive($handler, $handler->handle(self::request(), $session), $session); $this->assertInstanceOf(Error::class, $answer); $this->assertSame(-32021, $answer->jsonSerialize()['error']['code']); } /** * Runs the shim the way the transport does: inside a fiber, answering each * suspension with the next queued client response. * * @param RequestHandlerInterface $handler * @param Response|Error $first * @param list|Error> $answers * * @return Response|Error */ private function drive( RequestHandlerInterface $handler, Response|Error $first, SessionInterface $session, array $answers = [], ?InputRequiredShim $shim = null, ): Response|Error { $shim ??= new InputRequiredShim(); $request = self::request(); $fiber = new \Fiber(static fn (): Response|Error => $shim->fulfill($first, $handler, $request, $session, null)); $suspended = $fiber->start(); while ($fiber->isSuspended()) { $this->assertIsArray($suspended); $this->assertSame('request', $suspended['type'], 'the shim only ever suspends to send a client request'); $this->assertNotEmpty($answers, 'the shim sent more requests than the test queued answers for'); $suspended = $fiber->resume(array_shift($answers)); } /** @var Response|Error $return */ $return = $fiber->getReturn(); return $return; } private static function ask(): ElicitRequest { return new ElicitRequest('Who?', new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name'])); } private static function request(): CallToolRequest { return (new CallToolRequest('greet', []))->withId(1); } /** * @param array $declares */ private static function session(array $declares = ['elicitation' => []]): SessionInterface { $session = new Session(new InMemorySessionStore()); $session->set('client_capabilities', $declares); return $session; } private static function inputContext(SessionInterface $session): ?InputContext { $context = $session->get(InputContext::class); return $context instanceof InputContext ? $context : null; } /** * @param \Closure(Request, SessionInterface): (Response|Error) $handle * * @return RequestHandlerInterface */ private static function handler(\Closure $handle): RequestHandlerInterface { return new class($handle) implements RequestHandlerInterface { public function __construct(private readonly \Closure $handle) { } public function supports(Request $request): bool { return true; } public function handle(Request $request, SessionInterface $session): Response|Error { return ($this->handle)($request, $session); } }; } /** * @param Response|Error $result */ private static function text(Response|Error $result): string { self::assertInstanceOf(Response::class, $result); self::assertInstanceOf(CallToolResult::class, $result->result); $first = $result->result->content[0] ?? null; self::assertInstanceOf(TextContent::class, $first); return $first->text; } }