From 995ad654f42959ebca388afc754922ef2eb9463d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 14:42:44 +0000 Subject: [PATCH 01/55] test: move ToolCalls to tests/Support so the Symfony test tools can record into it ToolCalls no longer resets the laravel/mcp CustomCallTool; LaravelTestCase resets it next to ToolCalls, so the shared recorder names no library. --- .../Conformance/LaravelMcpConformanceTest.php | 2 +- tests/Integration/LaravelMcp/CallToolTest.php | 2 +- tests/Support/LaravelMcp/LaravelConformanceHarness.php | 1 + tests/Support/LaravelMcp/LaravelTestCase.php | 2 ++ tests/Support/LaravelMcp/Tools/BoomTool.php | 2 +- tests/Support/LaravelMcp/Tools/EchoTool.php | 2 +- tests/Support/LaravelMcp/Tools/FailingStreamTool.php | 2 +- tests/Support/LaravelMcp/Tools/NamedTool.php | 2 +- tests/Support/LaravelMcp/Tools/OwnTool.php | 2 +- tests/Support/LaravelMcp/Tools/RefusesTool.php | 2 +- tests/Support/LaravelMcp/Tools/RequestEchoTool.php | 2 +- tests/Support/LaravelMcp/Tools/StreamingTool.php | 2 +- tests/Support/LaravelMcp/Tools/StructuredTool.php | 2 +- tests/Support/{LaravelMcp => }/ToolCalls.php | 10 ++++++---- 14 files changed, 20 insertions(+), 15 deletions(-) rename tests/Support/{LaravelMcp => }/ToolCalls.php (71%) diff --git a/tests/Integration/Conformance/LaravelMcpConformanceTest.php b/tests/Integration/Conformance/LaravelMcpConformanceTest.php index fdcc815..226f508 100644 --- a/tests/Integration/Conformance/LaravelMcpConformanceTest.php +++ b/tests/Integration/Conformance/LaravelMcpConformanceTest.php @@ -13,7 +13,7 @@ use AgentCat\Tests\Support\LaravelMcp\LaravelTestCase; use AgentCat\Tests\Support\LaravelMcp\LaravelTrackingFixture; use AgentCat\Tests\Support\LaravelMcp\RequestEchoServer; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Exceptions\JsonRpcException; use PHPUnit\Framework\Attributes\Group; diff --git a/tests/Integration/LaravelMcp/CallToolTest.php b/tests/Integration/LaravelMcp/CallToolTest.php index 1593d6d..3b54588 100644 --- a/tests/Integration/LaravelMcp/CallToolTest.php +++ b/tests/Integration/LaravelMcp/CallToolTest.php @@ -24,9 +24,9 @@ use AgentCat\Tests\Support\LaravelMcp\RecordingMethod; use AgentCat\Tests\Support\LaravelMcp\RequestEchoServer; use AgentCat\Tests\Support\LaravelMcp\StreamingServer; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; use AgentCat\Tests\Support\LaravelMcp\Tools\EchoTool; use AgentCat\Tests\Support\LaravelMcp\Tools\StructuredTool; +use AgentCat\Tests\Support\ToolCalls; use AgentCat\ToolCall; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use Illuminate\Http\Request; diff --git a/tests/Support/LaravelMcp/LaravelConformanceHarness.php b/tests/Support/LaravelMcp/LaravelConformanceHarness.php index e0644ce..babeeb3 100644 --- a/tests/Support/LaravelMcp/LaravelConformanceHarness.php +++ b/tests/Support/LaravelMcp/LaravelConformanceHarness.php @@ -10,6 +10,7 @@ use AgentCat\Options; use AgentCat\Tests\Integration\Conformance\ConformanceHarness; use AgentCat\Tests\Support\ReusedId; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Container\Container; use Laravel\Mcp\Server; use PHPUnit\Framework\Assert; diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 11820df..633ab07 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -13,6 +13,7 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Foundation\Application; use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\TestCase; @@ -91,6 +92,7 @@ protected function defineEnvironment(mixed $app): void protected function tearDown(): void { ToolCalls::reset(); + CustomCallTool::reset(); TestMode::reset(); // also the configured switch that AgentCat's service provider sets BridgeConfig::reset(); // and the configured values Log::reset(); diff --git a/tests/Support/LaravelMcp/Tools/BoomTool.php b/tests/Support/LaravelMcp/Tools/BoomTool.php index 170752d..2e40811 100644 --- a/tests/Support/LaravelMcp/Tools/BoomTool.php +++ b/tests/Support/LaravelMcp/Tools/BoomTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Laravel\Mcp\Request; use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; diff --git a/tests/Support/LaravelMcp/Tools/EchoTool.php b/tests/Support/LaravelMcp/Tools/EchoTool.php index d7ee05d..306bf46 100644 --- a/tests/Support/LaravelMcp/Tools/EchoTool.php +++ b/tests/Support/LaravelMcp/Tools/EchoTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/tests/Support/LaravelMcp/Tools/FailingStreamTool.php b/tests/Support/LaravelMcp/Tools/FailingStreamTool.php index 0d9305a..a962dbb 100644 --- a/tests/Support/LaravelMcp/Tools/FailingStreamTool.php +++ b/tests/Support/LaravelMcp/Tools/FailingStreamTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; diff --git a/tests/Support/LaravelMcp/Tools/NamedTool.php b/tests/Support/LaravelMcp/Tools/NamedTool.php index dad4b76..dd59202 100644 --- a/tests/Support/LaravelMcp/Tools/NamedTool.php +++ b/tests/Support/LaravelMcp/Tools/NamedTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Tool; diff --git a/tests/Support/LaravelMcp/Tools/OwnTool.php b/tests/Support/LaravelMcp/Tools/OwnTool.php index c8e9fd9..db8daf2 100644 --- a/tests/Support/LaravelMcp/Tools/OwnTool.php +++ b/tests/Support/LaravelMcp/Tools/OwnTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/tests/Support/LaravelMcp/Tools/RefusesTool.php b/tests/Support/LaravelMcp/Tools/RefusesTool.php index 24c623a..e1f48de 100644 --- a/tests/Support/LaravelMcp/Tools/RefusesTool.php +++ b/tests/Support/LaravelMcp/Tools/RefusesTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; diff --git a/tests/Support/LaravelMcp/Tools/RequestEchoTool.php b/tests/Support/LaravelMcp/Tools/RequestEchoTool.php index 71d9237..5b8dcb3 100644 --- a/tests/Support/LaravelMcp/Tools/RequestEchoTool.php +++ b/tests/Support/LaravelMcp/Tools/RequestEchoTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Container\Container; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/tests/Support/LaravelMcp/Tools/StreamingTool.php b/tests/Support/LaravelMcp/Tools/StreamingTool.php index cf85a04..0d55414 100644 --- a/tests/Support/LaravelMcp/Tools/StreamingTool.php +++ b/tests/Support/LaravelMcp/Tools/StreamingTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\Server\Attributes\Description; diff --git a/tests/Support/LaravelMcp/Tools/StructuredTool.php b/tests/Support/LaravelMcp/Tools/StructuredTool.php index 1372792..81d0807 100644 --- a/tests/Support/LaravelMcp/Tools/StructuredTool.php +++ b/tests/Support/LaravelMcp/Tools/StructuredTool.php @@ -4,7 +4,7 @@ namespace AgentCat\Tests\Support\LaravelMcp\Tools; -use AgentCat\Tests\Support\LaravelMcp\ToolCalls; +use AgentCat\Tests\Support\ToolCalls; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/tests/Support/LaravelMcp/ToolCalls.php b/tests/Support/ToolCalls.php similarity index 71% rename from tests/Support/LaravelMcp/ToolCalls.php rename to tests/Support/ToolCalls.php index 58957fe..41cdf5b 100644 --- a/tests/Support/LaravelMcp/ToolCalls.php +++ b/tests/Support/ToolCalls.php @@ -2,9 +2,12 @@ declare(strict_types=1); -namespace AgentCat\Tests\Support\LaravelMcp; +namespace AgentCat\Tests\Support; -/** The arguments each test tool received, per tool name. The tools record here; `LaravelTestCase` resets it. */ +/** + * The arguments each test tool received, per tool name. The laravel/mcp and symfony/mcp-bundle test tools record here; + * their base test cases reset it. + */ final class ToolCalls { /** @var array>> */ @@ -22,10 +25,9 @@ public static function of(string $tool): array return self::$calls[$tool] ?? []; } - /** Forgets every call, `CustomCallTool`'s too. */ + /** Forgets every call. */ public static function reset(): void { self::$calls = []; - CustomCallTool::reset(); } } From 76e9077e95c4bd11aaf60399c763bd6b1bb8b6bf Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 14:42:52 +0000 Subject: [PATCH 02/55] build: add symfony/mcp-bundle as an optional dependency with a contract test of its builder services symfony/mcp-bundle and the Symfony parts it builds on are dev dependencies and a suggestion, never a requirement. BundleServicesTest pins the bundle internals AgentCat's bundle will read at compile time (the builder service ID and tag, the addRequestHandlers call with the tagged handlers, no configurator); PrivateMemberManifest::BUNDLE_SERVICES lists them. TestKernel, SymfonyTestCase and the five test tools are the shared Symfony test support: every kernel compiles into its own directory under the test HOME, logs to a MemoryLogger, and makes the mcp.server.* services public. On PHP 8.5 the lowest set raised deprecations inside symfony/config and symfony/dependency-injection 7.3.0 and in the 2.5.0 cache, event-dispatcher and translation contracts, so those packages get dev floors. symfony/error-handler 7.3 leaves its exception handler behind when FrameworkBundle boots; SymfonyTestCase restores it. The mcp/sdk main leg removes the bundle, which requires mcp/sdk ^0.8.1, runs PHPStan with phpstan.no-bundle.neon, and the Symfony tests skip. --- .gitattributes | 1 + .github/workflows/ci.yml | 4 +- composer-dependency-analyser.php | 6 +- composer.json | 13 +- phpstan.no-bundle.neon | 11 ++ tests/Architecture/LayeringTest.php | 31 ++++- tests/Contract/ManifestTest.php | 4 + tests/Contract/PrivateMemberManifest.php | 18 ++- tests/Contract/Symfony/BundleServicesTest.php | 60 ++++++++ tests/Support/Symfony/DefinitionsProbe.php | 42 ++++++ tests/Support/Symfony/SymfonyTestCase.php | 127 +++++++++++++++++ tests/Support/Symfony/TestKernel.php | 129 ++++++++++++++++++ tests/Support/Symfony/Tools/BoomTool.php | 20 +++ tests/Support/Symfony/Tools/EchoTool.php | 20 +++ tests/Support/Symfony/Tools/OwnTool.php | 22 +++ tests/Support/Symfony/Tools/RefusesTool.php | 22 +++ .../Support/Symfony/Tools/StructuredTool.php | 22 +++ tests/bin/mcp-sdk-main.sh | 4 +- 18 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 phpstan.no-bundle.neon create mode 100644 tests/Contract/Symfony/BundleServicesTest.php create mode 100644 tests/Support/Symfony/DefinitionsProbe.php create mode 100644 tests/Support/Symfony/SymfonyTestCase.php create mode 100644 tests/Support/Symfony/TestKernel.php create mode 100644 tests/Support/Symfony/Tools/BoomTool.php create mode 100644 tests/Support/Symfony/Tools/EchoTool.php create mode 100644 tests/Support/Symfony/Tools/OwnTool.php create mode 100644 tests/Support/Symfony/Tools/RefusesTool.php create mode 100644 tests/Support/Symfony/Tools/StructuredTool.php diff --git a/.gitattributes b/.gitattributes index 366cc9f..e244b02 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,7 @@ /.gitignore export-ignore /.editorconfig export-ignore /phpstan.neon.dist export-ignore +/phpstan.no-bundle.neon export-ignore /.php-cs-fixer.dist.php export-ignore /rector.php export-ignore /phpunit.xml.dist export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bbbabe..ab9792f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,9 +146,11 @@ jobs: - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 with: dependency-versions: highest + - name: Remove symfony/mcp-bundle, which requires mcp/sdk ^0.8.1 + run: composer remove --dev --no-update symfony/mcp-bundle - name: Install mcp/sdk main at the commit this release was verified against run: composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" - - run: vendor/bin/phpstan analyse --no-progress --error-format=github + - run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E all-green: diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 53e13a6..a4a69b1 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -31,4 +31,8 @@ // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. - ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]); + ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\Routing arrives with symfony/framework-bundle and symfony/mcp-bundle; the test kernel imports the bundle's MCP routes with it. + ->ignoreErrorsOnPackageAndPath('symfony/routing', __DIR__ . '/tests/Support/Symfony/TestKernel.php', [ErrorType::SHADOW_DEPENDENCY]) + // The test kernel registers AgentCat's Symfony bundle, whose class src/Symfony/AgentCatBundle.php is not written yet. Remove this line with it. + ->ignoreUnknownClasses(['AgentCat\Symfony\AgentCatBundle']); diff --git a/composer.json b/composer.json index bac6b5f..9cb7365 100644 --- a/composer.json +++ b/composer.json @@ -57,10 +57,20 @@ "rector/rector": "2.7.0", "shipmonk/composer-dependency-analyser": "^1.8.4", "spaze/phpstan-disallowed-calls": "^4.15", + "symfony/cache-contracts": "^2.5.3 || ^3.4.2", + "symfony/config": "^7.3.4 || ^8.0", + "symfony/dependency-injection": "^7.3.4 || ^8.0", + "symfony/event-dispatcher-contracts": "^2.5.3 || ^3.4.2", + "symfony/framework-bundle": "^7.3 || ^8.0", + "symfony/http-foundation": "^7.3 || ^8.0", + "symfony/http-kernel": "^7.3 || ^8.0", + "symfony/mcp-bundle": "^0.13 || ^0.14", "symfony/polyfill-uuid": "^1.20", "symfony/process": "^7.4 || ^8.1", "symfony/translation": "^6.4.3 || ^7.0.3 || ^8.0", + "symfony/translation-contracts": "^2.5.3 || ^3.4.2", "symfony/uid": "^5.4.35 || ^6.4.3 || ^7.3 || ^8.0", + "symfony/yaml": "^7.3 || ^8.0", "tomasvotruba/type-coverage": "^2.5.1" }, "conflict": { @@ -71,7 +81,8 @@ "ext-pcntl": "Delivers the queued events when a CLI server (an MCP stdio server) gets SIGTERM or SIGINT", "ext-posix": "Ends the process with the signal after that delivery, and finds the home directory for agentcat.log when HOME is not set", "laravel/mcp": "Tracks every laravel/mcp server of a Laravel application, through the auto-discovered service provider", - "mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)" + "mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)", + "symfony/mcp-bundle": "Tracks every symfony/mcp-bundle server through AgentCat\\Symfony\\AgentCatBundle" }, "minimum-stability": "stable", "autoload": { diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon new file mode 100644 index 0000000..4104ec5 --- /dev/null +++ b/phpstan.no-bundle.neon @@ -0,0 +1,11 @@ +# PHPStan for the mcp/sdk main leg, which removes symfony/mcp-bundle (it requires mcp/sdk ^0.8.1). +includes: + - phpstan.neon.dist +parameters: + tmpDir: .cache/phpstan-no-bundle + # (?) marks a path that PHPStan accepts before its tests exist. + excludePaths: + - tests/Support/Symfony + - tests/Integration/Symfony (?) + - tests/Contract/Symfony + - tests/Integration/Conformance/SymfonyConformanceTest.php (?) diff --git a/tests/Architecture/LayeringTest.php b/tests/Architecture/LayeringTest.php index f44b791..aaaa1ab 100644 --- a/tests/Architecture/LayeringTest.php +++ b/tests/Architecture/LayeringTest.php @@ -31,10 +31,10 @@ public function onlyTheAdapterDependsOnMcpSdk(): Rule { return PHPat::rule() ->classes(Selector::inNamespace('AgentCat')) - ->excluding(Selector::inNamespace('AgentCat\McpSdk'), Selector::inNamespace('AgentCat\Tests')) + ->excluding(Selector::inNamespace('AgentCat\McpSdk'), Selector::inNamespace('AgentCat\Symfony'), Selector::inNamespace('AgentCat\Tests')) ->shouldNot()->dependOn() ->classes(Selector::inNamespace('Mcp')) - ->because('mcp/sdk is optional: only AgentCat\McpSdk may reference it (01 AC-90)'); + ->because('mcp/sdk is optional: only AgentCat\McpSdk and the Symfony bundle, which configures Mcp\Server\Builder services, may reference it (01 AC-90)'); } #[TestRule] @@ -58,6 +58,33 @@ public function theLaravelAdapterNeedsNoMcpSdk(): Rule ->because('a Laravel application without mcp/sdk must load every Laravel class'); } + #[TestRule] + public function onlyTheSymfonyBundleDependsOnSymfonyFrameworkParts(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('AgentCat')) + ->excluding(Selector::inNamespace('AgentCat\Symfony'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\Tests')) + ->shouldNot()->dependOn() + ->classes( + Selector::inNamespace('Symfony\Component\HttpKernel'), + Selector::inNamespace('Symfony\Component\DependencyInjection'), + Selector::inNamespace('Symfony\Component\Config'), + Selector::inNamespace('Symfony\Component\HttpFoundation'), + Selector::inNamespace('Symfony\Bundle'), + ) + ->because('Symfony is optional (01 AC-90)'); + } + + #[TestRule] + public function theSymfonyBundleNamesNoBundleClass(): Rule + { + return PHPat::rule() + ->classes(Selector::inNamespace('AgentCat\Symfony')) + ->shouldNot()->dependOn() + ->classes(Selector::inNamespace('Symfony\AI'), Selector::inNamespace('AgentCat\Laravel'), Selector::inNamespace('AgentCat\LaravelMcp'), Selector::inNamespace('AgentCat\McpSdk')) + ->because('the bundle reads symfony/mcp-bundle service IDs and tags only, so it loads without the bundle, and reaches the mcp/sdk adapter only through Internal\Engine\Libraries'); + } + #[TestRule] public function theMcpSdkAdapterNeedsNoLaravel(): Rule { diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php index fd359e0..e45b04b 100644 --- a/tests/Contract/ManifestTest.php +++ b/tests/Contract/ManifestTest.php @@ -31,6 +31,9 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void 'Mcp\Server\Builder::$parts', 'Mcp\Server\Builder::$referenceHandler', 'Mcp\Server\Protocol::$requestHandlers', + 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', + 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', ], PrivateMemberManifest::members(), ); @@ -74,5 +77,6 @@ public function testNoMcpLibraryIsARuntimeRequirement(): void $this->assertSame('<0.8.1', $composer->conflict->{'mcp/sdk'}); $this->assertObjectHasProperty('laravel/mcp', $composer->suggest); $this->assertSame('<1.0', $composer->conflict->{'laravel/mcp'}); + $this->assertObjectHasProperty('symfony/mcp-bundle', $composer->suggest); } } diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php index 02c4605..88477ee 100644 --- a/tests/Contract/PrivateMemberManifest.php +++ b/tests/Contract/PrivateMemberManifest.php @@ -12,7 +12,8 @@ /** * The manifest of library internals AgentCat depends on, for Plan F's snapshot diff over `members()`: every - * #[TouchesPrivateMember] in src//Internal/Access, and every library class AgentCat extends. + * #[TouchesPrivateMember] in src//Internal/Access, every library class AgentCat extends, and the + * symfony/mcp-bundle service definitions AgentCat's bundle reads at compile time. */ final class PrivateMemberManifest { @@ -27,6 +28,16 @@ final class PrivateMemberManifest ListToolsResult::class => '__construct(array $tools, ?string $nextCursor = null)', ]; + /** + * The symfony/mcp-bundle service IDs, tags and method calls that AgentCat's bundle finds each server builder by, as + * strings: the bundle never names a class of symfony/mcp-bundle. `tests/Contract/Symfony` checks each one. + */ + public const array BUNDLE_SERVICES = [ + 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', + 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', + ]; + /** @return list */ public static function accessorClasses(): array { @@ -83,8 +94,8 @@ public static function extendedClasses(): array } /** - * The private members AgentCat reads (`Class::$member`) and the methods of the classes it extends that it depends - * on (`Class::__construct(...)`, `Class::handle(...): …`), sorted. + * The private members AgentCat reads (`Class::$member`), the methods of the classes it extends that it depends on + * (`Class::__construct(...)`, `Class::handle(...): …`) and the bundle service definitions it reads, sorted. * * @return list */ @@ -97,6 +108,7 @@ public static function members(): array foreach (self::EXTENDED as $class => $method) { $members[] = $class . '::' . $method; } + array_push($members, ...self::BUNDLE_SERVICES); $members = array_values(array_unique($members)); sort($members); diff --git a/tests/Contract/Symfony/BundleServicesTest.php b/tests/Contract/Symfony/BundleServicesTest.php new file mode 100644 index 0000000..dd72e3c --- /dev/null +++ b/tests/Contract/Symfony/BundleServicesTest.php @@ -0,0 +1,60 @@ +kernel(withAgentCat: false, probe: $probe); + + $builder = $probe->builders['conformance'] ?? null; + $this->assertIsArray($builder, 'no service tagged mcp.server.builder with server: conformance'); + $this->assertSame('mcp.server.conformance.builder', $builder['id']); + $this->assertEquals([Server::class, 'builder'], $builder['factory']); + $this->assertNull($builder['configurator']); + $calls = array_values(array_filter($builder['calls'], static fn(array $call): bool => $call[0] === 'addRequestHandlers')); + $this->assertCount(1, $calls); + $argument = $calls[0][1][0] ?? null; + $this->assertInstanceOf(TaggedIteratorArgument::class, $argument); + $this->assertSame('mcp.request_handler', $argument->getTag()); + } + + #[MaximumDuration(1000)] + public function testTheServerIsBuiltFromItsBuilder(): void + { + $kernel = $this->kernel(withAgentCat: false); + + $this->assertInstanceOf(Server::class, $kernel->getContainer()->get('mcp.server.conformance')); + } + + public function testTheManifestNamesTheBundleInternals(): void + { + $bundle = array_values(array_filter(PrivateMemberManifest::members(), static fn(string $member): bool => str_starts_with($member, 'symfony/mcp-bundle: '))); + + $this->assertSame([ + 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', + 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', + ], $bundle); + } +} diff --git a/tests/Support/Symfony/DefinitionsProbe.php b/tests/Support/Symfony/DefinitionsProbe.php new file mode 100644 index 0000000..8e723c2 --- /dev/null +++ b/tests/Support/Symfony/DefinitionsProbe.php @@ -0,0 +1,42 @@ +|null, calls: list}>, configurator: string|array|null}> + * per server name + */ + public array $builders = []; + + #[\Override] + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('mcp.server.builder') as $id => $tags) { + $definition = $container->getDefinition($id); + $calls = []; + foreach ($definition->getMethodCalls() as $call) { + if (is_array($call) && is_string($call[0] ?? null) && is_array($call[1] ?? null)) { + $calls[] = [$call[0], $call[1]]; + } + } + foreach ($tags as $attributes) { + $server = is_array($attributes) ? ($attributes['server'] ?? null) : null; + if (is_string($server)) { + $this->builders[$server] = ['id' => $id, 'factory' => $definition->getFactory(), 'calls' => $calls, 'configurator' => $definition->getConfigurator()]; + } + } + } + } +} diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php new file mode 100644 index 0000000..11ee4d1 --- /dev/null +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -0,0 +1,127 @@ + the kernels this test booted */ + private array $kernels = []; + + private ?Environment $environment = null; + + #[\Override] + protected function setUp(): void + { + if (!class_exists(McpBundle::class)) { + self::markTestSkipped('symfony/mcp-bundle is not installed'); + } + $home = getenv('HOME'); + if (!is_string($home) || $home === '') { + throw new \LogicException('tests/bootstrap.php sets HOME to the test HOME'); + } + $this->environment = Environment::clear(...self::VARIABLES); + TestMode::reset(); // an earlier test can have logged the once-per-process line of AGENTCAT_ENABLED + $this->directory = $home . '/symfony-' . bin2hex(random_bytes(6)); + if (!mkdir($this->directory, 0o700, true)) { + throw new \RuntimeException('Cannot create ' . $this->directory); + } + EventQueue::setGlobal(new EventQueue(transport: $this->transport = new RecordingTransport())); + } + + #[\Override] + protected function tearDown(): void + { + foreach ($this->kernels as $kernel) { + $kernel->shutdown(); + } + $this->kernels = []; + ToolCalls::reset(); + TestMode::reset(); + BridgeConfig::reset(); + Log::reset(); + Diagnostics::reset(); + EventQueue::setGlobal(null); + AgentCat::setClient(null); + TrackedServers::resetIncomplete(); + TrackedServers::resetSkipped(); + + $this->environment?->restore(); + FileTree::remove($this->directory); + } + + /** + * Boots a kernel with its own directory, so its container is compiled for this configuration. + * + * @param array $agentcat the agentcat configuration + * @param array $services more services: id => class name or instance (synthetic) + */ + protected function kernel(array $agentcat = [], array $services = [], bool $withAgentCat = true, ?CompilerPassInterface $probe = null): TestKernel + { + $kernel = new TestKernel($this->directory . '/kernel-' . count($this->kernels), $agentcat, $services, withAgentCat: $withAgentCat, probe: $probe); + $this->kernels[] = $kernel; + $handler = $this->exceptionHandler(); + $kernel->boot(); + if ($this->exceptionHandler() !== $handler) { + restore_exception_handler(); // FrameworkBundle::boot() leaves Symfony's handler with symfony/error-handler 7.3 + } + + return $kernel; + } + + /** + * The exception handler in place (PHP 8.3 has no `get_exception_handler()`). + * + * @return (callable(\Throwable): void)|null + */ + private function exceptionHandler(): ?callable + { + $handler = set_exception_handler(null); + restore_exception_handler(); + + return $handler; + } + + /** + * Flushes the global queue (1 s) and returns every delivery the API got. + * + * @return list + */ + protected function deliveries(): array + { + EventQueue::global()->flush(1.0); + + return $this->transport->deliveries; + } +} diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php new file mode 100644 index 0000000..94f816e --- /dev/null +++ b/tests/Support/Symfony/TestKernel.php @@ -0,0 +1,129 @@ + $agentcat the agentcat configuration + * @param array $services more services: id => class name or instance (synthetic) + * @param array $mcp the mcp configuration + */ + public function __construct( + private readonly string $directory, + private readonly array $agentcat = [], + private readonly array $services = [], + private readonly array $mcp = ['servers' => ['conformance' => ['name' => 'conformance-server', 'version' => '2.0.0', 'registry' => ['AgentCat\Tests\Support\Symfony\Tools\\'], 'http' => ['path' => '/mcp/conformance']]]], + private readonly bool $withAgentCat = true, + private readonly ?CompilerPassInterface $probe = null, + ) { + parent::__construct('test', false); + } + + /** @return \Generator (Symfony 8.1 deprecates the interface that KernelInterface names here) */ + #[\Override] + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new McpBundle(); + if ($this->withAgentCat) { + // @phpstan-ignore class.notFound (src/Symfony/AgentCatBundle.php is not written yet), generator.valueType (the same class) + yield new AgentCatBundle(); + } + } + + #[\Override] + public function getCacheDir(): string + { + return $this->directory . '/cache'; + } + + #[\Override] + public function getBuildDir(): string + { + return $this->getCacheDir(); + } + + #[\Override] + public function getLogDir(): string + { + return $this->directory . '/log'; + } + + /** Sets the synthetic services before the bundles boot, so a bundle's `boot()` can use them. */ + #[\Override] + protected function initializeContainer(): void + { + parent::initializeContainer(); + foreach ($this->services as $id => $service) { + if (is_object($service)) { + $this->getContainer()->set($id, $service); + } + } + } + + #[\Override] + protected function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new class implements CompilerPassInterface { + #[\Override] + public function process(ContainerBuilder $container): void + { + foreach ($container->getDefinitions() as $id => $definition) { + if (str_starts_with($id, 'mcp.server.')) { + $definition->setPublic(true); + } + } + } + }); + if ($this->probe instanceof CompilerPassInterface) { + $container->addCompilerPass($this->probe, PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + } + } + + protected function configureContainer(ContainerConfigurator $container): void + { + $container->extension('framework', ['secret' => 'test', 'test' => true, 'http_method_override' => false, 'router' => ['utf8' => true]]); + $container->extension('mcp', $this->mcp); + if ($this->withAgentCat) { + $container->extension('agentcat', $this->agentcat); + } + $services = $container->services()->defaults()->autowire()->autoconfigure(); + $services->load('AgentCat\Tests\Support\Symfony\Tools\\', __DIR__ . '/Tools'); + $services->set('logger', MemoryLogger::class)->public(); + foreach ($this->services as $id => $service) { + is_string($service) ? $services->set($id, $service)->public() : $services->set($id)->synthetic()->public(); + } + } + + /** The MCP routes, from the bundle's route loader. */ + protected function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import('.', 'mcp'); + } +} diff --git a/tests/Support/Symfony/Tools/BoomTool.php b/tests/Support/Symfony/Tools/BoomTool.php new file mode 100644 index 0000000..131edad --- /dev/null +++ b/tests/Support/Symfony/Tools/BoomTool.php @@ -0,0 +1,20 @@ +`. */ +final class EchoTool +{ + #[McpTool(name: 'echo', description: 'Echo')] + public function __invoke(string $msg): string + { + ToolCalls::record('echo', ['msg' => $msg]); + + return 'echo: ' . $msg; + } +} diff --git a/tests/Support/Symfony/Tools/OwnTool.php b/tests/Support/Symfony/Tools/OwnTool.php new file mode 100644 index 0000000..c4bd4c3 --- /dev/null +++ b/tests/Support/Symfony/Tools/OwnTool.php @@ -0,0 +1,22 @@ + $session_id]); + + return 'ok'; + } +} diff --git a/tests/Support/Symfony/Tools/RefusesTool.php b/tests/Support/Symfony/Tools/RefusesTool.php new file mode 100644 index 0000000..e2b63db --- /dev/null +++ b/tests/Support/Symfony/Tools/RefusesTool.php @@ -0,0 +1,22 @@ + 'object', 'properties' => ['echoed' => ['type' => 'string']]])] + public function __invoke(): CallToolResult + { + ToolCalls::record('structured', []); + + return new CallToolResult([new TextContent('x')], structuredContent: ['echoed' => 'x']); + } +} diff --git a/tests/bin/mcp-sdk-main.sh b/tests/bin/mcp-sdk-main.sh index 029cd69..1a67fdf 100755 --- a/tests/bin/mcp-sdk-main.sh +++ b/tests/bin/mcp-sdk-main.sh @@ -17,6 +17,8 @@ cd "$copy" if [ -n "${MCP_SDK_REPO:-}" ]; then composer config repositories.mcp-sdk-main vcs "$MCP_SDK_REPO" fi +# symfony/mcp-bundle requires mcp/sdk ^0.8.1, which main does not satisfy: the Symfony tests skip without it. +composer remove --dev --no-update symfony/mcp-bundle composer require --dev --no-interaction --update-with-all-dependencies "mcp/sdk:dev-main#82e19d114e95671e05e7df7909195f7ace03d90e as 0.9.0" -vendor/bin/phpstan analyse --no-progress --memory-limit=1G +vendor/bin/phpstan analyse --no-progress --memory-limit=1G --configuration=phpstan.no-bundle.neon vendor/bin/phpunit --testsuite "${SUITES:-Contract,Integration,E2E}" From 892f0f661d99b662282ec3a5683f4ced61c2e9c7 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 15:03:21 +0000 Subject: [PATCH 03/55] fix: bound each pump pass to the events queued at its start A telemetry-only event, an event whose encoding fails and an event a hook drops start no transfer, so the delivery slots stay free. A redactEvent hook that publishes a custom event on every run then kept one pump() pass, the handler-entry pump of a request included, from ever returning. Each pass now starts only the events queued when it began, as 70d1812 did for the prepare pass; an event a hook queues meanwhile waits for the next pass, and a flush still ends at its deadline. --- src/Internal/Delivery/EventQueue.php | 17 +++-- .../Unit/Internal/Delivery/EventQueueTest.php | 73 +++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index 0f026a5..e3040c0 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -219,8 +219,9 @@ public function add(QueuedEvent $event): void } /** - * Non-blocking: runs the pipeline for waiting events while fewer than 5 are in delivery, pumps the transport, - * ticks the companions and pumps the side channel. A re-entrant call (from a hook or an exporter) does nothing. + * Non-blocking: starts the events queued when it began while fewer than 5 are in delivery (the pipeline of a + * waiting one runs then), pumps the transport, ticks the companions and pumps the side channel. An event a hook + * queues meanwhile waits for the next pump. A re-entrant call (from a hook or an exporter) does nothing. * * `pump()` is non-blocking only when libcurl resolves host names asynchronously (threaded resolver or * c-ares). With a synchronous resolver, starting a transfer can block for the DNS lookup. @@ -409,14 +410,18 @@ private function wait(float $until): void } /** - * One pass: starts events while fewer than 5 are in delivery, oldest first (those through the pipeline, then the - * waiting ones, whose pipeline runs now), and pumps the transport, then ticks each companion (unless `$tick` is - * false) and pumps the side channel. Never throws. + * One pass: starts the events queued when it began while fewer than 5 are in delivery, oldest first (those through + * the pipeline, then the waiting ones, whose pipeline runs now), and pumps the transport, then ticks each companion + * (unless `$tick` is false) and pumps the side channel. Never throws. + * + * An event a hook queues meanwhile waits for the next pass: an event that starts no transfer (telemetry only, an + * encode failure, dropped by a hook) leaves the slots free, so a hook that keeps queueing such events cannot hold + * the pass. A hook can only add events (the cap drops one for each new one), so `$left` never outruns the queue. */ private function pumpOnce(bool $tick = true): void { try { - while ($this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY) { + for ($left = $this->queued(); $left > 0 && $this->transport->inFlight() < self::CONCURRENCY; --$left) { $ready = $this->ready->isEmpty() ? $this->prepare($this->waiting->dequeue()) : $this->ready->dequeue(); if ($ready instanceof ReadyEvent) { $this->send($ready); diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index 8f81cea..49e643e 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -39,6 +39,9 @@ final class EventQueueTest extends TestCase /** How long a slow hook takes on the simulated clock of `RecordingTransport`. */ private const float HOOK_SECONDS = 0.01; + /** The most events a self-feeding hook queues in a test: without a bound a broken queue would hang the test. */ + private const int SELF_FEEDING_BOUND = 1_000; + /** Read by `ApiBaseUrl::resolve()` through `getenv()`, `$_ENV` and `$_SERVER`. */ private const array URL_VARIABLES = ['AGENTCAT_API_URL', 'MCPCAT_API_URL']; @@ -625,6 +628,51 @@ static function () use ($transport, $at): void { $this->assertSame(['queueLength' => 2, 'activeRequests' => 5], $queue->stats()); } + /** + * A telemetry-only event starts no transfer, so the slots stay free while a `redactEvent` hook queues one more on + * every run (a hook that publishes a custom event). A pump, the handler-entry pump of a request included, starts + * only the events queued when it began, each once; the next pump takes the new ones. + */ + public function testAPumpStartsOnlyTheEventsQueuedWhenItBegan(): void + { + $exporter = new RecordingExporter(); + $queue = new EventQueue(transport: new RecordingTransport(), exporters: $exporter); + $children = new \ArrayObject(); + $redact = $this->selfFeeding($queue, $children); + for ($i = 0; $i < 3; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i, null), $redact)); + } + + $queue->pump(); + + $this->assertCount(3, $children, 'the hook of each event queued at the start, once'); + $this->assertSame(['event-0', 'event-1', 'event-2'], array_map(static fn(Event $event): ?string => $event->resourceName, $exporter->events)); + $this->assertSame(['queueLength' => 3, 'activeRequests' => 0], $queue->stats()); + + $queue->pump(); + + $this->assertCount(6, $children); + $this->assertSame(['event-0', 'event-1', 'event-2', 'child', 'child', 'child'], array_map(static fn(Event $event): ?string => $event->resourceName, $exporter->events)); + } + + /** The same hook, slow, in a flush: each pass starts only the events queued when it began, and the deadline ends it. */ + public function testAFlushEndsAtItsDeadlineWhileATelemetryOnlyHookKeepsQueueing(): void + { + $transport = new RecordingTransport(); + $queue = new EventQueue(transport: $transport); + $children = new \ArrayObject(); + $redact = $this->selfFeeding($queue, $children, $transport); + for ($i = 0; $i < 7; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i, null), $redact)); + } + + $delivered = $queue->flush(1.0); + + $this->assertLessThan(1.0 + 1.0, $transport->now(), 'the deadline, plus the hooks of the last pass'); + $this->assertLessThan(self::SELF_FEEDING_BOUND, count($children), 'the deadline ended the flush, not the bound of the test'); + $this->assertFalse($delivered); + } + /** The process ends with `shutdown()`: it spends its budget on the events it can start, not on hooks of the others. */ public function testShutdownRunsNoHookOfAnEventThatFindsNoFreeSlot(): void { @@ -1572,6 +1620,31 @@ static function () use ($resourceName, $ran, $transport): void { ]); } + /** + * A `redactEvent` hook that queues a telemetry-only `child` event with the same hook on every run, up to + * `SELF_FEEDING_BOUND`, and appends the child to `$children`. With `$transport`, each run takes `HOOK_SECONDS` on its + * simulated clock. + * + * @param \ArrayObject $children + * + * @return \Closure(Event): Event + */ + private function selfFeeding(EventQueue $queue, \ArrayObject $children, ?RecordingTransport $transport = null): \Closure + { + $redact = null; + $redact = function (Event $event) use (&$redact, $queue, $children, $transport): Event { + $transport?->waitForActivity($transport->now() + self::HOOK_SECONDS); + if (count($children) < self::SELF_FEEDING_BOUND) { + $children->append('child'); + $queue->add(new QueuedEvent($this->event('child', null), $redact)); + } + + return $event; + }; + + return $redact; + } + /** * An event whose pending hook appends its resource name to `$ran` when it runs. * From 1fa439812c73c4d28da08ff242a038878ae8ab43 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 15:20:58 +0000 Subject: [PATCH 04/55] feat: let a framework bridge give mcp/sdk calls their request context ClientSeams::of() takes an httpContext closure; a client built with it hands it to every server it tracks (TrackedServer::$httpContext), and the mcp/sdk adapter's request reader reads the call's method, URL and headers through it instead of $_SERVER. A worker that serves many requests in one process (Symfony Runtime, FrankenPHP worker mode, RoadRunner, Swoole) then records each request's own headers. Without the closure the SAPI globals still count. RequestStackContext::of() gives that closure for a Symfony RequestStack: the main request when a call is recorded, null without one (the mcp:server console command) and null, with no line, when Symfony cannot read the request. The rules of LaravelHttpContext move into HttpContext::fromRequest(), which both bridges call with plain values: the URL as sent (HTTPS, the Host header and the raw request URI), repeated header values joined with ", ", and Symfony's made-up php-auth-* headers left out. --- composer-dependency-analyser.php | 2 + src/Client.php | 10 +- src/Internal/ClientSeams.php | 25 ++-- src/Internal/Engine/HttpContext.php | 38 +++++- src/Internal/Engine/TrackedServer.php | 4 + .../Internal/LaravelHttpContext.php | 26 +--- src/McpSdk/McpSdkAdapter.php | 2 +- src/Symfony/Internal/RequestStackContext.php | 44 +++++++ .../McpSdk/RequestContextSeamTest.php | 88 +++++++++++++ .../Unit/Internal/Engine/HttpContextTest.php | 36 ++++++ .../Unit/Symfony/RequestStackContextTest.php | 118 ++++++++++++++++++ 11 files changed, 356 insertions(+), 37 deletions(-) create mode 100644 src/Symfony/Internal/RequestStackContext.php create mode 100644 tests/Integration/McpSdk/RequestContextSeamTest.php create mode 100644 tests/Unit/Symfony/RequestStackContextTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index a4a69b1..5e491d2 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -28,6 +28,8 @@ // False positives: the analyser reads AgentCat's own `Env::get()` and `new Event(` as Laravel's global env() and // event() helpers. PHPat keeps Laravel out of these files (LayeringTest). ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Internal/Support/BridgeConfig.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // Symfony is optional (01 AC-90): dev dependencies used only by the Symfony bundle. + ->ignoreErrorsOnPackageAndPath('symfony/http-foundation', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. diff --git a/src/Client.php b/src/Client.php index adc7384..473f063 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,6 +9,7 @@ use AgentCat\Internal\Delivery\EventQueue; use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Engine\CallInterceptor; +use AgentCat\Internal\Engine\HttpContext; use AgentCat\Internal\Engine\Libraries; use AgentCat\Internal\Engine\Library; use AgentCat\Internal\Engine\Publisher; @@ -45,11 +46,15 @@ private bool $telemetry; + /** @var (\Closure(): ?HttpContext)|null the HTTP request of a call, for every server this client tracks */ + private ?\Closure $httpContext; + /** * Creates a client that sends through the process-wide event queue. Call it without arguments. A * dependency-injection container that autowires it gets the same client. * - * @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate. + * @param ClientSeams|null $seams Internal: tests and `AgentCat::fake()` replace the queue and the enabled gate, and + * a framework bridge gives the HTTP request of a call. */ public function __construct(?ClientSeams $seams = null) { @@ -57,6 +62,7 @@ public function __construct(?ClientSeams $seams = null) $this->interceptor = new CallInterceptor($this->publisher); $this->enabled = $seams->enabled ?? TestMode::sdkEnabled(...); $this->telemetry = $seams->telemetry ?? true; + $this->httpContext = $seams?->httpContext; } /** @@ -129,7 +135,7 @@ public function track(object $server, ?string $projectId = null, ?Options $optio } elseif ($projectId !== '' && preg_match(self::PROJECT_ID_PATTERN, $projectId) !== 1) { Log::write(sprintf('WARN: Project ID "%s" does not match proj_ followed by 27 letters or digits; the AgentCat API drops its events', $projectId)); } - $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher); + $state = new TrackedServer($projectId, $options, $library->serverInfo($server), $this->publisher, $this->httpContext); $library->install($server, $state, $this->publisher, $this->interceptor); TrackedServers::add($server, $state); Log::write(sprintf( diff --git a/src/Internal/ClientSeams.php b/src/Internal/ClientSeams.php index 1515014..08756ff 100644 --- a/src/Internal/ClientSeams.php +++ b/src/Internal/ClientSeams.php @@ -5,29 +5,38 @@ namespace AgentCat\Internal; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\HttpContext; /** - * What tests and `AgentCat::fake()` replace in a `Client`: its event queue, its enabled gate and whether it sets up telemetry. The constructor is - * private, so a dependency-injection container cannot build one, and an autowired `Client` always gets the - * process-wide queue and the default gate. + * What tests, `AgentCat::fake()` and the framework bridges replace in a `Client`: its event queue, its enabled gate, + * whether it sets up telemetry, and where the HTTP request of a call comes from. The constructor is private, so a + * dependency-injection container cannot build one, and an autowired `Client` always gets the process-wide queue, the + * default gate and the SAPI globals. * * @internal */ final readonly class ClientSeams { - /** @param (\Closure(): bool)|null $enabled */ + /** + * @param (\Closure(): bool)|null $enabled + * @param (\Closure(): ?HttpContext)|null $httpContext + */ private function __construct( public EventQueue $queue, public ?\Closure $enabled, public bool $telemetry, + public ?\Closure $httpContext, ) {} /** - * @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44) - * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`) + * @param (\Closure(): bool)|null $enabled whether `track()` instruments; null keeps the default gate (D31, D44) + * @param bool $telemetry false sets up no exporters and no diagnostics (`AgentCat::fake()`) + * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework (the Symfony + * bundle's `RequestStack`), for every server the client tracks; + * null reads the SAPI globals (plan E2 header conflict 5) */ - public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true): self + public static function of(EventQueue $queue, ?\Closure $enabled = null, bool $telemetry = true, ?\Closure $httpContext = null): self { - return new self($queue, $enabled, $telemetry); + return new self($queue, $enabled, $telemetry, $httpContext); } } diff --git a/src/Internal/Engine/HttpContext.php b/src/Internal/Engine/HttpContext.php index ceb7b40..cbab475 100644 --- a/src/Internal/Engine/HttpContext.php +++ b/src/Internal/Engine/HttpContext.php @@ -5,12 +5,20 @@ namespace AgentCat\Internal\Engine; /** - * The HTTP request of the current PHP request, read from `$_SERVER`. Null on the command line (stdio servers). + * The HTTP request of an MCP call: read from `$_SERVER` (null on the command line, stdio servers), or from the request + * object a framework bridge hands over, so a worker that serves many requests in one process records each one's own. * * @internal */ final readonly class HttpContext { + /** + * The headers that Symfony's `ServerBag` makes up from the `Authorization` header or the SAPI's `PHP_AUTH_*` + * variables. No client sends them, and two hold the credentials in clear text: the `Authorization` header stays + * (D06). + */ + private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest']; + /** * @param array $headers lower-case dashed names, values verbatim (D06) */ @@ -52,6 +60,32 @@ public static function fromServer(string $sapi, array $server): ?self return new self($method, self::url($server), $headers); } + /** + * The request object of a framework (Symfony's `Request`, which Laravel's extends), from its plain values, with the + * URL and the headers as the client sent them, like the SAPI reading of `fromServer()`: + * - the URL from the scheme `HTTPS` gives, the `Host` header and the raw request URI, through `url()`: Laravel's + * `fullUrl()` sorts the query and drops a trailing slash, and Symfony's `getUri()` sorts the query and refuses + * an invalid host. The `Host` header, not the server variable: a request that a worker builds from PSR-7 has + * only the header; + * - the header names as the header bag keeps them (lower case); the values of a repeated header joined with `, `, + * like a SAPI joins them, a null value as `''`; the `php-auth-*` headers that Symfony makes up left out. + * + * @param array> $headers the request's header bag (`HeaderBag::all()`) + * @param mixed $https the `HTTPS` server variable + * @param mixed $requestUri the `REQUEST_URI` server variable + */ + public static function fromRequest(string $method, array $headers, mixed $https, ?string $host, mixed $requestUri): self + { + $values = []; + foreach ($headers as $name => $list) { + if (!in_array($name, self::MADE_UP_HEADERS, true)) { + $values[$name] = implode(', ', $list); + } + } + + return new self($method, self::url(['HTTPS' => $https, 'HTTP_HOST' => $host, 'REQUEST_URI' => $requestUri]), $values); + } + /** * The projection TypeScript v1 calls `requestInfo` (D34): `{headers, url?}`. `method` stays internal. */ @@ -72,7 +106,7 @@ public function toRequestInfo(): \stdClass * * @param array $server */ - public static function url(array $server): ?string + private static function url(array $server): ?string { $host = $server['HTTP_HOST'] ?? null; if (!is_string($host) || $host === '') { diff --git a/src/Internal/Engine/TrackedServer.php b/src/Internal/Engine/TrackedServer.php index 35f5b90..2bc7bd3 100644 --- a/src/Internal/Engine/TrackedServer.php +++ b/src/Internal/Engine/TrackedServer.php @@ -26,12 +26,16 @@ final class TrackedServer * @param string $projectId '' in telemetry-only mode * @param \Closure(): array{0: ?string, 1: ?string} $serverInfo server name and version, read when an event is published * @param Publisher $publisher the publisher of the client that tracked the server + * @param (\Closure(): ?HttpContext)|null $httpContext the HTTP request of a call, from the framework; null reads the + * SAPI globals. The laravel/mcp adapter reads the server's own + * transport instead (plan E1 header conflict 9) */ public function __construct( public readonly string $projectId, public readonly Options $options, private readonly \Closure $serverInfo, private readonly Publisher $publisher, + public readonly ?\Closure $httpContext = null, ) { $this->injection = new InjectionState(); } diff --git a/src/LaravelMcp/Internal/LaravelHttpContext.php b/src/LaravelMcp/Internal/LaravelHttpContext.php index 5ea1a65..e5768b9 100644 --- a/src/LaravelMcp/Internal/LaravelHttpContext.php +++ b/src/LaravelMcp/Internal/LaravelHttpContext.php @@ -15,31 +15,9 @@ */ final class LaravelHttpContext { - /** - * The headers that Symfony's `ServerBag` makes up from the `Authorization` header or the SAPI's `PHP_AUTH_*` - * variables. No client sends them, and two hold the credentials in clear text: the `Authorization` header stays - * (D06). - */ - private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest']; - - /** - * The URL and the headers as the client sent them, like the SAPI reading of `HttpContext::fromServer()`: - * - the URL from the scheme, the `Host` header and the raw request URI (Laravel's `fullUrl()` sorts the query - * and drops a trailing slash); - * - header names in lower case, as Symfony's `HeaderBag` keeps them; the values of a repeated header joined - * with `, `, like a SAPI joins them. - */ + /** The URL and the headers as the client sent them, by the rules of `HttpContext::fromRequest()`. */ public static function of(Request $request): HttpContext { - $headers = []; - foreach ($request->headers->all() as $name => $values) { - if (!in_array($name, self::MADE_UP_HEADERS, true)) { - $headers[$name] = implode(', ', $values); // a null value is '' - } - } - // The Host header, not the server variable: a request that a worker builds from PSR-7 has only the header. - $url = HttpContext::url(['HTTPS' => $request->server->get('HTTPS'), 'HTTP_HOST' => $request->headers->get('host'), 'REQUEST_URI' => $request->server->get('REQUEST_URI')]); - - return new HttpContext($request->getMethod(), $url, $headers); + return HttpContext::fromRequest($request->getMethod(), $request->headers->all(), $request->server->get('HTTPS'), $request->headers->get('host'), $request->server->get('REQUEST_URI')); } } diff --git a/src/McpSdk/McpSdkAdapter.php b/src/McpSdk/McpSdkAdapter.php index fb66bd1..08d7862 100644 --- a/src/McpSdk/McpSdkAdapter.php +++ b/src/McpSdk/McpSdkAdapter.php @@ -184,7 +184,7 @@ public static function install(Builder $builder, TrackedServer $server, Publishe $builder->setReferenceHandler(new CapturingReferenceHandler($inner, $stash)); $list = new ListToolsInterceptor($server, $delegates, $publisher); $builder->addRequestHandler($list); - $builder->addRequestHandler(new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, $stash, new RequestReader())); + $builder->addRequestHandler(new CallToolInterceptor($server, $delegates, $list, $interceptor, $publisher, $stash, new RequestReader($server->httpContext))); } /** diff --git a/src/Symfony/Internal/RequestStackContext.php b/src/Symfony/Internal/RequestStackContext.php new file mode 100644 index 0000000..a095d43 --- /dev/null +++ b/src/Symfony/Internal/RequestStackContext.php @@ -0,0 +1,44 @@ +getMainRequest(); + + return $request instanceof Request ? self::read($request) : null; + }; + } + + private static function read(Request $request): ?HttpContext + { + try { + return HttpContext::fromRequest($request->getMethod(), $request->headers->all(), $request->server->get('HTTPS'), $request->headers->get('host'), $request->server->get('REQUEST_URI')); + } catch (\Throwable) { + return null; // the client controls the request: this is no drift + } + } +} diff --git a/tests/Integration/McpSdk/RequestContextSeamTest.php b/tests/Integration/McpSdk/RequestContextSeamTest.php new file mode 100644 index 0000000..0c10f9f --- /dev/null +++ b/tests/Integration/McpSdk/RequestContextSeamTest.php @@ -0,0 +1,88 @@ +queue, static fn(): bool => true, false, static fn(): HttpContext => new HttpContext('POST', 'https://worker.test/mcp', ['x-from' => 'request-stack']))); + $client->track($builder, TrackingFixture::PROJECT); + $harness = $fixture->harness($builder); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $info = JsonAssert::at($fixture->event()->parameters, 'extra', 'requestInfo'); + $this->assertSame(['x-from' => 'request-stack'], (array) JsonAssert::at($info, 'headers')); + $this->assertSame('https://worker.test/mcp', JsonAssert::at($info, 'url')); + } + + /** Review Focus 4: one container, many requests, as in a Symfony Runtime worker under the CLI SAPI. */ + #[Group('ac-02-43')] + public function testEachRequestOfAWorkerCarriesItsOwnHeaders(): void + { + $fixture = new TrackingFixture(); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + $requests = new RequestStack(); + $client = new Client(ClientSeams::of($fixture->queue, static fn(): bool => true, false, RequestStackContext::of($requests))); + $client->track($builder, TrackingFixture::PROJECT); + $harness = $fixture->harness($builder); + + foreach (['ann', 'bob', 'cid'] as $user) { + $requests->push(Request::create('/mcp', 'POST', server: ['HTTP_X_USER' => $user])); + $harness->callTool('echo', ['msg' => $user, 'session_id' => 'start']); + $requests->pop(); + } + + $events = $fixture->events(); + $this->assertSame(['ann', 'bob', 'cid'], array_map(static fn(Event $e): mixed => JsonAssert::at($e->parameters, 'extra', 'requestInfo', 'headers', 'x-user'), $events)); + } + + public function testWithoutAContextTheSapiGlobalsStillCount(): void + { + $fixture = new TrackingFixture(); + $builder = TrackingFixture::builder(); + Tools::echo($builder); + (new Client(ClientSeams::of($fixture->queue, static fn(): bool => true, false)))->track($builder, TrackingFixture::PROJECT); + $harness = $fixture->harness($builder); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $extra = JsonAssert::at($fixture->event()->parameters, 'extra'); + $this->assertInstanceOf(\stdClass::class, $extra); + $this->assertObjectNotHasProperty('requestInfo', $extra); // PHPUnit runs on the CLI + } +} diff --git a/tests/Unit/Internal/Engine/HttpContextTest.php b/tests/Unit/Internal/Engine/HttpContextTest.php index e6e9937..2028d8c 100644 --- a/tests/Unit/Internal/Engine/HttpContextTest.php +++ b/tests/Unit/Internal/Engine/HttpContextTest.php @@ -111,4 +111,40 @@ public function testCaptureIsNullUnderTheCommandLine(): void { $this->assertNotInstanceOf(HttpContext::class, HttpContext::capture()); } + + /** A framework's request object, read as `fromServer()` reads the SAPI: the URL as sent, through `url()`. */ + #[Group('ac-02-43')] + public function testARequestObjectGivesItsMethodTheUrlAsSentAndItsHeaders(): void + { + $context = HttpContext::fromRequest('POST', ['host' => ['Example.test:8443'], 'x-agentcat-health-run' => ['S']], 'on', 'Example.test:8443', '/mcp/?b=2&a=1'); + + $this->assertSame('POST', $context->method); + $this->assertSame('https://Example.test:8443/mcp/?b=2&a=1', $context->url); + $this->assertSame(['host' => 'Example.test:8443', 'x-agentcat-health-run' => 'S'], $context->headers); + } + + public function testARequestObjectWithoutAHostHasNoUrl(): void + { + $this->assertNull(HttpContext::fromRequest('GET', [], null, null, '/mcp')->url); + $this->assertSame('http://h.test', HttpContext::fromRequest('GET', [], 'off', 'h.test', null)->url); + } + + /** The values of a repeated header joined with `, `, like a SAPI joins them; a null value is ''. */ + public function testRepeatedHeaderValuesAreJoined(): void + { + $context = HttpContext::fromRequest('GET', ['x-multi' => ['a', 'b'], 'x-empty' => [null], 'x-none' => []], null, null, null); + + $this->assertSame(['x-multi' => 'a, b', 'x-empty' => '', 'x-none' => ''], $context->headers); + } + + /** + * Symfony's `ServerBag` makes up `php-auth-user`, `php-auth-pw` and `php-auth-digest` from the credentials; no + * client sends them, and two hold the credentials in clear text. The `Authorization` header stays (D06). + */ + public function testTheMadeUpCredentialHeadersAreLeftOut(): void + { + $headers = ['authorization' => ['Basic dTpzZWNyZXQ='], 'php-auth-user' => ['u'], 'php-auth-pw' => ['secret'], 'php-auth-digest' => ['d'], 'php-auth-other' => ['o']]; + + $this->assertSame(['authorization' => 'Basic dTpzZWNyZXQ=', 'php-auth-other' => 'o'], HttpContext::fromRequest('POST', $headers, null, null, null)->headers); + } } diff --git a/tests/Unit/Symfony/RequestStackContextTest.php b/tests/Unit/Symfony/RequestStackContextTest.php new file mode 100644 index 0000000..f0d3c45 --- /dev/null +++ b/tests/Unit/Symfony/RequestStackContextTest.php @@ -0,0 +1,118 @@ + 'POST', + 'HTTPS' => 'on', + 'HTTP_HOST' => 'a.test', + 'REQUEST_URI' => '/mcp?x=1', + 'QUERY_STRING' => 'x=1', + 'HTTP_ACCEPT' => 'application/json, text/event-stream', + 'CONTENT_TYPE' => 'application/json', + ]; + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + /** The `mcp:server` console command (stdio) has no main request. */ + public function testAnEmptyStackGivesNull(): void + { + $this->assertNotInstanceOf(HttpContext::class, RequestStackContext::of(new RequestStack())()); + } + + public function testTheMainRequestGivesItsMethodUrlAndHeaders(): void + { + $requests = new RequestStack(); + $requests->push(new Request(server: self::SERVER)); + + $context = RequestStackContext::of($requests)(); + + $this->assertInstanceOf(HttpContext::class, $context); + $this->assertSame('POST', $context->method); + $this->assertSame('https://a.test/mcp?x=1', $context->url); + $this->assertSame(['host' => 'a.test', 'accept' => 'application/json, text/event-stream', 'content-type' => 'application/json'], $context->headers); + } + + public function testASubRequestDoesNotChangeTheAnswer(): void + { + $requests = new RequestStack(); + $requests->push(new Request(server: self::SERVER)); + $context = RequestStackContext::of($requests); + $main = $context(); + + $requests->push(new Request(server: ['REQUEST_METHOD' => 'GET', 'HTTP_HOST' => 'sub.test', 'REQUEST_URI' => '/fragment', 'HTTP_X_SUB' => '1'])); + + $this->assertEquals($main, $context()); + $this->assertSame('https://a.test/mcp?x=1', $context()?->url); + } + + /** The stack is read when a call is recorded, not when the closure is made: a worker pushes a new request each time. */ + public function testEachCallReadsTheRequestOfItsMoment(): void + { + $requests = new RequestStack(); + $context = RequestStackContext::of($requests); + + $requests->push(new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'a.test', 'HTTP_X_USER' => 'ann'])); + $ann = $context(); + $requests->pop(); + $requests->push(new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'a.test', 'HTTP_X_USER' => 'bob'])); + $bob = $context(); + $requests->pop(); + + $this->assertSame(['ann', 'bob'], [$ann?->headers['x-user'] ?? null, $bob?->headers['x-user'] ?? null]); + $this->assertNotInstanceOf(HttpContext::class, $context()); + } + + /** As the Laravel bridge and the conformance scenario have it: the made-up `php-auth-*` headers are left out (D06). */ + public function testTheHeadersSymfonyMakesUpFromTheCredentialsAreLeftOut(): void + { + $requests = new RequestStack(); + $requests->push(new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'a.test', 'REQUEST_URI' => '/mcp', 'HTTP_AUTHORIZATION' => 'Basic dTpzZWNyZXQ=', 'PHP_AUTH_USER' => 'u', 'PHP_AUTH_PW' => 'secret'])); + + $this->assertSame(['host' => 'a.test', 'authorization' => 'Basic dTpzZWNyZXQ='], RequestStackContext::of($requests)()?->headers); + } + + /** As the mcp/sdk adapter and the Laravel bridge record it: Symfony's `getHost()` would refuse it. */ + public function testAnInvalidHostIsRecordedAsSent(): void + { + $requests = new RequestStack(); + $requests->push(new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'bad host!', 'REQUEST_URI' => '/mcp'])); + + $context = RequestStackContext::of($requests)(); + + $this->assertSame('http://bad host!/mcp', $context?->url); + $this->assertSame(['host' => 'bad host!'], $context->headers); + } + + /** Symfony refuses an invalid `X-HTTP-Method-Override`, which the client sends: no HTTP context, and no line. */ + public function testARequestSymfonyCannotReadGivesNullWithoutALine(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $requests = new RequestStack(); + $requests->push(new Request(server: ['REQUEST_METHOD' => 'POST', 'HTTP_HOST' => 'a.test', 'HTTP_X_HTTP_METHOD_OVERRIDE' => 'NOT A METHOD'])); + + $this->assertNotInstanceOf(HttpContext::class, RequestStackContext::of($requests)()); + $this->assertSame([], $logger->messages()); + } +} From 4c0b2fa0e7f61b8bafa3c172286538ff3dced6bc Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 15:46:06 +0000 Subject: [PATCH 05/55] fix: stop a pump or prepare pass when a signal drain empties the queue during a hook A SIGTERM or SIGINT that arrives while a hook runs, with an earlier handler that keeps the process running, calls finalShutdown(), which delivers the whole queue in the middle of the pass. The bounded loops of pumpOnce() (892f0f6) and prepareWaiting() (70d1812) then dequeued from an empty queue: the queue logged a false "Failed to deliver AgentCat event after retries" line and that pass skipped the transport pump. Both loops now also stop when the queue is empty. --- src/Internal/Delivery/EventQueue.php | 11 ++-- .../Unit/Internal/Delivery/EventQueueTest.php | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/Internal/Delivery/EventQueue.php b/src/Internal/Delivery/EventQueue.php index e3040c0..5db4ea7 100644 --- a/src/Internal/Delivery/EventQueue.php +++ b/src/Internal/Delivery/EventQueue.php @@ -416,12 +416,13 @@ private function wait(float $until): void * * An event a hook queues meanwhile waits for the next pass: an event that starts no transfer (telemetry only, an * encode failure, dropped by a hook) leaves the slots free, so a hook that keeps queueing such events cannot hold - * the pass. A hook can only add events (the cap drops one for each new one), so `$left` never outruns the queue. + * the pass. A signal drain during a hook (`finalShutdown()`) delivers the whole queue: the pass stops when the + * queue is empty. */ private function pumpOnce(bool $tick = true): void { try { - for ($left = $this->queued(); $left > 0 && $this->transport->inFlight() < self::CONCURRENCY; --$left) { + for ($left = $this->queued(); $left > 0 && $this->queued() > 0 && $this->transport->inFlight() < self::CONCURRENCY; --$left) { $ready = $this->ready->isEmpty() ? $this->prepare($this->waiting->dequeue()) : $this->ready->dequeue(); if ($ready instanceof ReadyEvent) { $this->send($ready); @@ -461,12 +462,12 @@ private function finishCompanions(): void /** * The pipeline of the events that waited when the pass began, whatever the free slots (`flush()`): the payloads wait - * in order. An event a hook queues meanwhile waits for the next pass. A hook can only add waiting events (the cap - * drops a prepared one first, and a waiting one only for a new one), so `$left` never outruns the queue. + * in order. An event a hook queues meanwhile waits for the next pass. A signal drain during a hook + * (`finalShutdown()`) delivers the whole queue: the pass stops when no event waits. */ private function prepareWaiting(): void { - for ($left = $this->waiting->count(); $left > 0; --$left) { + for ($left = $this->waiting->count(); $left > 0 && !$this->waiting->isEmpty(); --$left) { $ready = $this->prepare($this->waiting->dequeue()); if ($ready instanceof ReadyEvent) { $this->ready->enqueue($ready); diff --git a/tests/Unit/Internal/Delivery/EventQueueTest.php b/tests/Unit/Internal/Delivery/EventQueueTest.php index 49e643e..189b091 100644 --- a/tests/Unit/Internal/Delivery/EventQueueTest.php +++ b/tests/Unit/Internal/Delivery/EventQueueTest.php @@ -673,6 +673,58 @@ public function testAFlushEndsAtItsDeadlineWhileATelemetryOnlyHookKeepsQueueing( $this->assertFalse($delivered); } + /** + * A SIGTERM during a hook, with an earlier handler that keeps the process running (`03` AC-11): the signal drain + * (`finalShutdown()`) delivers the whole queue in the middle of the pass. The pass then stops at the empty queue, + * logs nothing, and still pumps the transport, so the event whose hook ran completes too. + */ + public function testASignalDrainDuringAHookEndsThePumpPassCleanly(): void + { + $transport = new RecordingTransport(); + $queue = new EventQueue(transport: $transport); + $queue->add(new QueuedEvent($this->event('event-0'), pending: [ + static function () use ($queue): void { + $queue->finalShutdown(); + }, + ])); + $queue->add(new QueuedEvent($this->event('event-1'))); + $queue->add(new QueuedEvent($this->event('event-2'))); + + $queue->pump(); + + $this->assertSame([], $this->logger->messages()); + $this->assertSame(['queueLength' => 0, 'activeRequests' => 0], $queue->stats(), 'the pass pumped the transport'); + $names = $this->resourceNames($transport); + sort($names); + $this->assertSame(['event-0', 'event-1', 'event-2'], $names, 'each event delivered once'); + } + + /** The same drain during a hook of a flush's prepare pass: the pass stops at the empty queue, and the flush delivers. */ + public function testASignalDrainDuringAHookEndsThePreparePassCleanly(): void + { + $transport = new RecordingTransport(complete: false); + $queue = new EventQueue(transport: $transport); + for ($i = 0; $i < 5; ++$i) { + $queue->add(new QueuedEvent($this->event('event-' . $i))); + } + $queue->add(new QueuedEvent($this->event('event-5'), pending: [ + static function () use ($queue, $transport): void { + $transport->complete = true; // the API answers the five in flight, so the drain empties the queue + $queue->finalShutdown(); + }, + ])); + $queue->add(new QueuedEvent($this->event('event-6'))); + + $delivered = $queue->flush(1.0); + + $this->assertSame([], $this->logger->messages()); + $this->assertTrue($delivered); + $this->assertSame(['queueLength' => 0, 'activeRequests' => 0], $queue->stats()); + $names = $this->resourceNames($transport); + sort($names); + $this->assertSame(['event-0', 'event-1', 'event-2', 'event-3', 'event-4', 'event-5', 'event-6'], $names, 'each event delivered once'); + } + /** The process ends with `shutdown()`: it spends its budget on the events it can start, not on hooks of the others. */ public function testShutdownRunsNoHookOfAnEventThatFindsNoFreeSlot(): void { From 9d7985503c6832d229a09777149dd6db9b69a3bb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 15:46:06 +0000 Subject: [PATCH 06/55] test: name the no-context seam test after what it proves on the command line --- tests/Integration/McpSdk/RequestContextSeamTest.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Integration/McpSdk/RequestContextSeamTest.php b/tests/Integration/McpSdk/RequestContextSeamTest.php index 0c10f9f..5f7b7c4 100644 --- a/tests/Integration/McpSdk/RequestContextSeamTest.php +++ b/tests/Integration/McpSdk/RequestContextSeamTest.php @@ -71,7 +71,12 @@ public function testEachRequestOfAWorkerCarriesItsOwnHeaders(): void $this->assertSame(['ann', 'bob', 'cid'], array_map(static fn(Event $e): mixed => JsonAssert::at($e->parameters, 'extra', 'requestInfo', 'headers', 'x-user'), $events)); } - public function testWithoutAContextTheSapiGlobalsStillCount(): void + /** + * Without a context, a client keeps the reader's default, the SAPI globals: on the command line they hold no HTTP + * request. `HttpEndToEndTest::testAHandshakeCallIsTrackedEndToEnd()` proves that the same default records + * the request from `$_SERVER` under a web SAPI. + */ + public function testWithoutAContextACommandLineCallRecordsNoRequestInfo(): void { $fixture = new TrackingFixture(); $builder = TrackingFixture::builder(); @@ -83,6 +88,6 @@ public function testWithoutAContextTheSapiGlobalsStillCount(): void $extra = JsonAssert::at($fixture->event()->parameters, 'extra'); $this->assertInstanceOf(\stdClass::class, $extra); - $this->assertObjectNotHasProperty('requestInfo', $extra); // PHPUnit runs on the CLI + $this->assertObjectNotHasProperty('requestInfo', $extra); } } From 84eac838166006f8ac7a903da9b393a9575185d6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 16:00:53 +0000 Subject: [PATCH 07/55] refactor: move the bridge's typed key reads into core so the Symfony bundle can share them --- .../Support}/BridgeKeys.php | 50 ++++++++++--------- src/Laravel/Internal/ServerTracker.php | 6 ++- .../Support}/BridgeKeysTest.php | 35 +++++++------ tests/Unit/Laravel/ConfigFileTest.php | 3 +- 4 files changed, 53 insertions(+), 41 deletions(-) rename src/{Laravel/Internal => Internal/Support}/BridgeKeys.php (52%) rename tests/Unit/{Laravel => Internal/Support}/BridgeKeysTest.php (62%) diff --git a/src/Laravel/Internal/BridgeKeys.php b/src/Internal/Support/BridgeKeys.php similarity index 52% rename from src/Laravel/Internal/BridgeKeys.php rename to src/Internal/Support/BridgeKeys.php index 80f56a0..19b5403 100644 --- a/src/Laravel/Internal/BridgeKeys.php +++ b/src/Internal/Support/BridgeKeys.php @@ -2,20 +2,18 @@ declare(strict_types=1); -namespace AgentCat\Laravel\Internal; +namespace AgentCat\Internal\Support; /** - * The keys of config/agentcat.php that the bridge reads itself (not options), read with their types (Review Focus - * 3): a value of a wrong type logs one line through `$warn`, and the default is used, so a typo never goes unseen. A - * string `exclude` is a list of one class. Null is an unset value (`env()` of an unset variable): the default, no line. + * The keys of a framework bridge's `agentcat` configuration that the bridge reads itself (not options), read with their + * types (plan E1 Review Focus 3): a value of a wrong type logs one line through `$warn`, and the default is used, so a + * typo never goes unseen. Null is an unset value (`env()` of an unset variable, `%env(default::…)%` in Symfony): the + * default, no line. The lines name the key as the customer wrote it, `agentcat.`. * * @internal */ final readonly class BridgeKeys { - /** config/agentcat.php's `exclude`: the default of a value of a wrong type too. */ - public const array DEFAULT_EXCLUDE = ['Laravel\Boost\Mcp\Boost']; - private const string WRONG_TYPE = 'WARN: AgentCat option "agentcat.%s" must be %s, got %s; the default is used'; private const string WRONG_ENTRY = 'WARN: AgentCat option "agentcat.%s.%s" must be %s, got %s; the entry is ignored'; @@ -29,7 +27,7 @@ public function __construct( private \Closure $warn, ) {} - /** `project_id`, `log_channel`, `diagnostics_endpoint` or `diagnostics_token`: a string, or null. */ + /** A string, or null: a project ID, a log channel, a service ID, the diagnostics endpoint or token. */ public function string(string $key): ?string { $value = $this->config[$key] ?? null; @@ -42,35 +40,39 @@ public function string(string $key): ?string } /** - * The server classes AgentCat never tracks. + * A list of strings, such as the server classes AgentCat never tracks. A string is a list of one; a null entry is + * unset. + * + * @param list $default the value when the key is unset, and when it is of a wrong type * * @return list */ - public function exclude(): array + public function strings(string $key, array $default): array { - $value = $this->config['exclude'] ?? self::DEFAULT_EXCLUDE; + $value = $this->config[$key] ?? $default; if (is_string($value)) { return [$value]; } if (!is_array($value)) { - $this->wrongType('exclude', 'array or string', $value); + $this->wrongType($key, 'array or string', $value); - return self::DEFAULT_EXCLUDE; + return $default; } - $classes = []; - foreach ($value as $index => $class) { - if (is_string($class)) { - $classes[] = $class; - } elseif ($class !== null) { - ($this->warn)(sprintf(self::WRONG_ENTRY, 'exclude', $index, 'string', get_debug_type($class))); + $strings = []; + foreach ($value as $index => $entry) { + if (is_string($entry)) { + $strings[] = $entry; + } elseif ($entry !== null) { + ($this->warn)(sprintf(self::WRONG_ENTRY, $key, $index, 'string', get_debug_type($entry))); } } - return $classes; + return $strings; } /** - * Server class => project ID; a null project is the configured `project_id`. + * `projects`: a server (its class in Laravel, its name in Symfony) => project ID; a null project is the configured + * `project_id`. * * @return array */ @@ -83,11 +85,11 @@ public function projects(): array return []; } $projects = []; - foreach ($value as $class => $project) { + foreach ($value as $server => $project) { if ($project === null || is_string($project)) { - $projects[$class] = $project; + $projects[$server] = $project; } else { - ($this->warn)(sprintf(self::WRONG_ENTRY, 'projects', $class, 'string or null', get_debug_type($project))); + ($this->warn)(sprintf(self::WRONG_ENTRY, 'projects', $server, 'string or null', get_debug_type($project))); } } diff --git a/src/Laravel/Internal/ServerTracker.php b/src/Laravel/Internal/ServerTracker.php index f888065..e015079 100644 --- a/src/Laravel/Internal/ServerTracker.php +++ b/src/Laravel/Internal/ServerTracker.php @@ -11,6 +11,7 @@ use AgentCat\Internal\Engine\TrackedServer; use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; +use AgentCat\Internal\Support\BridgeKeys; use AgentCat\Internal\Support\ConfigOptions; use AgentCat\Internal\Support\EnabledGate; use AgentCat\Internal\Support\ErrorText; @@ -35,6 +36,9 @@ final class ServerTracker /** @var list The keys of config/agentcat.php that the bridge reads itself; the others are options. */ public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude', 'projects', 'log_channel', 'diagnostics_endpoint', 'diagnostics_token']; + /** config/agentcat.php's `exclude`: the default of a value of a wrong type too. */ + public const array DEFAULT_EXCLUDE = ['Laravel\Boost\Mcp\Boost']; + private ?Client $client = null; private ?Options $options = null; @@ -51,7 +55,7 @@ public function track(Server $server, Container $app): void $keys = new BridgeKeys($config, static function (string $line) use ($bad): void { $bad->append($line); }); - if ($this->matches($server, $keys->exclude()) !== null) { + if ($this->matches($server, $keys->strings('exclude', self::DEFAULT_EXCLUDE)) !== null) { return; // an excluded server touches neither the log nor diagnostics } $this->adoptLogger($app, $keys->string('log_channel')); diff --git a/tests/Unit/Laravel/BridgeKeysTest.php b/tests/Unit/Internal/Support/BridgeKeysTest.php similarity index 62% rename from tests/Unit/Laravel/BridgeKeysTest.php rename to tests/Unit/Internal/Support/BridgeKeysTest.php index f7a6f25..e0da5ff 100644 --- a/tests/Unit/Laravel/BridgeKeysTest.php +++ b/tests/Unit/Internal/Support/BridgeKeysTest.php @@ -2,14 +2,17 @@ declare(strict_types=1); -namespace AgentCat\Tests\Unit\Laravel; +namespace AgentCat\Tests\Unit\Internal\Support; -use AgentCat\Laravel\Internal\BridgeKeys; +use AgentCat\Internal\Support\BridgeKeys; use PHPUnit\Framework\TestCase; -/** M2: the bridge's own keys of config/agentcat.php, with their types. */ +/** M2: a framework bridge's own keys of its `agentcat` configuration, with their types. */ final class BridgeKeysTest extends TestCase { + /** Laravel's `exclude` default. */ + private const array DEFAULT = ['Laravel\Boost\Mcp\Boost']; + /** @var \ArrayObject */ private \ArrayObject $lines; @@ -30,29 +33,33 @@ public function testAStringKeyIsAStringOrNull(): void $this->assertSame(['WARN: AgentCat option "agentcat.diagnostics_token" must be string or null, got int; the default is used'], $this->lines->getArrayCopy()); } - public function testExcludeIsTheDefaultWhenUnsetOrNull(): void + public function testAListIsTheDefaultWhenUnsetOrNull(): void { - $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys([])->exclude()); - $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys(['exclude' => null])->exclude()); - $this->assertSame([], $this->keys(['exclude' => []])->exclude()); + $this->assertSame(self::DEFAULT, $this->keys([])->strings('exclude', self::DEFAULT)); + $this->assertSame(self::DEFAULT, $this->keys(['exclude' => null])->strings('exclude', self::DEFAULT)); + $this->assertSame([], $this->keys(['exclude' => []])->strings('exclude', self::DEFAULT)); $this->assertSame([], $this->lines->getArrayCopy()); } - public function testAStringExcludeIsAListOfOneClass(): void + public function testAStringIsAListOfOne(): void { - $this->assertSame(['App\Mcp\Internal'], $this->keys(['exclude' => 'App\Mcp\Internal'])->exclude()); + $this->assertSame(['App\Mcp\Internal'], $this->keys(['exclude' => 'App\Mcp\Internal'])->strings('exclude', self::DEFAULT)); $this->assertSame([], $this->lines->getArrayCopy()); } - public function testAnExcludeOfAnotherTypeIsTheDefaultWithOneLine(): void + public function testAListOfAnotherTypeIsTheDefaultWithOneLine(): void { - $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $this->keys(['exclude' => 1.5])->exclude()); - $this->assertSame(['WARN: AgentCat option "agentcat.exclude" must be array or string, got float; the default is used'], $this->lines->getArrayCopy()); + $this->assertSame(self::DEFAULT, $this->keys(['exclude' => 1.5])->strings('exclude', self::DEFAULT)); + $this->assertSame(['App\A', 'App\B'], $this->keys(['servers' => true])->strings('servers', ['App\A', 'App\B'])); + $this->assertSame([ + 'WARN: AgentCat option "agentcat.exclude" must be array or string, got float; the default is used', + 'WARN: AgentCat option "agentcat.servers" must be array or string, got bool; the default is used', + ], $this->lines->getArrayCopy()); } - public function testAnExcludeEntryOfAnotherTypeIsIgnoredWithOneLineAndANullOneSilently(): void + public function testAListEntryOfAnotherTypeIsIgnoredWithOneLineAndANullOneSilently(): void { - $this->assertSame(['App\A', 'App\B'], $this->keys(['exclude' => ['App\A', null, ['App\C'], 'x' => 'App\B']])->exclude()); + $this->assertSame(['App\A', 'App\B'], $this->keys(['exclude' => ['App\A', null, ['App\C'], 'x' => 'App\B']])->strings('exclude', self::DEFAULT)); $this->assertSame(['WARN: AgentCat option "agentcat.exclude.2" must be string, got array; the entry is ignored'], $this->lines->getArrayCopy()); } diff --git a/tests/Unit/Laravel/ConfigFileTest.php b/tests/Unit/Laravel/ConfigFileTest.php index 79ef540..8cfdfe6 100644 --- a/tests/Unit/Laravel/ConfigFileTest.php +++ b/tests/Unit/Laravel/ConfigFileTest.php @@ -5,7 +5,6 @@ namespace AgentCat\Tests\Unit\Laravel; use AgentCat\Internal\Support\ConfigOptions; -use AgentCat\Laravel\Internal\BridgeKeys; use AgentCat\Laravel\Internal\ServerTracker; use PHPUnit\Framework\TestCase; @@ -19,7 +18,7 @@ public function testTheConfigFileHasNoClosureAndOnlyKnownKeys(): void array_walk_recursive($config, function (mixed $value): void { $this->assertNotInstanceOf(\Closure::class, $value); // config:cache }); - $this->assertSame(BridgeKeys::DEFAULT_EXCLUDE, $config['exclude'] ?? null); // also the default of a bad value + $this->assertSame(ServerTracker::DEFAULT_EXCLUDE, $config['exclude'] ?? null); // also the default of a bad value $this->assertSame([], array_diff(array_keys($config), [...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)])); } From bffa8a16fb08132cf67febfd8a7c2414fc4f40bb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 16:21:16 +0000 Subject: [PATCH 08/55] build: raise the symfony/clock dev floor so an MCP request on the PHP 8.5 lowest set triggers no deprecation --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 9cb7365..54172b0 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "shipmonk/composer-dependency-analyser": "^1.8.4", "spaze/phpstan-disallowed-calls": "^4.15", "symfony/cache-contracts": "^2.5.3 || ^3.4.2", + "symfony/clock": "^6.4.3 || ^7.0.3 || ^8.0", "symfony/config": "^7.3.4 || ^8.0", "symfony/dependency-injection": "^7.3.4 || ^8.0", "symfony/event-dispatcher-contracts": "^2.5.3 || ^3.4.2", From 719bd343d5439b51bca6389437a440807a8c821d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 16:48:29 +0000 Subject: [PATCH 09/55] feat: track every symfony/mcp-bundle server through AgentCatBundle --- composer-dependency-analyser.php | 10 +- phpstan.no-bundle.neon | 2 +- src/Symfony/AgentCatBundle.php | 129 +++++ src/Symfony/Internal/BuilderConfigurator.php | 45 ++ .../DependencyInjection/TrackBuildersPass.php | 87 +++ src/Symfony/Internal/ServerTracker.php | 152 +++++ .../Symfony/BundleConfigurationTest.php | 224 ++++++++ tests/Integration/Symfony/BundleTest.php | 536 ++++++++++++++++++ tests/Support/FileTree.php | 1 + tests/Support/Symfony/CustomerCallHandler.php | 42 ++ tests/Support/Symfony/FailingHook.php | 16 + tests/Support/Symfony/SymfonyHttp.php | 194 +++++++ tests/Support/Symfony/SymfonyTestCase.php | 11 +- tests/Support/Symfony/TestKernel.php | 16 +- tests/Unit/Symfony/AgentCatBundleTest.php | 163 ++++++ .../Unit/Symfony/BuilderConfiguratorTest.php | 72 +++ tests/Unit/Symfony/TrackBuildersPassTest.php | 127 +++++ 17 files changed, 1817 insertions(+), 10 deletions(-) create mode 100644 src/Symfony/AgentCatBundle.php create mode 100644 src/Symfony/Internal/BuilderConfigurator.php create mode 100644 src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php create mode 100644 src/Symfony/Internal/ServerTracker.php create mode 100644 tests/Integration/Symfony/BundleConfigurationTest.php create mode 100644 tests/Integration/Symfony/BundleTest.php create mode 100644 tests/Support/Symfony/CustomerCallHandler.php create mode 100644 tests/Support/Symfony/FailingHook.php create mode 100644 tests/Support/Symfony/SymfonyHttp.php create mode 100644 tests/Unit/Symfony/AgentCatBundleTest.php create mode 100644 tests/Unit/Symfony/BuilderConfiguratorTest.php create mode 100644 tests/Unit/Symfony/TrackBuildersPassTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 5e491d2..ad9c7ef 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -29,12 +29,14 @@ // event() helpers. PHPat keeps Laravel out of these files (LayeringTest). ->ignoreErrorsOnPackageAndPaths('laravel/framework', [__DIR__ . '/src/Internal/Support/TestMode.php', __DIR__ . '/src/Internal/Support/BridgeConfig.php', __DIR__ . '/src/Client.php'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) // Symfony is optional (01 AC-90): dev dependencies used only by the Symfony bundle. - ->ignoreErrorsOnPackageAndPath('symfony/http-foundation', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + ->ignoreErrorsOnPackagesAndPaths(['symfony/config', 'symfony/dependency-injection', 'symfony/http-foundation', 'symfony/http-kernel'], [__DIR__ . '/src/Symfony'], [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // The bundle configures the Mcp\Server\Builder services of symfony/mcp-bundle, which requires mcp/sdk. + ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) + // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it. + ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY]) // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Routing arrives with symfony/framework-bundle and symfony/mcp-bundle; the test kernel imports the bundle's MCP routes with it. - ->ignoreErrorsOnPackageAndPath('symfony/routing', __DIR__ . '/tests/Support/Symfony/TestKernel.php', [ErrorType::SHADOW_DEPENDENCY]) - // The test kernel registers AgentCat's Symfony bundle, whose class src/Symfony/AgentCatBundle.php is not written yet. Remove this line with it. - ->ignoreUnknownClasses(['AgentCat\Symfony\AgentCatBundle']); + ->ignoreErrorsOnPackageAndPath('symfony/routing', __DIR__ . '/tests/Support/Symfony/TestKernel.php', [ErrorType::SHADOW_DEPENDENCY]); diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon index 4104ec5..e8f256d 100644 --- a/phpstan.no-bundle.neon +++ b/phpstan.no-bundle.neon @@ -6,6 +6,6 @@ parameters: # (?) marks a path that PHPStan accepts before its tests exist. excludePaths: - tests/Support/Symfony - - tests/Integration/Symfony (?) + - tests/Integration/Symfony - tests/Contract/Symfony - tests/Integration/Conformance/SymfonyConformanceTest.php (?) diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php new file mode 100644 index 0000000..cec1c3d --- /dev/null +++ b/src/Symfony/AgentCatBundle.php @@ -0,0 +1,129 @@ + true, 'enable_tracing' => true, 'enable_tool_call_context' => true]; + + /** The configuration's root key is `agentcat` (D55), not the `agent_cat` that Symfony derives from the class name. */ + public function __construct() + { + $this->extensionAlias = 'agentcat'; // set here: #[\Override] on a redeclared property needs PHP 8.5 + } + + #[\Override] + public function configure(DefinitionConfigurator $definition): void + { + $children = $definition->rootNode()->children(); + $children->scalarNode('enabled')->defaultValue('%env(default::AGENTCAT_ENABLED)%')->info('On or off for every MCP server and every AgentCat call: true or false. Unset: on, except while your tests run.')->end(); + $children->scalarNode('project_id')->defaultValue('%env(default::AGENTCAT_PROJECT_ID)%')->info('Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run.')->end(); + $children->arrayNode('exclude_servers')->info('Servers AgentCat never tracks: names of mcp.servers entries. A string is one name.')->beforeNormalization()->castToArray()->end()->scalarPrototype()->end()->defaultValue([])->end(); + $children->arrayNode('projects')->info('Another project for some servers: mcp.servers name => project ID. A null project is project_id.')->useAttributeAsKey('server')->scalarPrototype()->end()->defaultValue([])->end(); + $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp. Unset: their own file, agentcat.log in the log directory.')->end(); + $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL is the TypeScript SDK\'s name.')->end(); + foreach (self::BOOLS as $key => $default) { + $children->scalarNode($key)->defaultValue($default)->end(); + } + $children->scalarNode('custom_context_description')->defaultNull()->end(); + $children->scalarNode('enable_agent_tracking')->defaultFalse()->end(); + foreach (self::HOOKS as $hook) { + $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, resolved when the hook runs.')->end(); + } + $children->variableNode('exporters')->defaultNull()->info('Your own telemetry tools: name => {type: otlp|datadog|sentry|posthog, …}. A ~ entry is skipped.')->end(); + $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS is the TypeScript SDK\'s name.')->end(); + $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end(); + $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end(); + $children->scalarNode('disable_signal_handlers')->defaultFalse()->end(); + } + + /** @param array $config */ + #[\Override] + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + $excluded = $config['exclude_servers'] ?? []; + $builder->setParameter('agentcat.exclude_servers', is_array($excluded) ? $excluded : []); + $services = []; + foreach ([...self::HOOKS, 'logger'] as $key) { + $id = $config[$key] ?? null; + if (is_string($id) && $id !== '') { + // A missing service is left out of the locator, and the container compiles: the hook guard logs it when + // the hook runs, the tracker when it chooses the logger (D32). + $services[$id] = new Reference($id, ContainerInterface::IGNORE_ON_INVALID_REFERENCE); + } + } + // The configuration as a service: its environment variables resolve when AgentCat reads it, never at compile. + $builder->register('agentcat.config', \ArrayObject::class)->setArguments([$config])->setPublic(true); + $builder->register('agentcat.hooks', ServiceLocator::class)->setArguments([$services])->addTag('container.service_locator'); // and the logger + $builder->register('agentcat.server_tracker', ServerTracker::class)->setArguments([ + new ServiceClosureArgument(new Reference('agentcat.config')), + new Reference('agentcat.hooks'), + new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE), + '%kernel.logs_dir%/agentcat.log', + ]); + } + + #[\Override] + public function build(ContainerBuilder $container): void + { + $container->addCompilerPass(new TrackBuildersPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + } + + /** + * `agentcat.enabled` decides for every client, the shared one too, before AGENTCAT_ENABLED (plan E1 header conflict + * 12): a literal, a parameter or a secret of the configuration is not in the environment. Unset or unknown: no + * switch. A fake still records. The same for every other name AgentCat reads, and diagnostics get the kernel's + * environment. The tracker logs the bad values; nothing is logged here, and boot tracks nothing. + */ + #[\Override] + public function boot(): void + { + if (!$this->container instanceof ContainerInterface) { + return; // Symfony gives a bundle its container before it boots it + } + $config = []; + try { + $service = $this->container->get('agentcat.config'); + $config = $service instanceof \ArrayObject ? $service->getArrayCopy() : []; + } catch (\Throwable) { + // A variable without a default is not set: the tracker logs it when it tracks a server (D32). + } + TestMode::configure(TestMode::word($config['enabled'] ?? null)); + BridgeConfig::configure([ + 'AGENTCAT_PROJECT_ID' => $this->string($config['project_id'] ?? null), + 'AGENTCAT_API_URL' => $this->string($config['api_url'] ?? null), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->string($config['diagnostics_endpoint'] ?? null), + 'AGENTCAT_DIAGNOSTICS_TOKEN' => $this->string($config['diagnostics_token'] ?? null), + 'APP_ENV' => $this->string($this->container->getParameter('kernel.environment')), + ], ConfigOptions::diagnosticsOff($config['disable_diagnostics'] ?? null)); + } + + private function string(mixed $value): ?string + { + return is_string($value) ? $value : null; + } +} diff --git a/src/Symfony/Internal/BuilderConfigurator.php b/src/Symfony/Internal/BuilderConfigurator.php new file mode 100644 index 0000000..1938da6 --- /dev/null +++ b/src/Symfony/Internal/BuilderConfigurator.php @@ -0,0 +1,45 @@ + $requestHandlers the first argument of each `addRequestHandlers` call the pass removed: the + * tagged request handlers + * @param mixed $previous the configurator the builder had, a callable once the container resolves it + */ + public function __construct( + private string $server, + private ServerTracker $tracker, + private array $requestHandlers, + private mixed $previous = null, + ) {} + + public function configure(Builder $builder): void + { + if (is_callable($this->previous)) { + ($this->previous)($builder); + } + $this->tracker->track($builder, $this->server); // never throws: the customer's handlers are added in any case + foreach ($this->requestHandlers as $handlers) { + if (is_iterable($handlers)) { + /** @var iterable> $handlers the services tagged mcp.request_handler */ + $builder->addRequestHandlers($handlers); + } + } + } +} diff --git a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php new file mode 100644 index 0000000..2229507 --- /dev/null +++ b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php @@ -0,0 +1,87 @@ +hasParameter('agentcat.exclude_servers') ? $container->getParameter('agentcat.exclude_servers') : []; + $excluded = is_array($excluded) ? $excluded : []; + $builders = []; + foreach ($container->findTaggedServiceIds('mcp.server.builder') as $id => $tags) { + $attributes = $tags[0] ?? null; + $server = is_array($attributes) ? ($attributes['server'] ?? null) : null; + if (is_string($server)) { + $builders[] = [$id, $server]; + } + } + $servers = array_column($builders, 1); + foreach ($builders as [$id, $server]) { + if (!$this->excluded($server, $excluded, $servers)) { + $this->track($container, $id, $server); + } + } + } + + private function track(ContainerBuilder $container, string $id, string $server): void + { + $builder = $container->getDefinition($id); + $handlers = []; + $calls = []; + foreach ($builder->getMethodCalls() as $call) { + $method = is_array($call) ? ($call[0] ?? null) : null; + if (is_string($method) && strtolower($method) === 'addrequesthandlers') { + $arguments = $call[1] ?? null; + $handlers[] = is_array($arguments) ? ($arguments[0] ?? []) : []; // added after AgentCat's own handlers (header conflict 1) + } else { + $calls[] = $call; + } + } + $builder->setMethodCalls($calls); + $configurator = 'agentcat.builder_configurator.' . $server; + $container->register($configurator, BuilderConfigurator::class) + ->setArguments([$server, new Reference('agentcat.server_tracker'), $handlers, $builder->getConfigurator()]); + $builder->setConfigurator([new Reference($configurator), 'configure']); + } + + /** + * Whether `exclude_servers` names the server. symfony/config writes a key `my-server` of `mcp.servers` as + * `my_server` (a key that holds an underscore, or whose other spelling is a key too, stays as written), so an entry + * names a server by either spelling. + * + * @param array $excluded + * @param list $servers every server name + */ + private function excluded(string $server, array $excluded, array $servers): bool + { + foreach ($excluded as $entry) { + if (is_string($entry) && $server === (in_array($entry, $servers, true) || str_contains($entry, '_') ? $entry : str_replace('-', '_', $entry))) { + return true; + } + } + + return false; + } +} diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php new file mode 100644 index 0000000..22d0fd7 --- /dev/null +++ b/src/Symfony/Internal/ServerTracker.php @@ -0,0 +1,152 @@ + The keys of the agentcat configuration that the bundle reads itself; the others are options. */ + public const array BRIDGE_KEYS = ['enabled', 'project_id', 'exclude_servers', 'projects', 'logger', 'diagnostics_endpoint', 'diagnostics_token']; + + private const string NO_LOGGER = 'WARN: AgentCat option "agentcat.logger" must be the ID of a PSR-3 logger service, got "%s"; the default is used'; + + private ?Client $client = null; + + private ?Options $options = null; + + /** @var array{project: ?string, projects: array, logger: ?string}|null the bundle's keys, read once */ + private ?array $keys = null; + + /** + * @param \Closure(): mixed $config the processed agentcat configuration, an `\ArrayObject`, read when called + * @param ContainerInterface $services the services the configuration names, by ID: the hooks and the logger (a + * missing one is left out) + * @param string $logFile the file of AgentCat's lines without a `logger` (D61) + */ + public function __construct( + private readonly \Closure $config, + private readonly ContainerInterface $services, + private readonly ?RequestStack $requests, + private readonly string $logFile, + ) {} + + public function track(Builder $builder, string $server): void + { + try { + $config = ($this->config)(); + $config = $config instanceof \ArrayObject ? $config->getArrayCopy() : []; + $keys = $this->keys; + $lines = []; + if ($keys === null) { + [$keys, $lines] = $this->keys($config); + $this->keys = $keys; + } + $this->adoptLogger($keys['logger']); + foreach ($lines as $line) { + Log::write($line); + } + $client = AgentCat::client(); + // A fake records every server (header conflict 6); the shared client is never replaced. + if (!FakeLog::of($client) instanceof FakeLog) { + $client = $this->client ??= new Client(ClientSeams::of( + EventQueue::global(), + EnabledGate::of($config['enabled'] ?? null), + httpContext: $this->requests instanceof RequestStack ? RequestStackContext::of($this->requests) : null, + )); + } + $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->services->get(...)); + // A null entry (`%env(default::…)%` of an unset variable) is the configured project; without one, the + // client reads AGENTCAT_PROJECT_ID (D36). + $client->track($builder, $keys['projects'][$server] ?? $keys['project'], $this->options); + } catch (\Throwable $e) { + $this->adoptLogger(null); + Log::write('Warning: Failed to track server - ' . ErrorText::of($e)); + } + } + + /** + * The bundle's own keys, with their types, and the line of each bad value. + * + * @param array $config + * + * @return array{array{project: ?string, projects: array, logger: ?string}, array} + */ + private function keys(array $config): array + { + /** @var \ArrayObject $lines */ + $lines = new \ArrayObject(); + $read = new BridgeKeys($config, static function (string $line) use ($lines): void { + $lines->append($line); + }); + $keys = ['project' => $read->string('project_id'), 'projects' => $read->projects(), 'logger' => $read->string('logger')]; + $read->string('diagnostics_endpoint'); // read at boot (BridgeConfig); here for the line of a bad value + $read->string('diagnostics_token'); + + return [$keys, $lines->getArrayCopy()]; + } + + /** + * D61: AgentCat's lines go to the configured logger service, else to their own file, + * `%kernel.logs_dir%/agentcat.log` (TypeScript writes `~/mcpcat.log`), not to the Builder's `mcp` logger (D30, D38): + * under PHP-FPM every request builds the container and tracks the Builder again, so the setup lines repeat per + * request, and they stay out of the application's log and its error trackers. The first adopted logger of the + * process wins, and a fake's diversion keeps priority; after a fake, the next server routes the lines again. + */ + private function adoptLogger(?string $id): void + { + if (Log::hasLogger()) { + return; + } + $named = $id !== null && $id !== ''; + $logger = null; + if ($named) { + try { + $logger = $this->services->get($id); + } catch (\Throwable) { + // not a service: the line below says so + } + } + if ($logger instanceof LoggerInterface) { + Log::adopt($logger); + + return; + } + $directory = dirname($this->logFile); + if (!is_dir($directory)) { + @mkdir($directory, 0o777, true); // Symfony creates its log directory only when something writes there + } + Log::adopt(new FileLogger($this->logFile)); + if ($named) { + Log::write(sprintf(self::NO_LOGGER, $id)); + } + } +} diff --git a/tests/Integration/Symfony/BundleConfigurationTest.php b/tests/Integration/Symfony/BundleConfigurationTest.php new file mode 100644 index 0000000..ad5f004 --- /dev/null +++ b/tests/Integration/Symfony/BundleConfigurationTest.php @@ -0,0 +1,224 @@ + true, 'project_id' => self::PROJECT]; + + private MemoryLogger $logger; + + /** The diagnostics variables of the process; a test turns diagnostics on as a platform variable can. */ + private ?Environment $diagnosticsEnvironment = null; + + /** The collector the configuration names for diagnostics: a closed local port (ruling P8 of Plan E1). */ + private string $configuredCollector = ''; + + #[\Override] + protected function setUp(): void + { + $this->diagnosticsEnvironment = Environment::clear('AGENTCAT_DISABLE_DIAGNOSTICS', 'DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'MCPCAT_API_URL', 'APP_ENV'); + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=true'); // tests/bootstrap.php's value: a test that wants diagnostics says so + $this->configuredCollector = MockCollector::closedPortUrl() . '/configured'; + Diagnostics::reset(); // the first init() of the process decides: a test of another class can have made it + parent::setUp(); + Log::setLogger($this->logger = new MemoryLogger()); // nothing goes to agentcat.log in the test HOME + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->diagnosticsEnvironment?->restore(); + } + + /** D44: the configured switch stops the shared client, also where the process turns AgentCat on. */ + #[MaximumDuration(1000)] + public function testAConfiguredFalseStopsTheSharedClient(): void + { + putenv('AGENTCAT_ENABLED=true'); + $this->kernel(agentcat: ['enabled' => false, 'project_id' => self::PROJECT]); + + AgentCat::publishCustomEvent(self::SESSION, self::PROJECT); + + $this->assertSame([], $this->deliveries()); + $this->assertSame([], $this->logger->messages()); + } + + #[MaximumDuration(1000)] + public function testAConfiguredTrueTurnsTheSharedClientOnInATestEnvironment(): void + { + $this->kernel(agentcat: self::ON); + + AgentCat::publishCustomEvent(self::SESSION, self::PROJECT); + + $this->assertSame([self::SESSION], $this->fields('session_id')); + $this->assertContains(self::PUBLISHED, $this->logger->messages()); + } + + /** `enabled` from `%env(default::AGENTCAT_ENABLED)%` with the variable unset: the process decides. */ + #[MaximumDuration(1000)] + public function testWithoutAConfiguredSwitchTheProcessDecides(): void + { + $this->kernel(agentcat: ['project_id' => self::PROJECT]); + putenv('AGENTCAT_ENABLED=true'); + + AgentCat::publishCustomEvent(self::SESSION, self::PROJECT); + + $this->assertSame([self::SESSION], $this->fields('session_id')); + } + + #[MaximumDuration(1000)] + public function testAnUnknownConfiguredWordLeavesTheProcessToDecide(): void + { + $this->kernel(agentcat: ['enabled' => 'maybe', 'project_id' => self::PROJECT]); + putenv('AGENTCAT_ENABLED=true'); + + AgentCat::publishCustomEvent(self::SESSION, self::PROJECT); + + $this->assertSame([self::SESSION], $this->fields('session_id')); + } + + /** An mcp/sdk Builder of the application, tracked with the shared client and no project ID, gets the configured one. */ + #[MaximumDuration(1000)] + public function testTheSharedClientTracksWithTheConfiguredProjectId(): void + { + $this->kernel(agentcat: self::ON); + + AgentCat::track(TrackingFixture::builder()); + + $started = array_values(array_filter($this->logger->messages(), static fn(string $line): bool => str_starts_with($line, 'AgentCat setup started'))); + $this->assertCount(1, $started); + $this->assertStringStartsWith('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder', $started[0]); + } + + #[MaximumDuration(1000)] + public function testTheSharedClientSendsToTheConfiguredApiUrl(): void + { + $this->kernel(agentcat: [...self::ON, 'api_url' => 'https://configured.example.test']); + + AgentCat::track(TrackingFixture::builder()); + AgentCat::publishCustomEvent(self::SESSION, self::PROJECT); + + $this->assertSame(['https://configured.example.test/v1/public/events/'], array_map(static fn(Delivery $delivery): string => $delivery->url, $this->deliveries())); + } + + #[MaximumDuration(1000)] + public function testDiagnosticsGoToTheConfiguredEndpoint(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->kernel(agentcat: [...self::ON, 'diagnostics_endpoint' => $this->configuredCollector]); + + $this->assertSame($this->configuredCollector . '/v1/logs', $this->diagnosticsRequest()->url); + } + + #[MaximumDuration(1000)] + public function testDiagnosticsCarryTheConfiguredToken(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->kernel(agentcat: [...self::ON, 'diagnostics_token' => 'dgk_configured']); + + $this->assertSame('Bearer dgk_configured', $this->diagnosticsRequest()->headers['Authorization'] ?? null); + } + + /** The kernel's environment is the deployment environment of diagnostics (Laravel: `app.env`). */ + #[MaximumDuration(1000)] + public function testDiagnosticsCarryTheKernelEnvironment(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->kernel(agentcat: self::ON); + + $attributes = JsonAssert::at(Json::decode($this->diagnosticsRequest()->body), 'resourceLogs', 0, 'resource', 'attributes'); + $this->assertIsList($attributes); + $pairs = array_column(array_map(static fn(mixed $attribute): array => [JsonAssert::at($attribute, 'key'), JsonAssert::at($attribute, 'value', 'stringValue')], $attributes), 1, 0); + + $this->assertSame('test', $pairs['deployment.environment'] ?? null); + } + + #[MaximumDuration(1000)] + public function testWithoutAConfiguredOptOutTheDiagnosticsRun(): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->kernel(agentcat: self::ON); + + $this->assertSame(getenv('AGENTCAT_DIAGNOSTICS_ENDPOINT') . '/v1/logs', $this->diagnosticsRequest()->url); // tests/bootstrap.php's closed port + } + + /** @return iterable */ + public static function optOuts(): iterable + { + yield 'a value' => [true]; + yield 'a word' => ['yes']; + yield 'one value of a list' => [[null, 'on']]; + } + + /** D50: off wins; the configuration turns diagnostics off where the process turns them on. */ + #[DataProvider('optOuts')] + #[MaximumDuration(1000)] + public function testAConfiguredOptOutKeepsTheSharedClientOff(mixed $optOut): void + { + putenv('AGENTCAT_DISABLE_DIAGNOSTICS=false'); + $this->kernel(agentcat: [...self::ON, 'disable_diagnostics' => $optOut]); + + AgentCat::track(TrackingFixture::builder()); + + $this->assertFalse(Diagnostics::enabled()); + } + + /** + * The value of a field of each event the API got, after a flush. + * + * @return list + */ + private function fields(string $field): array + { + return array_map(static fn(Delivery $delivery): mixed => JsonAssert::at(Json::decode($delivery->body), $field), $this->deliveries()); + } + + /** The one diagnostics request after the shared client tracked a Builder and the queue shut down. */ + private function diagnosticsRequest(): HttpRequest + { + $http = new RecordingHttpChannel(); + EventQueue::setGlobal(new EventQueue(transport: $this->transport, channel: $http)); + + AgentCat::track(TrackingFixture::builder()); + EventQueue::global()->shutdown(); + + $this->assertTrue(Diagnostics::enabled()); + $this->assertCount(1, $http->requests); + + return $http->requests[0]; + } +} diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php new file mode 100644 index 0000000..8a49cd8 --- /dev/null +++ b/tests/Integration/Symfony/BundleTest.php @@ -0,0 +1,536 @@ + 'true', 'project_id' => self::PROJECT]; + + private const string OWN = 'ERROR: Tool "own" already declares a \'session_id\' parameter.'; + + private const string MISSING_IDENTIFY = 'Error: identify callback threw - Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: Service "app.missing_identify" not found'; + + /** Two servers: `conformance` and `second` (`second-server`, on `/mcp/second`), with the same tools. */ + private const array TWO_SERVERS = ['servers' => [ + 'conformance' => TestKernel::CONFORMANCE, + 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + TestKernel::CONFORMANCE, + ]]; + + #[MaximumDuration(1000)] + public function testEveryServerIsTrackedWithNoCode(): void + { + $kernel = $this->kernel(agentcat: self::ON); + + $tools = $this->http($kernel)->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + #[MaximumDuration(1000)] + public function testBootingTracksNothing(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $kernel = $this->kernel(agentcat: self::ON); + + $this->assertTrue($kernel->getContainer()->has('mcp.server.conformance')); + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); + } + + /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ + #[MaximumDuration(1000)] + public function testTheServerIsTrackedOncePerContainer(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $http = $this->http($this->kernel(agentcat: self::ON)); + + $http->listTools(); + $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $http->listTools(); + + $started = array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'AgentCat setup started')); + $this->assertSame(['AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder'], array_values($started)); + } + + /** Review Focus 1, header conflict 1. */ + #[MaximumDuration(1000)] + public function testACustomerToolsCallHandlerRunsAfterAgentCats(): void + { + $kernel = $this->kernel(agentcat: self::ON, services: [CustomerCallHandler::class => CustomerCallHandler::class]); + $http = $this->http($kernel); + $http->listTools(); + + $answer = $http->callTool('echo', ['msg' => 'hi', 'context' => 'why', 'session_id' => self::SESSION]); + + $this->assertSame([['msg' => 'hi']], ToolCalls::of(CustomerCallHandler::RECORDED_AS)); + $this->assertSame([], ToolCalls::of('echo')); + $this->assertSame('customer', $this->text($answer, 0)); + $this->assertCount(1, $this->deliveries()); + } + + /** A customer's handler answers on the first call too, before AgentCat has a session: with the mint-back. */ + #[MaximumDuration(1000)] + public function testACustomerHandlersAnswerGetsTheMintBack(): void + { + $kernel = $this->kernel(agentcat: self::ON, services: [CustomerCallHandler::class => CustomerCallHandler::class]); + $http = $this->http($kernel); + $http->listTools(); + + $answer = $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $this->text($answer, 0)); + $this->assertSame('customer', $this->text($answer, 1)); + } + + /** @return iterable, array|null}> */ + public static function exclusions(): iterable + { + yield 'a list' => [['exclude_servers' => ['conformance']], null]; + yield 'a string' => [['exclude_servers' => 'conformance'], null]; + // symfony/config writes the key my-conformance of mcp.servers as my_conformance + yield 'a name with a hyphen' => [['exclude_servers' => ['my-conformance']], ['servers' => ['my-conformance' => TestKernel::CONFORMANCE]]]; + } + + /** + * D53 by `mcp.servers` name, at compile time. + * + * @param array $exclusion + * @param array|null $mcp + */ + #[DataProvider('exclusions')] + #[MaximumDuration(2000)] + public function testAnExcludedServerIsLeftAlone(array $exclusion, ?array $mcp): void + { + $this->assertUntouched([...self::ON, ...$exclusion], $mcp); + } + + #[MaximumDuration(1000)] + public function testAnExclusionLeavesTheOtherServersTracked(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'exclude_servers' => ['conformance']], mcp: self::TWO_SERVERS); + + $tools = $this->http($kernel, '/mcp/second')->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + /** D31: under PHPUnit, AgentCat is off unless `enabled` says otherwise. */ + #[MaximumDuration(2000)] + public function testWithoutASwitchInATestTheServerIsLeftAlone(): void + { + $this->assertUntouched(['project_id' => self::PROJECT]); + } + + /** D44: a false value turns AgentCat off, also where AGENTCAT_ENABLED turns it on. */ + #[MaximumDuration(2000)] + public function testWithAgentCatOffTheServerIsLeftAlone(): void + { + putenv('AGENTCAT_ENABLED=true'); + + $this->assertUntouched(['enabled' => false, 'project_id' => self::PROJECT]); + } + + /** Review Focus 3, ruling P9: `AGENTCAT_ENABLED=maybe` gives one line, and the D31 default (off under PHPUnit). */ + #[MaximumDuration(2000)] + public function testAnUnknownEnabledWordLogsOneLineAndLeavesTheServerAlone(): void + { + putenv('AGENTCAT_ENABLED=maybe'); + + $lines = $this->assertUntouched([], logs: true); + + $this->assertSame(['WARN: AgentCat config "agentcat.enabled" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used'], $lines); + } + + #[MaximumDuration(1000)] + public function testAServerCanGoToItsOwnProject(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['conformance' => self::OTHER_PROJECT]]); + $http = $this->http($kernel); + $http->listTools(); + + $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([self::OTHER_PROJECT], $this->projects()); + } + + /** D53: two servers of one application send to two projects. */ + #[MaximumDuration(1000)] + public function testTwoServersSendToTwoProjects(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['second' => self::OTHER_PROJECT]], mcp: self::TWO_SERVERS); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $this->http($kernel, '/mcp/second')->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $events = array_map(static fn(Delivery $delivery): mixed => Json::decode($delivery->body), $this->deliveries()); + $this->assertSame([[self::PROJECT, 'conformance-server'], [self::OTHER_PROJECT, 'second-server']], array_map(static fn(mixed $event): array => [JsonAssert::at($event, 'project_id'), JsonAssert::at($event, 'server_name')], $events)); + } + + /** A projects entry from an unset variable resolves to null at runtime: the server goes to `project_id`. */ + #[MaximumDuration(1000)] + public function testANullProjectEntryGoesToTheProjectId(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['conformance' => '%env(default::AGENTCAT_BILLING_PROJECT_ID)%', 'second' => self::OTHER_PROJECT]], mcp: self::TWO_SERVERS); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([self::PROJECT], $this->projects()); + } + + #[MaximumDuration(1000)] + public function testAProjectsKeyWithAHyphenNamesTheServer(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['my-conformance' => self::OTHER_PROJECT]], mcp: ['servers' => ['my-conformance' => TestKernel::CONFORMANCE]]); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([self::OTHER_PROJECT], $this->projects()); + } + + /** Review Focus 3 for the bundle's own keys: each of a wrong type logs one line, once, and its default is used. */ + #[MaximumDuration(1000)] + public function testBundleKeysOfAWrongTypeLogOneLineEachAndFallBack(): void + { + putenv('AGENTCAT_PROJECT_ID=' . self::PROJECT); // the default of a project_id that is no string + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: ['enabled' => true, 'project_id' => 12345, 'projects' => ['conformance' => 7, 'second' => self::OTHER_PROJECT], 'logger' => 5, 'diagnostics_endpoint' => 5, 'diagnostics_token' => false], mcp: self::TWO_SERVERS); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $this->http($kernel, '/mcp/second')->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([ + 'WARN: AgentCat option "agentcat.project_id" must be string or null, got int; the default is used', + 'WARN: AgentCat option "agentcat.projects.conformance" must be string or null, got int; the entry is ignored', + 'WARN: AgentCat option "agentcat.logger" must be string or null, got int; the default is used', + 'WARN: AgentCat option "agentcat.diagnostics_endpoint" must be string or null, got int; the default is used', + 'WARN: AgentCat option "agentcat.diagnostics_token" must be string or null, got bool; the default is used', + ], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'WARN:')))); + $this->assertSame([self::PROJECT, self::OTHER_PROJECT], $this->projects()); + } + + /** M3 of Plan E1: `enable_tracing: '%env(AGENTCAT_ENABLE_TRACING)%'` with `AGENTCAT_ENABLE_TRACING=0` turns tracing off. */ + #[MaximumDuration(1000)] + public function testAZeroFromTheEnvironmentTurnsTracingOff(): void + { + $tracing = Environment::clear('AGENTCAT_ENABLE_TRACING'); + putenv('AGENTCAT_ENABLE_TRACING=0'); + try { + $kernel = $this->kernel(agentcat: [...self::ON, 'enable_tracing' => '%env(AGENTCAT_ENABLE_TRACING)%']); + + $answer = $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + } finally { + $tracing->restore(); + } + + $this->assertSame('echo: hi', $this->text($answer, 0)); + $this->assertSame([], $this->deliveries()); + } + + /** D55: a hook is the ID of an invokable service, resolved when the hook runs. */ + #[MaximumDuration(1000)] + public function testAHookIsAServiceOfTheContainer(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'identify' => 'app.identify'], services: ['app.identify' => new InvokableIdentify('from-the-container')]); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame(['from-the-container'], $this->fields('identify_actor_given_id')); + } + + /** @return iterable */ + public static function hooks(): iterable + { + foreach (['identify', 'resolve_session_id', 'event_tags', 'event_properties', 'redact_sensitive_information', 'redact_event'] as $hook) { + yield $hook => [$hook]; + } + } + + /** D55: each hook key names a service; what the service throws reaches the hook guard's line (D32). */ + #[DataProvider('hooks')] + #[MaximumDuration(1000)] + public function testEveryHookIsAServiceOfTheContainer(string $hook): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: [...self::ON, $hook => 'app.hook'], services: ['app.hook' => new FailingHook($hook)]); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + EventQueue::global()->flush(1.0); + + $this->assertCount(1, array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'RuntimeException: ' . $hook . ' ran'))); + } + + /** Header conflict 5: the HTTP context of a call is the RequestStack's main request, not the SAPI globals. */ + #[MaximumDuration(1000)] + public function testTheEventCarriesTheRequestOfTheKernel(): void + { + $http = $this->http($this->kernel(agentcat: self::ON)); + + $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([['http://127.0.0.1/mcp/conformance', $http->sessionId()]], array_map(static fn(mixed $info): array => [JsonAssert::at($info, 'url'), JsonAssert::at($info, 'headers', 'mcp-session-id')], $this->fields('parameters', 'extra', 'requestInfo'))); + } + + /** One tracker serves the container: its client and options are built once, so each bad value logs once. */ + #[MaximumDuration(1000)] + public function testEachBadValueLogsOnceForEveryServer(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: ['enabled' => 'maybe', 'enable_report_missing' => 'maybe'], mcp: self::TWO_SERVERS); + + $this->http($kernel)->listTools(); + $this->http($kernel, '/mcp/second')->listTools(); + + $this->assertSame([ + 'WARN: AgentCat config "agentcat.enabled" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', + 'WARN: AgentCat config "agentcat.enable_report_missing" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', + ], $logger->messages()); + } + + #[Group('ac-05-E5')] + #[MaximumDuration(1000)] + public function testTheToolsOwnExceptionIsRecordedThroughTheBundlesReferenceHandler(): void + { + $kernel = $this->kernel(agentcat: self::ON); + $http = $this->http($kernel); + $http->listTools(); + + $http->callTool('boom', ['session_id' => self::SESSION]); + + $this->assertSame(['RuntimeException'], $this->fields('error', 'type')); + } + + /** Header conflict 6: a fake records the calls of every server, whatever the configuration says. */ + #[MaximumDuration(1000)] + public function testAFakeRecordsTheCallsWhateverTheConfigurationSays(): void + { + $fake = AgentCat::fake(); + $kernel = $this->kernel(agentcat: ['enabled' => false]); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi']); + + $fake->assertTrackedCount(1); + $this->assertSame([], $this->transport->deliveries); + $this->assertDirectoryDoesNotExist($kernel->getLogDir()); // the fake's log keeps priority + } + + /** D61: AgentCat's lines go to `%kernel.logs_dir%/agentcat.log`, not to the Builder's `mcp` logger. */ + #[MaximumDuration(1000)] + public function testByDefaultTheLinesGoToTheirOwnFileInTheLogDirectory(): void + { + $kernel = $this->kernel(agentcat: self::ON); + + $this->http($kernel)->listTools(); + + $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder', $this->agentCatLog($kernel)); + $this->assertStringContainsString(self::OWN, $this->agentCatLog($kernel)); + $this->assertSame(0o777 & ~umask(), fileperms($kernel->getLogDir()) & 0o777); // as Symfony creates its directories + $mcp = $kernel->getContainer()->get('logger'); + $this->assertInstanceOf(MemoryLogger::class, $mcp); + $this->assertNotSame([], $mcp->messages()); // mcp/sdk's own lines + $this->assertSame([], array_intersect($this->agentCatLines($kernel), $mcp->messages())); + } + + /** D61: `logger` names the PSR-3 logger service of AgentCat's lines, for example `monolog.logger.mcp`. */ + #[MaximumDuration(1000)] + public function testALoggerServiceGetsTheLines(): void + { + $logger = new MemoryLogger(); + $kernel = $this->kernel(agentcat: [...self::ON, 'logger' => 'app.agentcat_logger'], services: ['app.agentcat_logger' => $logger]); + + $this->http($kernel)->listTools(); + + $this->assertContains('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', $logger->messages()); + $this->assertDirectoryDoesNotExist($kernel->getLogDir()); + } + + /** @return iterable */ + public static function badLoggers(): iterable + { + yield 'a missing service' => ['app.missing_logger']; + yield 'a service that is no logger' => ['request_stack']; + } + + /** D61, ruling P8: a `logger` that names no logger logs one line to the file and never breaks a request. */ + #[DataProvider('badLoggers')] + #[MaximumDuration(1000)] + public function testALoggerThatIsNoLoggerLogsOneLineToTheFile(string $id): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'logger' => $id]); + $http = $this->http($kernel); + + $answer = $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame('echo: hi', $this->text($answer, 0)); + $lines = file($kernel->getLogDir() . '/agentcat.log', FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + $this->assertStringEndsWith(sprintf(' WARN: AgentCat option "agentcat.logger" must be the ID of a PSR-3 logger service, got "%s"; the default is used', $id), $lines[0] ?? ''); + $this->assertCount(1, array_filter($lines, static fn(string $line): bool => str_contains($line, 'agentcat.logger'))); + $this->assertStringContainsString('AgentCat setup started | project ' . self::PROJECT, $lines[1] ?? ''); + } + + /** + * D32: a configuration that cannot be read at runtime (a variable without a default that is not set) leaves the + * server untracked with one line, and the request answers. + */ + #[MaximumDuration(1000)] + public function testAConfigurationThatCannotBeReadLeavesTheServerUntracked(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'project_id' => '%env(AGENTCAT_UNSET_PROJECT_ID)%']); + $http = $this->http($kernel); + + $tools = $http->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame('structured', JsonAssert::at(end($tools), 'name')); + $this->assertSame(['Warning: Failed to track server - Symfony\Component\DependencyInjection\Exception\EnvNotFoundException: Environment variable not found: "AGENTCAT_UNSET_PROJECT_ID".'], $this->agentCatLines($kernel)); + } + + /** Review Focus 3, ruling P9: mistyped and unset values; the container compiles and the request answers. */ + #[MaximumDuration(1000)] + public function testBadRuntimeValuesNeverBreakARequest(): void + { + putenv('AGENTCAT_ENABLED=true'); + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: ['identify' => 'app.missing_identify', 'exporters' => ['otlp' => null], 'enable_report_missing' => 'maybe']); + $http = $this->http($kernel); + $http->listTools(); + + $answer = $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + $this->assertTrue(EventQueue::global()->flush(1.0)); + + $this->assertStringStartsWith(Constants::MINT_BACK_HEADER_ISSUED, $this->text($answer, 0)); + $this->assertSame('echo: hi', $this->text($answer, 1)); + $this->assertDirectoryDoesNotExist($kernel->getLogDir()); // Log::setLogger() chose the logger + $this->assertSame([ + 'WARN: AgentCat config "agentcat.enable_report_missing" is "maybe", not one of true, 1, yes, on, false, 0, no, off; the default is used', + 'AgentCat setup started | project (telemetry-only) | sdk mcp-sdk/builder | signals mcpSdkBuilder', + 'Warning: No projectId provided and no exporters configured. Events will not be sent anywhere.', // only null exporter entries + 'AgentCat setup complete | project (telemetry-only) | tracing=true context=true reportMissing=true exporters=0', + self::OWN, // the fixture's own tool + self::MISSING_IDENTIFY, // the hook guard, when the hook runs (D32); the rest of the message is Symfony's + ], array_map(static fn(string $line): string => self::prefixed($line, self::OWN, self::MISSING_IDENTIFY), $logger->messages())); + } + + /** + * An untracked server answers exactly like the server of an application without AgentCat, and nothing is queued. + * + * @param array $agentcat + * @param array|null $mcp + * + * @return list the lines AgentCat logged + */ + private function assertUntouched(array $agentcat, ?array $mcp = null, bool $logs = false): array + { + $plain = $this->answers($this->kernel(withAgentCat: false, mcp: $mcp)); + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $tracked = $this->answers($this->kernel(agentcat: $agentcat, mcp: $mcp)); + + $this->assertEquals($plain, $tracked); + $this->assertFalse(EventQueue::global()->hasWork()); + if (!$logs) { + $this->assertSame([], $logger->messages()); + } + + return $logger->messages(); + } + + /** @return array{\stdClass, \stdClass} the listing and the answer to one `tools/call` */ + private function answers(TestKernel $kernel): array + { + $http = $this->http($kernel); + + return [$http->listTools(), $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; + } + + /** The prefix that the line starts with, else the line. */ + private static function prefixed(string $line, string ...$prefixes): string + { + foreach ($prefixes as $prefix) { + if (str_starts_with($line, $prefix)) { + return $prefix; + } + } + + return $line; + } + + /** @return list the `project_id` of each event the API got */ + private function projects(): array + { + return $this->fields('project_id'); + } + + /** @return list the value at `$path` of each event the API got, after a flush */ + private function fields(string ...$path): array + { + return array_map(static fn(Delivery $delivery): mixed => JsonAssert::at(Json::decode($delivery->body), ...$path), $this->deliveries()); + } + + /** The text of content block `$block` of a `tools/call` answer. */ + private function text(\stdClass $answer, int $block): string + { + $text = JsonAssert::at($answer, 'result', 'content', $block, 'text'); + $this->assertIsString($text); + + return $text; + } + + private function agentCatLog(TestKernel $kernel): string + { + $path = $kernel->getLogDir() . '/agentcat.log'; + + return is_file($path) ? (string) file_get_contents($path) : ''; + } + + /** @return list the messages of agentcat.log, without the prefix of each line */ + private function agentCatLines(TestKernel $kernel): array + { + $lines = array_filter(explode("\n", $this->agentCatLog($kernel)), static fn(string $line): bool => $line !== ''); + + return array_values(array_map(static fn(string $line): string => (string) preg_replace('/^\[[^\]]*\] \[[^\]]*\] /', '', $line), $lines)); + } +} diff --git a/tests/Support/FileTree.php b/tests/Support/FileTree.php index 4dd7315..c6eab8d 100644 --- a/tests/Support/FileTree.php +++ b/tests/Support/FileTree.php @@ -18,6 +18,7 @@ public static function remove(string $path): void return; } + @chmod($path, 0o700); // a directory made without permissions (for example by a mutant of a mkdir() mode) can be read $names = scandir($path); foreach ($names === false ? [] : $names as $name) { if ($name !== '.' && $name !== '..') { diff --git a/tests/Support/Symfony/CustomerCallHandler.php b/tests/Support/Symfony/CustomerCallHandler.php new file mode 100644 index 0000000..958005d --- /dev/null +++ b/tests/Support/Symfony/CustomerCallHandler.php @@ -0,0 +1,42 @@ + + */ +final class CustomerCallHandler implements RequestHandlerInterface +{ + public const string RECORDED_AS = 'customer-handler'; + + #[\Override] + public function supports(Request $request): bool + { + return $request instanceof CallToolRequest; + } + + /** @return Response */ + #[\Override] + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof CallToolRequest); + ToolCalls::record(self::RECORDED_AS, $request->arguments); + + return new Response($request->getId(), new CallToolResult([new TextContent('customer')])); + } +} diff --git a/tests/Support/Symfony/FailingHook.php b/tests/Support/Symfony/FailingHook.php new file mode 100644 index 0000000..48e81f0 --- /dev/null +++ b/tests/Support/Symfony/FailingHook.php @@ -0,0 +1,16 @@ + ran` when it runs: the hook guard logs what it threw. */ +final readonly class FailingHook +{ + public function __construct(private string $name) {} + + public function __invoke(): never + { + throw new \RuntimeException($this->name . ' ran'); + } +} diff --git a/tests/Support/Symfony/SymfonyHttp.php b/tests/Support/Symfony/SymfonyHttp.php new file mode 100644 index 0000000..3da4e15 --- /dev/null +++ b/tests/Support/Symfony/SymfonyHttp.php @@ -0,0 +1,194 @@ +handle()` in process, with no sockets and no `terminate()`: + * a test that needs the terminate phase builds the request with `callRequest()` and runs both itself. Handshake-era + * calls carry the `Mcp-Session-Id` that `initialize()` received; `listTools()`, `callTool()` and `callRequest()` send + * `initialize()` first when there is no session yet. `modern()` sends one self-contained 2026-07-28 request instead. + */ +final class SymfonyHttp +{ + public const string PATH = '/mcp/conformance'; + + private const array MODERN_CLIENT = ['name' => 'modern-client', 'version' => '2.0.0']; + + private int $nextId = 1; + + private ?string $sessionId = null; + + private string $protocolVersion = '2025-06-18'; + + /** @param string $path the HTTP path of the server: the `conformance` server's by default */ + public function __construct( + private readonly KernelInterface $kernel, + private readonly string $path = self::PATH, + ) {} + + /** The `Mcp-Session-Id` the server issued at `initialize()`; null before. */ + public function sessionId(): ?string + { + return $this->sessionId; + } + + /** Sends `initialize` and `notifications/initialized`; returns the `initialize` result. */ + public function initialize(string $clientName = 'harness-client', string $clientVersion = '1.0.0', string $protocolVersion = '2025-06-18'): \stdClass + { + $result = HttpHarness::result($this->request('initialize', [ + 'protocolVersion' => $protocolVersion, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => $clientName, 'version' => $clientVersion], + ])); + $this->protocolVersion = is_string($result->protocolVersion ?? null) ? $result->protocolVersion : $protocolVersion; + + $response = $this->handle($this->build(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], [], true)); + Assert::assertSame(202, $response->getStatusCode(), 'notifications/initialized'); + + return $result; + } + + /** + * One JSON-RPC request in the session; returns the whole answer (`result` or `error`). + * + * @param array $params + * @param array $headers + */ + public function request(string $method, array $params = [], array $headers = []): \stdClass + { + $id = $this->nextId; + + return $this->answer($this->handle($this->message($method, $params, $headers, true)), $id); + } + + /** The `result` of a `tools/list` request. */ + public function listTools(?string $cursor = null): \stdClass + { + $this->session(); + + return HttpHarness::result($this->request('tools/list', $cursor === null ? [] : ['cursor' => $cursor])); + } + + /** + * The whole answer to a `tools/call` request: `result` or `error`. + * + * @param array $arguments + * @param array|null $meta + */ + public function callTool(string $name, array $arguments, ?array $meta = null): \stdClass + { + $this->session(); + $id = $this->nextId; + + return $this->answer($this->handle($this->callRequest($name, $arguments, $meta)), $id); + } + + /** + * The request of a `tools/call` in the session, not handled: the test hands it to the kernel itself. + * + * @param array $arguments + * @param array|null $meta + */ + public function callRequest(string $name, array $arguments, ?array $meta = null): Request + { + $this->session(); + $params = ['name' => $name, 'arguments' => (object) $arguments]; + if ($meta !== null) { + $params['_meta'] = (object) $meta; + } + + return $this->message('tools/call', $params, [], true); + } + + /** + * One 2026-07-28 request: no session, the protocol version, client capabilities and client info in `params._meta`, + * and the standard headers. Returns the whole answer. + * + * @param array $params + * @param array{name: string, version: string}|null $clientInfo + */ + public function modern(string $method, array $params, ?string $name = null, ?array $clientInfo = self::MODERN_CLIENT): \stdClass + { + $id = $this->nextId; + + return $this->answer($this->handle($this->message($method, HttpHarness::modernParams($params, $clientInfo), HttpHarness::modernHeaders($method, $name), false)), $id); + } + + /** Sends `initialize()` when there is no session yet. */ + private function session(): void + { + if ($this->sessionId === null) { + $this->initialize(); + } + } + + /** + * The request of the next JSON-RPC message. + * + * @param array $params + * @param array $headers + */ + private function message(string $method, array $params, array $headers, bool $withSession): Request + { + $message = ['jsonrpc' => '2.0', 'id' => $this->nextId++, 'method' => $method, 'params' => $params === [] ? new \stdClass() : $params]; + + return $this->build($message, $headers, $withSession); + } + + /** + * @param array $message + * @param array $headers + */ + private function build(array $message, array $headers, bool $withSession): Request + { + $all = ['Content-Type' => 'application/json', 'Accept' => 'application/json, text/event-stream']; + if ($withSession && $this->sessionId !== null) { + $all += ['Mcp-Session-Id' => $this->sessionId, 'MCP-Protocol-Version' => $this->protocolVersion]; + } + $server = ['HTTP_HOST' => '127.0.0.1']; + foreach ([...$all, ...$headers] as $name => $value) { + $key = strtoupper(str_replace('-', '_', $name)); + $server[$key === 'CONTENT_TYPE' ? $key : 'HTTP_' . $key] = $value; + } + + return Request::create('http://127.0.0.1' . $this->path, 'POST', server: $server, content: json_encode($message, JSON_THROW_ON_ERROR)); + } + + private function handle(Request $request): Response + { + $response = $this->kernel->handle($request); + $session = $response->headers->get('Mcp-Session-Id'); + if (is_string($session) && $session !== '') { + $this->sessionId = $session; + } + + return $response; + } + + /** The message of an answer that answers request `$id`: a JSON body, or a `data:` line of an SSE stream. */ + private function answer(Response $response, int $id): \stdClass + { + if ($response instanceof StreamedResponse) { + ob_start(); + try { + $response->sendContent(); + } finally { + $body = (string) ob_get_clean(); + } + } else { + $body = (string) $response->getContent(); + } + + return HttpHarness::decode($body, (string) $response->headers->get('Content-Type'), $id); + } +} diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 11ee4d1..b0b1ced 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -86,10 +86,11 @@ protected function tearDown(): void * * @param array $agentcat the agentcat configuration * @param array $services more services: id => class name or instance (synthetic) + * @param array|null $mcp the mcp configuration; null: the `conformance` server only */ - protected function kernel(array $agentcat = [], array $services = [], bool $withAgentCat = true, ?CompilerPassInterface $probe = null): TestKernel + protected function kernel(array $agentcat = [], array $services = [], bool $withAgentCat = true, ?CompilerPassInterface $probe = null, ?array $mcp = null): TestKernel { - $kernel = new TestKernel($this->directory . '/kernel-' . count($this->kernels), $agentcat, $services, withAgentCat: $withAgentCat, probe: $probe); + $kernel = new TestKernel($this->directory . '/kernel-' . count($this->kernels), $agentcat, $services, $mcp ?? TestKernel::MCP, $withAgentCat, $probe); $this->kernels[] = $kernel; $handler = $this->exceptionHandler(); $kernel->boot(); @@ -100,6 +101,12 @@ protected function kernel(array $agentcat = [], array $services = [], bool $with return $kernel; } + /** `HttpHarness`'s API over the kernel's MCP route at `$path`: the `conformance` server's by default. */ + protected function http(TestKernel $kernel, string $path = SymfonyHttp::PATH): SymfonyHttp + { + return new SymfonyHttp($kernel, $path); + } + /** * The exception handler in place (PHP 8.3 has no `get_exception_handler()`). * diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index 94f816e..f887473 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -28,6 +28,12 @@ final class TestKernel extends Kernel { use MicroKernelTrait; + /** The `mcp.servers` entry of the `conformance` server. */ + public const array CONFORMANCE = ['name' => 'conformance-server', 'version' => '2.0.0', 'registry' => ['AgentCat\Tests\Support\Symfony\Tools\\'], 'http' => ['path' => '/mcp/conformance']]; + + /** The default mcp configuration: the `conformance` server only. */ + public const array MCP = ['servers' => ['conformance' => self::CONFORMANCE]]; + /** * @param string $directory the directory of the cache and log directories * @param array $agentcat the agentcat configuration @@ -38,7 +44,7 @@ public function __construct( private readonly string $directory, private readonly array $agentcat = [], private readonly array $services = [], - private readonly array $mcp = ['servers' => ['conformance' => ['name' => 'conformance-server', 'version' => '2.0.0', 'registry' => ['AgentCat\Tests\Support\Symfony\Tools\\'], 'http' => ['path' => '/mcp/conformance']]]], + private readonly array $mcp = self::MCP, private readonly bool $withAgentCat = true, private readonly ?CompilerPassInterface $probe = null, ) { @@ -52,7 +58,6 @@ public function registerBundles(): iterable yield new FrameworkBundle(); yield new McpBundle(); if ($this->withAgentCat) { - // @phpstan-ignore class.notFound (src/Symfony/AgentCatBundle.php is not written yet), generator.valueType (the same class) yield new AgentCatBundle(); } } @@ -69,10 +74,15 @@ public function getBuildDir(): string return $this->getCacheDir(); } + /** + * Inside the cache directory: kernels of one process with the same configuration share the container class that + * the first one compiled, and symfony/dependency-injection writes a path outside the cache directory relative to + * that class's file, so `%kernel.logs_dir%` would name the first kernel's directory. + */ #[\Override] public function getLogDir(): string { - return $this->directory . '/log'; + return $this->getCacheDir() . '/log'; } /** Sets the synthetic services before the bundles boot, so a bundle's `boot()` can use them. */ diff --git a/tests/Unit/Symfony/AgentCatBundleTest.php b/tests/Unit/Symfony/AgentCatBundleTest.php new file mode 100644 index 0000000..1bd29da --- /dev/null +++ b/tests/Unit/Symfony/AgentCatBundleTest.php @@ -0,0 +1,163 @@ +assertSame([ + 'enabled' => '%env(default::AGENTCAT_ENABLED)%', + 'project_id' => '%env(default::AGENTCAT_PROJECT_ID)%', + 'exclude_servers' => [], + 'projects' => [], + 'logger' => null, + 'api_url' => '%env(default::AGENTCAT_API_URL)%', + 'enable_report_missing' => true, + 'enable_tracing' => true, + 'enable_tool_call_context' => true, + 'custom_context_description' => null, + 'enable_agent_tracking' => false, + 'identify' => null, + 'resolve_session_id' => null, + 'event_tags' => null, + 'event_properties' => null, + 'redact_sensitive_information' => null, + 'redact_event' => null, + 'exporters' => null, + 'disable_diagnostics' => ['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'], + 'diagnostics_endpoint' => '%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%', + 'diagnostics_token' => '%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%', + 'disable_signal_handlers' => false, + ], $this->process([])); + } + + /** Every key is a key of the bundle or an option, as in config/agentcat.php (Laravel's log_channel is logger here). */ + public function testTheTreeHasTheBundleKeysAndTheOptions(): void + { + $this->assertEqualsCanonicalizing([...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)], array_keys($this->process([]))); + } + + /** Plan E1 final ruling M2: a string is a list of one server. */ + public function testAStringExclusionIsAListOfOneServer(): void + { + $this->assertSame(['internal'], $this->process(['exclude_servers' => 'internal'])['exclude_servers'] ?? null); + $this->assertSame(['internal', 'admin'], $this->process(['exclude_servers' => ['internal', 'admin']])['exclude_servers'] ?? null); + } + + /** The keys of `projects` are written as symfony/mcp-bundle writes the keys of `mcp.servers`. */ + public function testProjectsAreServerNamesAndProjectIds(): void + { + $projects = $this->process(['projects' => ['main' => 'proj_m', 'my-server' => '%env(default::AGENTCAT_MY_SERVER_PROJECT_ID)%', 'other' => null]])['projects'] ?? null; + + $this->assertSame(['main' => 'proj_m', 'my_server' => '%env(default::AGENTCAT_MY_SERVER_PROJECT_ID)%', 'other' => null], $projects); + } + + /** The bool keys keep what they are given, for the D44 words at runtime: `%env(AGENTCAT_ENABLE_TRACING)%` compiles. */ + public function testTheBoolKeysTakeWordsAndVariables(): void + { + $config = $this->process(['enable_tracing' => '%env(AGENTCAT_ENABLE_TRACING)%', 'enable_report_missing' => 'no', 'enable_agent_tracking' => 1, 'disable_signal_handlers' => 'maybe', 'enable_tool_call_context' => false]); + + $this->assertSame(['%env(AGENTCAT_ENABLE_TRACING)%', 'no', 1, 'maybe', false], [$config['enable_tracing'] ?? null, $config['enable_report_missing'] ?? null, $config['enable_agent_tracking'] ?? null, $config['disable_signal_handlers'] ?? null, $config['enable_tool_call_context'] ?? null]); + } + + public function testExportersAndTheOptOutTakeAnyValue(): void + { + $config = $this->process(['exporters' => ['otlp' => null, 'datadog' => ['type' => 'datadog']], 'disable_diagnostics' => true]); + + $this->assertSame(['otlp' => null, 'datadog' => ['type' => 'datadog']], $config['exporters'] ?? null); + $this->assertTrue($config['disable_diagnostics'] ?? null); + } + + /** Symfony validates the tree when the container compiles: an unknown key fails there. */ + public function testAnUnknownKeyFailsTheCompile(): void + { + $thrown = Thrown::by(InvalidConfigurationException::class, fn(): array => $this->process(['enable_tracking' => true])); + + $this->assertStringContainsString('Unrecognized option "enable_tracking" under "agentcat"', $thrown->getMessage()); + } + + /** + * At -100 before optimization: after symfony/mcp-bundle's `McpAppPass` (10) and `McpPass` (0), before a pass that + * the application adds at the same priority (the kernel's own `build()` runs after the bundles'). + */ + public function testThePassRunsBeforeOptimizationAtPriorityMinus100(): void + { + $container = new ContainerBuilder(); + $before = $this->pass(); + $after = $this->pass(); + $container->addCompilerPass($before, PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + + (new AgentCatBundle())->build($container); + $container->addCompilerPass($after, PassConfig::TYPE_BEFORE_OPTIMIZATION, -100); + + $passes = array_values(array_filter($container->getCompilerPassConfig()->getBeforeOptimizationPasses(), static fn(CompilerPassInterface $pass): bool => in_array($pass, [$before, $after], true) || $pass instanceof TrackBuildersPass)); + $this->assertSame([$before, TrackBuildersPass::class, $after], array_map(static fn(CompilerPassInterface $pass): CompilerPassInterface|string => $pass instanceof TrackBuildersPass ? TrackBuildersPass::class : $pass, $passes)); + } + + /** Symfony gives a bundle its container before it boots it; without one, boot changes nothing. */ + public function testBootWithoutAContainerChangesNothing(): void + { + BridgeConfig::configure(['AGENTCAT_PROJECT_ID' => 'proj_x'], true); + TestMode::configure(false); + $bundle = new AgentCatBundle(); + $bundle->setContainer(null); + try { + $bundle->boot(); + + $this->assertSame('proj_x', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertTrue(BridgeConfig::diagnosticsOff()); + $this->assertFalse(TestMode::sdkEnabled()); + } finally { + BridgeConfig::reset(); + TestMode::reset(); + } + } + + private function pass(): CompilerPassInterface + { + return new class implements CompilerPassInterface { + #[\Override] + public function process(ContainerBuilder $container): void {} + }; + } + + /** + * @param array $config + * + * @return array + */ + private function process(array $config): array + { + $extension = (new AgentCatBundle())->getContainerExtension(); + $this->assertInstanceOf(ConfigurationExtensionInterface::class, $extension); + $this->assertSame('agentcat', $extension->getAlias()); + $configuration = $extension->getConfiguration([], new ContainerBuilder()); + $this->assertInstanceOf(ConfigurationInterface::class, $configuration); + + /** @var array */ + return (new Processor())->processConfiguration($configuration, [$config]); + } +} diff --git a/tests/Unit/Symfony/BuilderConfiguratorTest.php b/tests/Unit/Symfony/BuilderConfiguratorTest.php new file mode 100644 index 0000000..32207d0 --- /dev/null +++ b/tests/Unit/Symfony/BuilderConfiguratorTest.php @@ -0,0 +1,72 @@ +logger = new MemoryLogger()); + } + + #[\Override] + protected function tearDown(): void + { + Log::reset(); + } + + public function testThePreviousConfiguratorRunsFirstThenTheTrackerThenTheHandlers(): void + { + $builder = Server::builder(); + $previous = static function (Builder $configured) use ($builder): void { + Log::write($configured === $builder ? 'previous' : 'previous got another builder'); + }; + + (new BuilderConfigurator('main', $this->tracker(), [$this->handlers('first'), 'not iterable', $this->handlers('second')], $previous))->configure($builder); + + $this->assertSame(['previous', 'Warning: Failed to track server - RuntimeException: no configuration', 'handlers first', 'handlers second'], $this->logger->messages()); + } + + public function testWithoutAPreviousConfiguratorTheTrackerRunsFirst(): void + { + (new BuilderConfigurator('main', $this->tracker(), [$this->handlers('only')], 'not a function'))->configure(Server::builder()); + + $this->assertSame(['Warning: Failed to track server - RuntimeException: no configuration', 'handlers only'], $this->logger->messages()); + } + + /** A tracker whose configuration cannot be read: it logs one line and leaves the builder as it is. */ + private function tracker(): ServerTracker + { + return new ServerTracker(static fn(): never => throw new \RuntimeException('no configuration'), new ServiceLocator([]), null, '/nonexistent/agentcat.log'); + } + + /** + * Handlers that write a line when the Builder takes them. + * + * @return \Generator + */ + private function handlers(string $name): \Generator + { + Log::write('handlers ' . $name); + + yield from []; + } +} diff --git a/tests/Unit/Symfony/TrackBuildersPassTest.php b/tests/Unit/Symfony/TrackBuildersPassTest.php new file mode 100644 index 0000000..fdcc071 --- /dev/null +++ b/tests/Unit/Symfony/TrackBuildersPassTest.php @@ -0,0 +1,127 @@ +setParameter('agentcat.exclude_servers', []); + $container->register('agentcat.server_tracker', ServerTracker::class); + $handlers = new TaggedIteratorArgument('mcp.request_handler'); + $container->register('mcp.server.main.builder', Builder::class) + ->setFactory([Server::class, 'builder']) + ->addMethodCall('setEventDispatcher', [new Reference('event_dispatcher')]) + ->addMethodCall('addRequestHandlers', [$handlers]) + ->addMethodCall('setLogger', [new Reference('logger')]) + ->addTag('mcp.server.builder', ['server' => 'main']); + + (new TrackBuildersPass())->process($container); + + $builder = $container->getDefinition('mcp.server.main.builder'); + $this->assertSame(['setEventDispatcher', 'setLogger'], array_column($builder->getMethodCalls(), 0)); + $this->assertEquals([new Reference('agentcat.builder_configurator.main'), 'configure'], $builder->getConfigurator()); + $configurator = $container->getDefinition('agentcat.builder_configurator.main'); + $this->assertSame(BuilderConfigurator::class, $configurator->getClass()); + $this->assertEquals(['main', new Reference('agentcat.server_tracker'), [$handlers], null], $configurator->getArguments()); + } + + public function testAnExcludedServerIsLeftAsItIs(): void + { + $container = new ContainerBuilder(); + $container->setParameter('agentcat.exclude_servers', ['internal']); + $container->register('mcp.server.internal.builder', Builder::class)->addMethodCall('addRequestHandlers', [new TaggedIteratorArgument('mcp.request_handler')])->addTag('mcp.server.builder', ['server' => 'internal']); + + (new TrackBuildersPass())->process($container); + + $this->assertNull($container->getDefinition('mcp.server.internal.builder')->getConfigurator()); + $this->assertCount(1, $container->getDefinition('mcp.server.internal.builder')->getMethodCalls()); + $this->assertFalse($container->hasDefinition('agentcat.builder_configurator.internal')); + } + + public function testAConfiguratorTheBuilderHadRunsFirst(): void + { + $container = new ContainerBuilder(); + $container->setParameter('agentcat.exclude_servers', []); + $container->register('mcp.server.main.builder', Builder::class)->setConfigurator([new Reference('app.configurator'), 'configure'])->addTag('mcp.server.builder', ['server' => 'main']); + + (new TrackBuildersPass())->process($container); + + $this->assertEquals([new Reference('app.configurator'), 'configure'], $container->getDefinition('agentcat.builder_configurator.main')->getArgument(3)); + } + + /** Header conflict 3: without the call, the builder is still tracked, after its handlers (the C1 behavior). */ + public function testABuilderWithoutTheHandlersCallIsStillTracked(): void + { + $container = new ContainerBuilder(); + $container->register('mcp.server.main.builder', Builder::class)->addMethodCall('setLogger', [new Reference('logger')])->addTag('mcp.server.builder', ['server' => 'main']); + + (new TrackBuildersPass())->process($container); // no agentcat.exclude_servers parameter either + + $this->assertCount(1, $container->getDefinition('mcp.server.main.builder')->getMethodCalls()); + $this->assertEquals(['main', new Reference('agentcat.server_tracker'), [], null], $container->getDefinition('agentcat.builder_configurator.main')->getArguments()); + } + + /** Every builder gets its own configurator; the case of the method name does not matter to PHP, nor here. */ + public function testEachServerGetsItsOwnConfigurator(): void + { + $container = new ContainerBuilder(); + $container->setParameter('agentcat.exclude_servers', []); + foreach (['main', 'internal'] as $server) { + $container->register('mcp.server.' . $server . '.builder', Builder::class)->addMethodCall('AddRequestHandlers', [[new Reference('app.' . $server)]])->addTag('mcp.server.builder', ['server' => $server]); + } + + (new TrackBuildersPass())->process($container); + + foreach (['main', 'internal'] as $server) { + $this->assertSame([], $container->getDefinition('mcp.server.' . $server . '.builder')->getMethodCalls()); + $this->assertEquals([$server, new Reference('agentcat.server_tracker'), [[new Reference('app.' . $server)]], null], $container->getDefinition('agentcat.builder_configurator.' . $server)->getArguments()); + } + } + + public function testAServiceWithoutAServerNameIsLeftAsItIs(): void + { + $container = new ContainerBuilder(); + $container->register('mcp.server.main.builder', Builder::class)->addMethodCall('addRequestHandlers', [[]])->addTag('mcp.server.builder'); + + (new TrackBuildersPass())->process($container); + + $this->assertNull($container->getDefinition('mcp.server.main.builder')->getConfigurator()); + $this->assertCount(1, $container->getDefinition('mcp.server.main.builder')->getMethodCalls()); + } + + /** + * symfony/config writes the `mcp.servers` key `my-server` as `my_server`, so `exclude_servers: [my-server]` names + * it. A name that is a server itself stays exact. + */ + public function testAnExclusionNamesTheServerAsSymfonyWritesItsKey(): void + { + $container = new ContainerBuilder(); + $container->setParameter('agentcat.exclude_servers', ['my-server', 'a-b', 'c-d_e']); + foreach (['my_server', 'a-b', 'a_b', 'c_d_e'] as $server) { + $container->register('mcp.server.' . $server . '.builder', Builder::class)->addTag('mcp.server.builder', ['server' => $server]); + } + + (new TrackBuildersPass())->process($container); + + $this->assertNull($container->getDefinition('mcp.server.my_server.builder')->getConfigurator()); + $this->assertNull($container->getDefinition('mcp.server.a-b.builder')->getConfigurator()); + $this->assertNotNull($container->getDefinition('mcp.server.a_b.builder')->getConfigurator()); + $this->assertNotNull($container->getDefinition('mcp.server.c_d_e.builder')->getConfigurator()); // c-d_e keeps its hyphen in Symfony + } +} From 7bfb5686d286128cffada63a10b87c130cf2b5f1 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 17:03:09 +0000 Subject: [PATCH 10/55] test: give each test kernel its own container class so it never reads the directory of a removed kernel --- tests/Support/Symfony/TestKernel.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index f887473..6647332 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -74,15 +74,21 @@ public function getBuildDir(): string return $this->getCacheDir(); } + #[\Override] + public function getLogDir(): string + { + return $this->directory . '/log'; + } + /** - * Inside the cache directory: kernels of one process with the same configuration share the container class that - * the first one compiled, and symfony/dependency-injection writes a path outside the cache directory relative to - * that class's file, so `%kernel.logs_dir%` would name the first kernel's directory. + * A container class of its own: kernels of one process with the same configuration would share the class that the + * first one compiled, and that class reads paths relative to its own file (`%kernel.logs_dir%`, the lazy proxies), + * so in the directory of the first kernel, which its test removed. */ #[\Override] - public function getLogDir(): string + protected function getContainerClass(): string { - return $this->getCacheDir() . '/log'; + return parent::getContainerClass() . '_' . hash('xxh3', $this->directory); } /** Sets the synthetic services before the bundles boot, so a bundle's `boot()` can use them. */ From 4f647cd862d23385c77810c5a0b2c4646c901fa6 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 17:19:52 +0000 Subject: [PATCH 11/55] refactor: create the bundle's log directory with mkdir's default mode A mode literal gave Infection a mutant (0o1000) that makes a directory its own cleanup cannot read under Infection's include interceptor, so mutation runs left directories in the test HOME. The default mode is the same 0777 less the umask. The FileTree chmod added for that case is dropped: the interceptor's url_stat reports such a directory as missing before the chmod runs. --- src/Symfony/Internal/ServerTracker.php | 2 +- tests/Support/FileTree.php | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php index 22d0fd7..ddc2374 100644 --- a/src/Symfony/Internal/ServerTracker.php +++ b/src/Symfony/Internal/ServerTracker.php @@ -142,7 +142,7 @@ private function adoptLogger(?string $id): void } $directory = dirname($this->logFile); if (!is_dir($directory)) { - @mkdir($directory, 0o777, true); // Symfony creates its log directory only when something writes there + @mkdir($directory, recursive: true); // 0777 less the umask; Symfony makes its log directory only for a write } Log::adopt(new FileLogger($this->logFile)); if ($named) { diff --git a/tests/Support/FileTree.php b/tests/Support/FileTree.php index c6eab8d..4dd7315 100644 --- a/tests/Support/FileTree.php +++ b/tests/Support/FileTree.php @@ -18,7 +18,6 @@ public static function remove(string $path): void return; } - @chmod($path, 0o700); // a directory made without permissions (for example by a mutant of a mkdir() mode) can be read $names = scandir($path); foreach ($names === false ? [] : $names as $name) { if ($name !== '.' && $name !== '..') { From a7f24d1f4ee636727f45e2f0f6b87bd942826634 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 17:47:13 +0000 Subject: [PATCH 12/55] fix: leave out the X-Php-Ob-Level header that Symfony's HttpKernel adds to each request --- src/Internal/Engine/HttpContext.php | 11 ++++++----- tests/Integration/Symfony/BundleTest.php | 14 +++++++++++--- tests/Unit/Internal/Engine/HttpContextTest.php | 8 ++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Internal/Engine/HttpContext.php b/src/Internal/Engine/HttpContext.php index cbab475..a9fabc6 100644 --- a/src/Internal/Engine/HttpContext.php +++ b/src/Internal/Engine/HttpContext.php @@ -13,11 +13,11 @@ final readonly class HttpContext { /** - * The headers that Symfony's `ServerBag` makes up from the `Authorization` header or the SAPI's `PHP_AUTH_*` - * variables. No client sends them, and two hold the credentials in clear text: the `Authorization` header stays - * (D06). + * The headers that Symfony puts in a request's header bag and no client sent. `ServerBag` makes up the `php-auth-*` + * headers from the `Authorization` header or the SAPI's `PHP_AUTH_*` variables; two hold the credentials in clear + * text, and the `Authorization` header stays (D06). `HttpKernel::handle()` adds `x-php-ob-level`. */ - private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest']; + private const array MADE_UP_HEADERS = ['php-auth-user', 'php-auth-pw', 'php-auth-digest', 'x-php-ob-level']; /** * @param array $headers lower-case dashed names, values verbatim (D06) @@ -68,7 +68,8 @@ public static function fromServer(string $sapi, array $server): ?self * an invalid host. The `Host` header, not the server variable: a request that a worker builds from PSR-7 has * only the header; * - the header names as the header bag keeps them (lower case); the values of a repeated header joined with `, `, - * like a SAPI joins them, a null value as `''`; the `php-auth-*` headers that Symfony makes up left out. + * like a SAPI joins them, a null value as `''`; the headers that Symfony makes up left out: `php-auth-*` from the + * credentials (`ServerBag`), and `x-php-ob-level`, which `HttpKernel::handle()` adds to each request it handles. * * @param array> $headers the request's header bag (`HeaderBag::all()`) * @param mixed $https the `HTTPS` server variable diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 8a49cd8..2158a6f 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -299,15 +299,23 @@ public function testEveryHookIsAServiceOfTheContainer(string $hook): void $this->assertCount(1, array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'RuntimeException: ' . $hook . ' ran'))); } - /** Header conflict 5: the HTTP context of a call is the RequestStack's main request, not the SAPI globals. */ + /** + * Header conflict 5: the HTTP context of a call is the RequestStack's main request, not the SAPI globals, with the + * headers the client sent: not the `X-Php-Ob-Level` that Symfony's HttpKernel adds (Plan E1 Task 6 parity). + */ #[MaximumDuration(1000)] public function testTheEventCarriesTheRequestOfTheKernel(): void { $http = $this->http($this->kernel(agentcat: self::ON)); + $http->listTools(); - $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $http->request('tools/call', ['name' => 'echo', 'arguments' => ['msg' => 'hi', 'session_id' => self::SESSION]], ['X-Client' => 'kept']); - $this->assertSame([['http://127.0.0.1/mcp/conformance', $http->sessionId()]], array_map(static fn(mixed $info): array => [JsonAssert::at($info, 'url'), JsonAssert::at($info, 'headers', 'mcp-session-id')], $this->fields('parameters', 'extra', 'requestInfo'))); + $info = $this->fields('parameters', 'extra', 'requestInfo'); + $this->assertSame([['http://127.0.0.1/mcp/conformance', $http->sessionId(), 'kept']], array_map(static fn(mixed $info): array => [JsonAssert::at($info, 'url'), JsonAssert::at($info, 'headers', 'mcp-session-id'), JsonAssert::at($info, 'headers', 'x-client')], $info)); + $headers = JsonAssert::at($info, 0, 'headers'); + $this->assertInstanceOf(\stdClass::class, $headers); + $this->assertObjectNotHasProperty('x-php-ob-level', $headers); } /** One tracker serves the container: its client and options are built once, so each bad value logs once. */ diff --git a/tests/Unit/Internal/Engine/HttpContextTest.php b/tests/Unit/Internal/Engine/HttpContextTest.php index 2028d8c..5fccaa0 100644 --- a/tests/Unit/Internal/Engine/HttpContextTest.php +++ b/tests/Unit/Internal/Engine/HttpContextTest.php @@ -147,4 +147,12 @@ public function testTheMadeUpCredentialHeadersAreLeftOut(): void $this->assertSame(['authorization' => 'Basic dTpzZWNyZXQ=', 'php-auth-other' => 'o'], HttpContext::fromRequest('POST', $headers, null, null, null)->headers); } + + /** Symfony's `HttpKernel::handle()` adds `X-Php-Ob-Level` to the request it handles; the client did not send it. */ + public function testTheOutputBufferLevelThatSymfonyAddsIsLeftOut(): void + { + $headers = ['x-php-ob-level' => ['1'], 'x-php-ob-levels' => ['2'], 'accept' => ['application/json']]; + + $this->assertSame(['x-php-ob-levels' => '2', 'accept' => 'application/json'], HttpContext::fromRequest('POST', $headers, null, null, null)->headers); + } } From 36a1fc259fd48b1e32a14ba5238bf81bf9a62a1e Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 17:52:05 +0000 Subject: [PATCH 13/55] refactor: hand a bridge's agentcat configuration to the process in one core helper --- src/Internal/Support/BridgeConfig.php | 33 +++++++++++++++- src/Laravel/AgentCatServiceProvider.php | 23 ++--------- src/Symfony/AgentCatBundle.php | 24 +++--------- .../Internal/Support/BridgeConfigTest.php | 38 ++++++++++++++++++- 4 files changed, 77 insertions(+), 41 deletions(-) diff --git a/src/Internal/Support/BridgeConfig.php b/src/Internal/Support/BridgeConfig.php index 37aafd6..ea0db95 100644 --- a/src/Internal/Support/BridgeConfig.php +++ b/src/Internal/Support/BridgeConfig.php @@ -7,8 +7,9 @@ /** * The values of a framework bridge's configuration that every client of the process reads before the environment * (plan E1 header conflict 12): with a cached configuration (`php artisan config:cache`), Laravel does not load the - * `.env` file, so `Env::get()` cannot see what the configuration got from it. The bridge sets them when it boots. The - * last call wins; they are process-wide, like the environment. `TestMode::configure()` does the same for the switch. + * `.env` file, so `Env::get()` cannot see what the configuration got from it. The bridge sets them when it boots + * (`fromBridge()`). The last call wins; they are process-wide, like the environment. `TestMode::configure()` does the + * same for the switch. * * @internal */ @@ -36,6 +37,29 @@ public static function configure(array $values, bool $diagnosticsOff): void self::$diagnosticsOff = $diagnosticsOff; } + /** + * Hands a framework bridge's `agentcat` configuration to every client of the process when the bridge boots (plan E1 + * header conflict 12, final ruling I2): the switch, from the D44 words of `enabled` (unset or unknown: no switch, + * `AGENTCAT_ENABLED` decides), every name AgentCat reads before the environment, and the application's environment + * for diagnostics. A value that is no string is unset here. Nothing is logged: the bridge's tracker logs the bad + * values when it tracks a server. + * + * @param array $agentcat the bridge's `agentcat` configuration + * @param mixed $appEnvironment the application's environment: Laravel's `app.env`, Symfony's + * `kernel.environment` + */ + public static function fromBridge(array $agentcat, mixed $appEnvironment): void + { + TestMode::configure(TestMode::word($agentcat['enabled'] ?? null)); + self::configure([ + 'AGENTCAT_PROJECT_ID' => self::string($agentcat['project_id'] ?? null), + 'AGENTCAT_API_URL' => self::string($agentcat['api_url'] ?? null), + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => self::string($agentcat['diagnostics_endpoint'] ?? null), + 'AGENTCAT_DIAGNOSTICS_TOKEN' => self::string($agentcat['diagnostics_token'] ?? null), + 'APP_ENV' => self::string($appEnvironment), + ], ConfigOptions::diagnosticsOff($agentcat['disable_diagnostics'] ?? null)); + } + /** The configured value of an environment name, else `Env::get()`. */ public static function env(string $name): ?string { @@ -48,6 +72,11 @@ public static function diagnosticsOff(): bool return self::$diagnosticsOff; } + private static function string(mixed $value): ?string + { + return is_string($value) ? $value : null; + } + /** Tests only: forgets the configured values. */ public static function reset(): void { diff --git a/src/Laravel/AgentCatServiceProvider.php b/src/Laravel/AgentCatServiceProvider.php index 74961d3..ff8700a 100644 --- a/src/Laravel/AgentCatServiceProvider.php +++ b/src/Laravel/AgentCatServiceProvider.php @@ -7,8 +7,6 @@ use AgentCat\AgentCat; use AgentCat\Client; use AgentCat\Internal\Support\BridgeConfig; -use AgentCat\Internal\Support\ConfigOptions; -use AgentCat\Internal\Support\TestMode; use AgentCat\Laravel\Console\InstallCommand; use AgentCat\Laravel\Internal\AfterRequest; use AgentCat\Laravel\Internal\AnswerLength; @@ -39,19 +37,11 @@ public function boot(): void if ($this->app->runningInConsole()) { $this->commands([InstallCommand::class]); } - // `agentcat.enabled` decides for every client, the shared one too, before AGENTCAT_ENABLED, which a cached - // configuration hides (header conflict 12). Unset or unknown: no switch. A fake still records. + // `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before + // the environment, which a cached configuration hides (header conflict 12). A fake still records. $config = $this->app->make('config'); - TestMode::configure(TestMode::word($config->get('agentcat.enabled'))); - // The same for every other name AgentCat reads, and diagnostics get the application's environment. The - // tracker logs the bad values; nothing is logged here. - BridgeConfig::configure([ - 'AGENTCAT_PROJECT_ID' => $this->string($config->get('agentcat.project_id')), - 'AGENTCAT_API_URL' => $this->string($config->get('agentcat.api_url')), - 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->string($config->get('agentcat.diagnostics_endpoint')), - 'AGENTCAT_DIAGNOSTICS_TOKEN' => $this->string($config->get('agentcat.diagnostics_token')), - 'APP_ENV' => $this->string($config->get('app.env')), - ], ConfigOptions::diagnosticsOff($config->get('agentcat.disable_diagnostics'))); + $agentcat = $config->get('agentcat'); + BridgeConfig::fromBridge(is_array($agentcat) ? $agentcat : [], $config->get('app.env')); if (class_exists(Server::class)) { $tracker = $this->app->make(ServerTracker::class); // resolved once per worker, never tracks here $this->app->afterResolving(Server::class, static function (Server $server, Container $app) use ($tracker): void { @@ -61,9 +51,4 @@ public function boot(): void } AfterRequest::register($this->app); // callbacks only: nothing runs at boot } - - private function string(mixed $value): ?string - { - return is_string($value) ? $value : null; - } } diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index cec1c3d..6bf5cb3 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -5,8 +5,6 @@ namespace AgentCat\Symfony; use AgentCat\Internal\Support\BridgeConfig; -use AgentCat\Internal\Support\ConfigOptions; -use AgentCat\Internal\Support\TestMode; use AgentCat\Symfony\Internal\DependencyInjection\TrackBuildersPass; use AgentCat\Symfony\Internal\ServerTracker; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; @@ -94,10 +92,10 @@ public function build(ContainerBuilder $container): void } /** - * `agentcat.enabled` decides for every client, the shared one too, before AGENTCAT_ENABLED (plan E1 header conflict - * 12): a literal, a parameter or a secret of the configuration is not in the environment. Unset or unknown: no - * switch. A fake still records. The same for every other name AgentCat reads, and diagnostics get the kernel's - * environment. The tracker logs the bad values; nothing is logged here, and boot tracks nothing. + * `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before the + * environment (plan E1 header conflict 12): a literal, a parameter or a secret of the configuration is not in the + * environment. Diagnostics get the kernel's environment. A fake still records. The tracker logs the bad values; + * nothing is logged here, and boot tracks nothing. */ #[\Override] public function boot(): void @@ -112,18 +110,6 @@ public function boot(): void } catch (\Throwable) { // A variable without a default is not set: the tracker logs it when it tracks a server (D32). } - TestMode::configure(TestMode::word($config['enabled'] ?? null)); - BridgeConfig::configure([ - 'AGENTCAT_PROJECT_ID' => $this->string($config['project_id'] ?? null), - 'AGENTCAT_API_URL' => $this->string($config['api_url'] ?? null), - 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $this->string($config['diagnostics_endpoint'] ?? null), - 'AGENTCAT_DIAGNOSTICS_TOKEN' => $this->string($config['diagnostics_token'] ?? null), - 'APP_ENV' => $this->string($this->container->getParameter('kernel.environment')), - ], ConfigOptions::diagnosticsOff($config['disable_diagnostics'] ?? null)); - } - - private function string(mixed $value): ?string - { - return is_string($value) ? $value : null; + BridgeConfig::fromBridge($config, $this->container->getParameter('kernel.environment')); } } diff --git a/tests/Unit/Internal/Support/BridgeConfigTest.php b/tests/Unit/Internal/Support/BridgeConfigTest.php index 198ff89..0e83860 100644 --- a/tests/Unit/Internal/Support/BridgeConfigTest.php +++ b/tests/Unit/Internal/Support/BridgeConfigTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Unit\Internal\Support; use AgentCat\Internal\Support\BridgeConfig; +use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use PHPUnit\Framework\TestCase; @@ -16,13 +17,14 @@ final class BridgeConfigTest extends TestCase #[\Override] protected function setUp(): void { - $this->environment = Environment::clear('AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL'); + $this->environment = Environment::clear('AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'APP_ENV', 'AGENTCAT_ENABLED'); } #[\Override] protected function tearDown(): void { BridgeConfig::reset(); + TestMode::reset(); $this->environment->restore(); } @@ -78,4 +80,38 @@ public function testResetForgetsTheConfiguration(): void $this->assertNull(BridgeConfig::env('AGENTCAT_PROJECT_ID')); $this->assertFalse(BridgeConfig::diagnosticsOff()); } + + /** Both bridges boot with this: their `agentcat` configuration, and the application's environment for diagnostics. */ + public function testABridgeConfigurationGivesEveryNameAndTheSwitch(): void + { + BridgeConfig::fromBridge([ + 'enabled' => 'off', + 'project_id' => 'proj_configured', + 'api_url' => 'https://api.configured.test', + 'diagnostics_endpoint' => 'https://otel.configured.test', + 'diagnostics_token' => 'dgk_configured', + 'disable_diagnostics' => [null, 'yes'], + ], 'staging'); + + $this->assertSame(['proj_configured', 'https://api.configured.test', 'https://otel.configured.test', 'dgk_configured', 'staging'], array_map(BridgeConfig::env(...), ['AGENTCAT_PROJECT_ID', 'AGENTCAT_API_URL', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', 'APP_ENV'])); + $this->assertTrue(BridgeConfig::diagnosticsOff()); + putenv('AGENTCAT_ENABLED=true'); + $this->assertFalse(TestMode::sdkEnabled()); // the configured switch decides before AGENTCAT_ENABLED + } + + /** A value that is no string, an unset one and an unknown word leave each name, and the switch, to the environment. */ + public function testABridgeConfigurationWithoutValuesLeavesTheEnvironmentToDecide(): void + { + putenv('AGENTCAT_PROJECT_ID=proj_env'); + putenv('AGENTCAT_ENABLED=true'); + TestMode::configure(false); + + BridgeConfig::fromBridge(['enabled' => 'maybe', 'project_id' => 12345, 'api_url' => null, 'disable_diagnostics' => [null, null]], null); + + $this->assertSame('proj_env', BridgeConfig::env('AGENTCAT_PROJECT_ID')); + $this->assertNull(BridgeConfig::env('AGENTCAT_API_URL')); + $this->assertNull(BridgeConfig::env('APP_ENV')); + $this->assertFalse(BridgeConfig::diagnosticsOff()); + $this->assertTrue(TestMode::sdkEnabled()); + } } From d9ac1974978d57dc45de748ea19d7e88bbfe5eca Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 18:14:53 +0000 Subject: [PATCH 14/55] test: share the handshake and tools/call params of the two HTTP harnesses --- tests/Support/McpSdk/HttpHarness.php | 56 ++++++++++++++++++++++----- tests/Support/Symfony/SymfonyHttp.php | 19 +++------ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/tests/Support/McpSdk/HttpHarness.php b/tests/Support/McpSdk/HttpHarness.php index 880492e..d64686a 100644 --- a/tests/Support/McpSdk/HttpHarness.php +++ b/tests/Support/McpSdk/HttpHarness.php @@ -22,6 +22,9 @@ final class HttpHarness private const array MODERN_CLIENT = ['name' => 'modern-client', 'version' => '2.0.0']; + /** The notification that ends the handshake. */ + public const array INITIALIZED = ['jsonrpc' => '2.0', 'method' => 'notifications/initialized']; + private readonly Psr17Factory $factory; private int $nextId = 1; @@ -44,18 +47,40 @@ public function sessionId(): ?string /** Sends `initialize` and `notifications/initialized`; returns the `initialize` result. */ public function initialize(string $clientName = 'harness-client', string $clientVersion = '1.0.0', string $protocolVersion = '2025-06-18'): \stdClass { - $result = self::result($this->request('initialize', [ + $result = self::result($this->request('initialize', self::initializeParams($clientName, $clientVersion, $protocolVersion))); + $this->protocolVersion = self::negotiated($result, $protocolVersion); + + $response = $this->post(self::INITIALIZED, [], true); + self::assertInitialized($response->getStatusCode(), (string) $response->getBody()); + + return $result; + } + + /** + * The params of an `initialize` request. + * + * @return array + */ + public static function initializeParams(string $clientName, string $clientVersion, string $protocolVersion): array + { + return [ 'protocolVersion' => $protocolVersion, 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => $clientName, 'version' => $clientVersion], - ])); - $this->protocolVersion = is_string($result->protocolVersion ?? null) ? $result->protocolVersion : $protocolVersion; + ]; + } - $response = $this->post(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], [], true); - Assert::assertSame(202, $response->getStatusCode(), 'notifications/initialized'); - Assert::assertSame('', (string) $response->getBody(), 'notifications/initialized'); + /** The protocol version that the server chose in its `initialize` result, else the one the client asked for. */ + public static function negotiated(\stdClass $result, string $requested): string + { + return is_string($result->protocolVersion ?? null) ? $result->protocolVersion : $requested; + } - return $result; + /** The server accepted `notifications/initialized`: 202 and no body. */ + public static function assertInitialized(int $status, string $body): void + { + Assert::assertSame(202, $status, 'notifications/initialized'); + Assert::assertSame('', $body, 'notifications/initialized'); } /** @@ -82,13 +107,26 @@ public function listTools(?string $cursor = null): \stdClass * @param array|null $meta */ public function callTool(string $name, array $arguments, ?array $meta = null): \stdClass + { + return $this->request('tools/call', self::callParams($name, $arguments, $meta)); + } + + /** + * The params of a `tools/call` request. + * + * @param array $arguments + * @param array|null $meta + * + * @return array + */ + public static function callParams(string $name, array $arguments, ?array $meta): array { $params = ['name' => $name, 'arguments' => (object) $arguments]; if ($meta !== null) { $params['_meta'] = (object) $meta; } - return $this->request('tools/call', $params); + return $params; } /** @@ -145,7 +183,7 @@ public static function modernHeaders(string $method, ?string $name): array */ public function callToolModern(string $name, array $arguments, ?array $clientInfo = self::MODERN_CLIENT): \stdClass { - return $this->modern('tools/call', ['name' => $name, 'arguments' => (object) $arguments], $name, $clientInfo); + return $this->modern('tools/call', self::callParams($name, $arguments, null), $name, $clientInfo); } /** The `result` of a 2026-07-28 `tools/list` request. */ diff --git a/tests/Support/Symfony/SymfonyHttp.php b/tests/Support/Symfony/SymfonyHttp.php index 3da4e15..dd46724 100644 --- a/tests/Support/Symfony/SymfonyHttp.php +++ b/tests/Support/Symfony/SymfonyHttp.php @@ -5,7 +5,6 @@ namespace AgentCat\Tests\Support\Symfony; use AgentCat\Tests\Support\McpSdk\HttpHarness; -use PHPUnit\Framework\Assert; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -45,15 +44,11 @@ public function sessionId(): ?string /** Sends `initialize` and `notifications/initialized`; returns the `initialize` result. */ public function initialize(string $clientName = 'harness-client', string $clientVersion = '1.0.0', string $protocolVersion = '2025-06-18'): \stdClass { - $result = HttpHarness::result($this->request('initialize', [ - 'protocolVersion' => $protocolVersion, - 'capabilities' => new \stdClass(), - 'clientInfo' => ['name' => $clientName, 'version' => $clientVersion], - ])); - $this->protocolVersion = is_string($result->protocolVersion ?? null) ? $result->protocolVersion : $protocolVersion; + $result = HttpHarness::result($this->request('initialize', HttpHarness::initializeParams($clientName, $clientVersion, $protocolVersion))); + $this->protocolVersion = HttpHarness::negotiated($result, $protocolVersion); - $response = $this->handle($this->build(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], [], true)); - Assert::assertSame(202, $response->getStatusCode(), 'notifications/initialized'); + $response = $this->handle($this->build(HttpHarness::INITIALIZED, [], true)); + HttpHarness::assertInitialized($response->getStatusCode(), (string) $response->getContent()); return $result; } @@ -102,12 +97,8 @@ public function callTool(string $name, array $arguments, ?array $meta = null): \ public function callRequest(string $name, array $arguments, ?array $meta = null): Request { $this->session(); - $params = ['name' => $name, 'arguments' => (object) $arguments]; - if ($meta !== null) { - $params['_meta'] = (object) $meta; - } - return $this->message('tools/call', $params, [], true); + return $this->message('tools/call', HttpHarness::callParams($name, $arguments, $meta), [], true); } /** From cfab293dbe74d0ea8924b0aedcaf799970189ba2 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 18:14:53 +0000 Subject: [PATCH 15/55] fix: read the bundle's environment-variable exclusions and service IDs at runtime - exclude_servers: a name written in the configuration is still removed when the container compiles; a value that holds an environment variable (any type, csv and json included) now compiles and is read by the tracker when it builds a server, which then leaves that server untracked. A string is a list of one, and a wrong type logs one line, as Laravel's exclude does. - A logger or hook service ID from an environment variable is not in the service locator: one line now says that the key takes a literal service ID, instead of the missing-logger line or a hook-guard line per event. - The log directory is made at the first line (LogFile), never while AgentCat logs nothing. - The info() texts say that MCPCAT_API_URL and DISABLE_DIAGNOSTICS are read too, and when exclude_servers names are read. - ServerNames holds the name matching of the pass and the tracker. - A console command (list) tracks nothing: no line, no diagnostics, no signal handler. --- composer-dependency-analyser.php | 2 + src/Symfony/AgentCatBundle.php | 38 +++- .../DependencyInjection/TrackBuildersPass.php | 27 +-- src/Symfony/Internal/LogFile.php | 36 +++ src/Symfony/Internal/ServerNames.php | 44 ++++ src/Symfony/Internal/ServerTracker.php | 72 ++++-- tests/Integration/Symfony/BundleTest.php | 211 +++++++++++++++++- tests/Unit/Symfony/AgentCatBundleTest.php | 9 +- tests/Unit/Symfony/TrackBuildersPassTest.php | 1 + 9 files changed, 377 insertions(+), 63 deletions(-) create mode 100644 src/Symfony/Internal/LogFile.php create mode 100644 src/Symfony/Internal/ServerNames.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index ad9c7ef..5dc50f6 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -38,5 +38,7 @@ ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\Console arrives with symfony/framework-bundle; a bundle test runs a console command as an application does. + ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/Integration/Symfony/BundleTest.php', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Routing arrives with symfony/framework-bundle and symfony/mcp-bundle; the test kernel imports the bundle's MCP routes with it. ->ignoreErrorsOnPackageAndPath('symfony/routing', __DIR__ . '/tests/Support/Symfony/TestKernel.php', [ErrorType::SHADOW_DEPENDENCY]); diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index 6bf5cb3..efd2889 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -40,20 +40,20 @@ public function configure(DefinitionConfigurator $definition): void $children = $definition->rootNode()->children(); $children->scalarNode('enabled')->defaultValue('%env(default::AGENTCAT_ENABLED)%')->info('On or off for every MCP server and every AgentCat call: true or false. Unset: on, except while your tests run.')->end(); $children->scalarNode('project_id')->defaultValue('%env(default::AGENTCAT_PROJECT_ID)%')->info('Your AgentCat project ID (proj_…). Unset: no events go to AgentCat; exporters still run.')->end(); - $children->arrayNode('exclude_servers')->info('Servers AgentCat never tracks: names of mcp.servers entries. A string is one name.')->beforeNormalization()->castToArray()->end()->scalarPrototype()->end()->defaultValue([])->end(); + $children->variableNode('exclude_servers')->defaultValue([])->info('Servers AgentCat never tracks: names of mcp.servers entries; a string is one name. Names written here are read when the container compiles, names from environment variables when each server is built.')->end(); $children->arrayNode('projects')->info('Another project for some servers: mcp.servers name => project ID. A null project is project_id.')->useAttributeAsKey('server')->scalarPrototype()->end()->defaultValue([])->end(); - $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp. Unset: their own file, agentcat.log in the log directory.')->end(); - $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL is the TypeScript SDK\'s name.')->end(); + $children->scalarNode('logger')->defaultNull()->info('The service ID of a PSR-3 logger for AgentCat\'s own lines, for example monolog.logger.mcp, written here, not in an environment variable. Unset: their own file, agentcat.log in the log directory.')->end(); + $children->scalarNode('api_url')->defaultValue('%env(default::AGENTCAT_API_URL)%')->info('The AgentCat API. Unset: AgentCat\'s. MCPCAT_API_URL, the TypeScript SDK\'s name, is read too.')->end(); foreach (self::BOOLS as $key => $default) { $children->scalarNode($key)->defaultValue($default)->end(); } $children->scalarNode('custom_context_description')->defaultNull()->end(); $children->scalarNode('enable_agent_tracking')->defaultFalse()->end(); foreach (self::HOOKS as $hook) { - $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, resolved when the hook runs.')->end(); + $children->scalarNode($hook)->defaultNull()->info('The service ID of an invokable service, written here, not in an environment variable. The service is resolved when the hook runs.')->end(); } $children->variableNode('exporters')->defaultNull()->info('Your own telemetry tools: name => {type: otlp|datadog|sentry|posthog, …}. A ~ entry is skipped.')->end(); - $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS is the TypeScript SDK\'s name.')->end(); + $children->variableNode('disable_diagnostics')->defaultValue(['%env(default::AGENTCAT_DISABLE_DIAGNOSTICS)%', '%env(default::DISABLE_DIAGNOSTICS)%'])->info('Diagnostics: AgentCat\'s own log lines, sent to AgentCat so that it can find broken installs. true turns them off; in a list, one true value is enough. DISABLE_DIAGNOSTICS, the TypeScript SDK\'s name, is read too.')->end(); $children->scalarNode('diagnostics_endpoint')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_ENDPOINT)%')->info('Where diagnostics go: an OpenTelemetry collector (OTLP/HTTP logs). Unset: AgentCat\'s.')->end(); $children->scalarNode('diagnostics_token')->defaultValue('%env(default::AGENTCAT_DIAGNOSTICS_TOKEN)%')->info('The bearer token of diagnostics_endpoint. Unset: AgentCat\'s.')->end(); $children->scalarNode('disable_signal_handlers')->defaultFalse()->end(); @@ -63,12 +63,25 @@ public function configure(DefinitionConfigurator $definition): void #[\Override] public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { + // The names written in the configuration: the pass removes those servers when the container compiles. The + // tracker reads the configuration's names again when it builds a server, from environment variables too. $excluded = $config['exclude_servers'] ?? []; - $builder->setParameter('agentcat.exclude_servers', is_array($excluded) ? $excluded : []); + $names = []; + foreach (is_array($excluded) ? $excluded : [$excluded] as $name) { + if (is_string($name) && !$this->variable($builder, $name)) { + $names[] = $name; + } + } + $builder->setParameter('agentcat.exclude_servers', $names); + // The services the configuration names by ID: the hooks and the logger. The container knows a service by the ID + // written here; an ID from an environment variable is known only at runtime, when the tracker says so. $services = []; + $variables = []; foreach ([...self::HOOKS, 'logger'] as $key) { $id = $config[$key] ?? null; - if (is_string($id) && $id !== '') { + if (is_string($id) && $this->variable($builder, $id)) { + $variables[] = $key; + } elseif (is_string($id) && $id !== '') { // A missing service is left out of the locator, and the container compiles: the hook guard logs it when // the hook runs, the tracker when it chooses the logger (D32). $services[$id] = new Reference($id, ContainerInterface::IGNORE_ON_INVALID_REFERENCE); @@ -82,9 +95,20 @@ public function loadExtension(array $config, ContainerConfigurator $container, C new Reference('agentcat.hooks'), new Reference('request_stack', ContainerInterface::NULL_ON_INVALID_REFERENCE), '%kernel.logs_dir%/agentcat.log', + '%agentcat.servers%', // every server name, which TrackBuildersPass writes + $variables, ]); } + /** Whether a value of the configuration holds an environment variable, which resolves at runtime only. */ + private function variable(ContainerBuilder $builder, string $value): bool + { + $variables = []; + $builder->resolveEnvPlaceholders($value, null, $variables); + + return $variables !== []; + } + #[\Override] public function build(ContainerBuilder $container): void { diff --git a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php index 2229507..1271132 100644 --- a/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php +++ b/src/Symfony/Internal/DependencyInjection/TrackBuildersPass.php @@ -5,6 +5,7 @@ namespace AgentCat\Symfony\Internal\DependencyInjection; use AgentCat\Symfony\Internal\BuilderConfigurator; +use AgentCat\Symfony\Internal\ServerNames; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; @@ -15,7 +16,9 @@ * attribute, and the `addRequestHandlers` method call with the tagged request handlers. That call moves into the * configurator, which tracks first and adds the handlers after AgentCat's: mcp/sdk answers with the first handler * that supports a request, so a customer's `tools/call` handler becomes the handler AgentCat delegates to. A builder - * without the call is tracked after its handlers. A server named in `agentcat.exclude_servers` keeps its definition. + * without the call is tracked after its handlers. A server that `agentcat.exclude_servers` names (the names written in + * the configuration; the bundle reads those from environment variables when it builds a server) keeps its definition. + * The pass writes every server name to `agentcat.servers`, for those runtime names. * * It runs before the optimization passes at priority -100, after symfony/mcp-bundle's `McpAppPass` (10) and `McpPass` * (0), so the configurator sees every method call they add. @@ -38,8 +41,9 @@ public function process(ContainerBuilder $container): void } } $servers = array_column($builders, 1); + $container->setParameter('agentcat.servers', $servers); foreach ($builders as [$id, $server]) { - if (!$this->excluded($server, $excluded, $servers)) { + if (!ServerNames::excluded($server, $excluded, $servers)) { $this->track($container, $id, $server); } } @@ -65,23 +69,4 @@ private function track(ContainerBuilder $container, string $id, string $server): ->setArguments([$server, new Reference('agentcat.server_tracker'), $handlers, $builder->getConfigurator()]); $builder->setConfigurator([new Reference($configurator), 'configure']); } - - /** - * Whether `exclude_servers` names the server. symfony/config writes a key `my-server` of `mcp.servers` as - * `my_server` (a key that holds an underscore, or whose other spelling is a key too, stays as written), so an entry - * names a server by either spelling. - * - * @param array $excluded - * @param list $servers every server name - */ - private function excluded(string $server, array $excluded, array $servers): bool - { - foreach ($excluded as $entry) { - if (is_string($entry) && $server === (in_array($entry, $servers, true) || str_contains($entry, '_') ? $entry : str_replace('-', '_', $entry))) { - return true; - } - } - - return false; - } } diff --git a/src/Symfony/Internal/LogFile.php b/src/Symfony/Internal/LogFile.php new file mode 100644 index 0000000..91b4602 --- /dev/null +++ b/src/Symfony/Internal/LogFile.php @@ -0,0 +1,36 @@ +file = new FileLogger($path); + } + + /** @param mixed[] $context */ + #[\Override] + public function log($level, string|\Stringable $message, array $context = []): void + { + $directory = dirname($this->path); + if (!is_dir($directory)) { + @mkdir($directory, recursive: true); // 0777 less the umask, as Symfony makes its directories + } + $this->file->log($level, $message, $context); + } +} diff --git a/src/Symfony/Internal/ServerNames.php b/src/Symfony/Internal/ServerNames.php new file mode 100644 index 0000000..32cec98 --- /dev/null +++ b/src/Symfony/Internal/ServerNames.php @@ -0,0 +1,44 @@ + $names the names in `exclude_servers`; an entry that is no string names nothing + * @param list $servers the name of every server, as symfony/mcp-bundle tags its builder + */ + public static function excluded(string $server, array $names, array $servers): bool + { + foreach ($names as $name) { + if (is_string($name) && self::server($name, $servers) === $server) { + return true; + } + } + + return false; + } + + /** + * The server a name selects: the name itself when it is a server, or when symfony/config keeps it as written. + * + * @param list $servers + */ + private static function server(string $name, array $servers): string + { + if (in_array($name, $servers, true) || str_contains($name, '_')) { + return $name; + } + + return str_replace('-', '_', $name); + } +} diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php index ddc2374..95d79ac 100644 --- a/src/Symfony/Internal/ServerTracker.php +++ b/src/Symfony/Internal/ServerTracker.php @@ -8,7 +8,6 @@ use AgentCat\Client; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; -use AgentCat\Internal\Logging\FileLogger; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\BridgeKeys; use AgentCat\Internal\Support\ConfigOptions; @@ -25,10 +24,11 @@ * Tracks each symfony/mcp-bundle server when the container builds its Builder (plan E2 header conflicts 2, 5, 6, 14). * Never throws: the customer's server must come out of the container whatever AgentCat does. * - * One tracker serves the container (a worker's too). At the first server it tracks, it reads the configuration, whose - * environment variables resolve then, and builds its client and the options, so each bad value logs once. A - * configuration that cannot be read (a variable without a default that is not set) logs one line for each server, which - * then answers untracked. + * One tracker serves the container (a worker's too). At the first server, it reads the configuration, whose environment + * variables resolve then, so each bad value logs once, at the first server it tracks. A server that `exclude_servers` + * names through an environment variable is left as it is: the pass removes only the servers named in the configuration + * itself. A configuration that cannot be read (a variable without a default that is not set) logs one line for each + * server, which then answers untracked. * * @internal */ @@ -39,24 +39,39 @@ final class ServerTracker private const string NO_LOGGER = 'WARN: AgentCat option "agentcat.logger" must be the ID of a PSR-3 logger service, got "%s"; the default is used'; + private const string LITERAL_ID = 'WARN: AgentCat option "agentcat.%s" must be a literal service ID, not an environment variable; the default is used'; + private ?Client $client = null; private ?Options $options = null; - /** @var array{project: ?string, projects: array, logger: ?string}|null the bundle's keys, read once */ + /** + * The bundle's keys, read once, and the configuration of the options. + * + * @var array{excluded: list, project: ?string, projects: array, logger: ?string, options: array}|null + */ private ?array $keys = null; + /** @var array the lines of bad values, written at the first server that AgentCat tracks */ + private array $lines = []; + /** - * @param \Closure(): mixed $config the processed agentcat configuration, an `\ArrayObject`, read when called - * @param ContainerInterface $services the services the configuration names, by ID: the hooks and the logger (a - * missing one is left out) - * @param string $logFile the file of AgentCat's lines without a `logger` (D61) + * @param \Closure(): mixed $config the processed agentcat configuration, an `\ArrayObject`, read when called + * @param ContainerInterface $services the services the configuration names, by ID: the hooks and the logger (a + * missing one is left out) + * @param string $logFile the file of AgentCat's lines without a `logger` (D61) + * @param list $servers the name of every server (`agentcat.servers`), for the names of + * `exclude_servers` + * @param list $variables the keys that name a service through an environment variable: the + * container knows a service by the ID written in the configuration only */ public function __construct( private readonly \Closure $config, private readonly ContainerInterface $services, private readonly ?RequestStack $requests, private readonly string $logFile, + private readonly array $servers = [], + private readonly array $variables = [], ) {} public function track(Builder $builder, string $server): void @@ -65,15 +80,18 @@ public function track(Builder $builder, string $server): void $config = ($this->config)(); $config = $config instanceof \ArrayObject ? $config->getArrayCopy() : []; $keys = $this->keys; - $lines = []; if ($keys === null) { - [$keys, $lines] = $this->keys($config); + [$keys, $this->lines] = $this->keys($config); $this->keys = $keys; } + if (ServerNames::excluded($server, $keys['excluded'], $this->servers)) { + return; // named through an environment variable: it touches neither the log nor diagnostics + } $this->adoptLogger($keys['logger']); - foreach ($lines as $line) { + foreach ($this->lines as $line) { Log::write($line); } + $this->lines = []; $client = AgentCat::client(); // A fake records every server (header conflict 6); the shared client is never replaced. if (!FakeLog::of($client) instanceof FakeLog) { @@ -83,7 +101,7 @@ public function track(Builder $builder, string $server): void httpContext: $this->requests instanceof RequestStack ? RequestStackContext::of($this->requests) : null, )); } - $this->options ??= ConfigOptions::options($config, self::BRIDGE_KEYS, $this->services->get(...)); + $this->options ??= ConfigOptions::options($keys['options'], self::BRIDGE_KEYS, $this->services->get(...)); // A null entry (`%env(default::…)%` of an unset variable) is the configured project; without one, the // client reads AGENTCAT_PROJECT_ID (D36). $client->track($builder, $keys['projects'][$server] ?? $keys['project'], $this->options); @@ -94,20 +112,34 @@ public function track(Builder $builder, string $server): void } /** - * The bundle's own keys, with their types, and the line of each bad value. + * The bundle's own keys, with their types, the configuration of the options, and the line of each bad value. A + * service ID from an environment variable is unset, with a line: the locator holds the IDs written in the + * configuration only. * * @param array $config * - * @return array{array{project: ?string, projects: array, logger: ?string}, array} + * @return array{array{excluded: list, project: ?string, projects: array, logger: ?string, options: array}, array} */ private function keys(array $config): array { /** @var \ArrayObject $lines */ $lines = new \ArrayObject(); + foreach ($this->variables as $key) { + if (($config[$key] ?? '') !== '') { + $lines->append(sprintf(self::LITERAL_ID, $key)); + } + $config[$key] = null; + } $read = new BridgeKeys($config, static function (string $line) use ($lines): void { $lines->append($line); }); - $keys = ['project' => $read->string('project_id'), 'projects' => $read->projects(), 'logger' => $read->string('logger')]; + $keys = [ + 'excluded' => $read->strings('exclude_servers', []), + 'project' => $read->string('project_id'), + 'projects' => $read->projects(), + 'logger' => $read->string('logger'), + 'options' => $config, + ]; $read->string('diagnostics_endpoint'); // read at boot (BridgeConfig); here for the line of a bad value $read->string('diagnostics_token'); @@ -140,11 +172,7 @@ private function adoptLogger(?string $id): void return; } - $directory = dirname($this->logFile); - if (!is_dir($directory)) { - @mkdir($directory, recursive: true); // 0777 less the umask; Symfony makes its log directory only for a write - } - Log::adopt(new FileLogger($this->logFile)); + Log::adopt(new LogFile($this->logFile)); // its directory is made at its first line if ($named) { Log::write(sprintf(self::NO_LOGGER, $id)); } diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index 2158a6f..c046482 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -7,6 +7,7 @@ use AgentCat\AgentCat; use AgentCat\Internal\Constants; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Diagnostics\Diagnostics; use AgentCat\Internal\Json\Json; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Tools\GetMoreTools; @@ -16,6 +17,7 @@ use AgentCat\Tests\Support\Laravel\InvokableIdentify; use AgentCat\Tests\Support\MemoryLogger; use AgentCat\Tests\Support\Symfony\CustomerCallHandler; +use AgentCat\Tests\Support\Symfony\DefinitionsProbe; use AgentCat\Tests\Support\Symfony\FailingHook; use AgentCat\Tests\Support\Symfony\SymfonyTestCase; use AgentCat\Tests\Support\Symfony\TestKernel; @@ -23,6 +25,10 @@ use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\DependencyInjection\Reference; /** * Every symfony/mcp-bundle server of an application is tracked once `AgentCatBundle` is registered (D27, D53, D55, @@ -43,12 +49,33 @@ final class BundleTest extends SymfonyTestCase private const string MISSING_IDENTIFY = 'Error: identify callback threw - Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException: Service "app.missing_identify" not found'; + /** The variables that these tests name in `%env()%` values; cleared before each test and put back after it. */ + private const array VARIABLES = ['AGENTCAT_TEST_EXCLUDE', 'AGENTCAT_TEST_LOGGER', 'AGENTCAT_TEST_IDENTIFY']; + + private const string LITERAL_ID = 'WARN: AgentCat option "agentcat.%s" must be a literal service ID, not an environment variable; the default is used'; + /** Two servers: `conformance` and `second` (`second-server`, on `/mcp/second`), with the same tools. */ private const array TWO_SERVERS = ['servers' => [ 'conformance' => TestKernel::CONFORMANCE, 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + TestKernel::CONFORMANCE, ]]; + private ?Environment $variables = null; + + #[\Override] + protected function setUp(): void + { + parent::setUp(); + $this->variables = Environment::clear(...self::VARIABLES); + } + + #[\Override] + protected function tearDown(): void + { + parent::tearDown(); + $this->variables?->restore(); + } + #[MaximumDuration(1000)] public function testEveryServerIsTrackedWithNoCode(): void { @@ -60,17 +87,29 @@ public function testEveryServerIsTrackedWithNoCode(): void $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); } + /** + * Header conflict 2 (Plan D carry): booting, and a console command that builds no MCP server, track nothing, so + * they get no diagnostics and no signal handlers, as Laravel's `artisan list`. + */ #[MaximumDuration(1000)] - public function testBootingTracksNothing(): void + public function testBootingAndACommandTrackNothing(): void { $logger = new MemoryLogger(); Log::setLogger($logger); - + $handlers = $this->signalHandlers(); $kernel = $this->kernel(agentcat: self::ON); + $console = new Application($kernel); + $console->setAutoExit(false); + $output = new BufferedOutput(); + + $exit = $console->run(new ArrayInput(['command' => 'list']), $output); - $this->assertTrue($kernel->getContainer()->has('mcp.server.conformance')); + $this->assertSame(0, $exit); + $this->assertStringContainsString('debug:mcp', $output->fetch()); // the bundle's command, listed without building a server $this->assertSame([], $logger->messages()); $this->assertFalse(EventQueue::global()->hasWork()); + $this->assertFalse(Diagnostics::enabled()); + $this->assertSame($handlers, $this->signalHandlers()); } /** The container builds the server once, so its Builder is tracked once, for every request of the container. */ @@ -119,28 +158,101 @@ public function testACustomerHandlersAnswerGetsTheMintBack(): void $this->assertSame('customer', $this->text($answer, 1)); } - /** @return iterable, array|null}> */ + /** @return iterable, array|null, string|null}> */ public static function exclusions(): iterable { - yield 'a list' => [['exclude_servers' => ['conformance']], null]; - yield 'a string' => [['exclude_servers' => 'conformance'], null]; - // symfony/config writes the key my-conformance of mcp.servers as my_conformance - yield 'a name with a hyphen' => [['exclude_servers' => ['my-conformance']], ['servers' => ['my-conformance' => TestKernel::CONFORMANCE]]]; + $hyphen = ['servers' => ['my-conformance' => TestKernel::CONFORMANCE]]; // symfony/config writes the key as my_conformance + yield 'a list' => [['exclude_servers' => ['conformance']], null, null]; + yield 'a string' => [['exclude_servers' => 'conformance'], null, null]; + yield 'a name with a hyphen' => [['exclude_servers' => ['my-conformance']], $hyphen, null]; + yield 'a variable' => [['exclude_servers' => '%env(AGENTCAT_TEST_EXCLUDE)%'], null, 'conformance']; + yield 'a variable in a list' => [['exclude_servers' => ['internal', '%env(AGENTCAT_TEST_EXCLUDE)%']], null, 'conformance']; + yield 'a csv variable' => [['exclude_servers' => '%env(csv:AGENTCAT_TEST_EXCLUDE)%'], null, 'internal,conformance']; + yield 'a variable with a hyphen' => [['exclude_servers' => '%env(AGENTCAT_TEST_EXCLUDE)%'], $hyphen, 'my-conformance']; } /** - * D53 by `mcp.servers` name, at compile time. + * D53 by `mcp.servers` name: a name written in the configuration when the container compiles, a name from an + * environment variable when the server is built (it then gets no AgentCat handler, as without AgentCat). * * @param array $exclusion * @param array|null $mcp */ #[DataProvider('exclusions')] #[MaximumDuration(2000)] - public function testAnExcludedServerIsLeftAlone(array $exclusion, ?array $mcp): void + public function testAnExcludedServerIsLeftAlone(array $exclusion, ?array $mcp, ?string $variable): void { + if ($variable !== null) { + putenv('AGENTCAT_TEST_EXCLUDE=' . $variable); + } + $this->assertUntouched([...self::ON, ...$exclusion], $mcp); } + /** @return iterable}> */ + public static function writtenNames(): iterable + { + $second = [new Reference('agentcat.builder_configurator.second'), 'configure']; + yield 'a list' => [[5, 'conformance', 'my-third', '%env(AGENTCAT_TEST_EXCLUDE)%'], ['conformance' => null, 'second' => $second, 'my_third' => null]]; + yield 'a string' => ['conformance', ['conformance' => null, 'second' => $second, 'my_third' => [new Reference('agentcat.builder_configurator.my_third'), 'configure']]]; + } + + /** + * The ruling of fix round 1: a name written in the configuration leaves the builder's definition as it is (no + * configurator), one from an environment variable reaches the configurator, which leaves the server untracked. An + * entry that is no name never fails the compile. + * + * @param array $configurators + */ + #[DataProvider('writtenNames')] + #[MaximumDuration(1000)] + public function testAWrittenNameIsRemovedWhenTheContainerCompiles(mixed $exclusion, array $configurators): void + { + putenv('AGENTCAT_TEST_EXCLUDE=second'); + $probe = new DefinitionsProbe(); + + $this->kernel(agentcat: [...self::ON, 'exclude_servers' => $exclusion], probe: $probe, mcp: ['servers' => [...self::TWO_SERVERS['servers'], 'my-third' => ['name' => 'third-server', 'http' => ['path' => '/mcp/third']] + TestKernel::CONFORMANCE]]); + + $this->assertEquals($configurators, array_map(static fn(array $builder): mixed => $builder['configurator'], $probe->builders)); + } + + /** A variable that names another server excludes nothing here. */ + #[MaximumDuration(1000)] + public function testAVariableThatNamesAnotherServerLeavesThisOneTracked(): void + { + putenv('AGENTCAT_TEST_EXCLUDE=internal'); + $kernel = $this->kernel(agentcat: [...self::ON, 'exclude_servers' => '%env(AGENTCAT_TEST_EXCLUDE)%']); + + $tools = $this->http($kernel)->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + } + + /** @return iterable */ + public static function wrongExclusions(): iterable + { + yield 'a value' => ['%env(int:AGENTCAT_TEST_EXCLUDE)%', '5', 'WARN: AgentCat option "agentcat.exclude_servers" must be array or string, got int; the default is used']; + yield 'an entry' => ['%env(json:AGENTCAT_TEST_EXCLUDE)%', '["internal", 7]', 'WARN: AgentCat option "agentcat.exclude_servers.1" must be string, got int; the entry is ignored']; + } + + /** As Laravel's `exclude`: a value or an entry of a wrong type logs one line, and excludes nothing. */ + #[DataProvider('wrongExclusions')] + #[MaximumDuration(1000)] + public function testAVariableOfAWrongTypeLogsOneLine(string $exclusion, string $variable, string $line): void + { + putenv('AGENTCAT_TEST_EXCLUDE=' . $variable); + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: [...self::ON, 'exclude_servers' => $exclusion]); + + $tools = $this->http($kernel)->listTools()->tools; + + $this->assertIsArray($tools); + $this->assertSame(GetMoreTools::NAME, JsonAssert::at(end($tools), 'name')); + $this->assertSame([$line], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_starts_with($line, 'WARN:')))); + } + #[MaximumDuration(1000)] public function testAnExclusionLeavesTheOtherServersTracked(): void { @@ -168,6 +280,17 @@ public function testWithAgentCatOffTheServerIsLeftAlone(): void $this->assertUntouched(['enabled' => false, 'project_id' => self::PROJECT]); } + /** D61: the log directory is made for the first line only, so AgentCat off makes none. */ + #[MaximumDuration(1000)] + public function testWithAgentCatOffNoLogDirectoryIsMade(): void + { + $kernel = $this->kernel(agentcat: ['enabled' => false, 'project_id' => self::PROJECT]); + + $this->http($kernel)->callTool('echo', ['msg' => 'hi']); + + $this->assertDirectoryDoesNotExist($kernel->getLogDir()); + } + /** Review Focus 3, ruling P9: `AGENTCAT_ENABLED=maybe` gives one line, and the D31 default (off under PHPUnit). */ #[MaximumDuration(2000)] public function testAnUnknownEnabledWordLogsOneLineAndLeavesTheServerAlone(): void @@ -299,6 +422,53 @@ public function testEveryHookIsAServiceOfTheContainer(string $hook): void $this->assertCount(1, array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'RuntimeException: ' . $hook . ' ran'))); } + /** + * The container knows a service by the ID written in the configuration: one from an environment variable is not + * read, and one line says so. The default is used: AgentCat's own file. + */ + #[MaximumDuration(1000)] + public function testALoggerIdFromAVariableIsNotReadAndOneLineSaysSo(): void + { + putenv('AGENTCAT_TEST_LOGGER=app.agentcat_logger'); + $logger = new MemoryLogger(); + $kernel = $this->kernel(agentcat: [...self::ON, 'logger' => '%env(AGENTCAT_TEST_LOGGER)%'], services: ['app.agentcat_logger' => $logger]); + + $this->http($kernel)->listTools(); + + $this->assertSame([], $logger->messages()); + $lines = $this->agentCatLines($kernel); + $this->assertSame(sprintf(self::LITERAL_ID, 'logger'), $lines[0] ?? null); + $this->assertCount(1, array_filter($lines, static fn(string $line): bool => str_contains($line, 'agentcat.logger'))); + } + + /** `%env(default::…)%` of an unset variable is an unset key: the default, no line. */ + #[MaximumDuration(1000)] + public function testALoggerFromAnUnsetVariableIsTheDefault(): void + { + $kernel = $this->kernel(agentcat: [...self::ON, 'logger' => '%env(default::AGENTCAT_TEST_LOGGER)%']); + + $this->http($kernel)->listTools(); + + $this->assertSame('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', $this->agentCatLines($kernel)[0] ?? null); + } + + /** A hook ID from an environment variable: one line when the server is tracked, and the hook never runs. */ + #[MaximumDuration(1000)] + public function testAHookIdFromAVariableIsNotReadAndOneLineSaysSo(): void + { + putenv('AGENTCAT_TEST_IDENTIFY=app.identify'); + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: [...self::ON, 'identify' => '%env(AGENTCAT_TEST_IDENTIFY)%'], services: ['app.identify' => new InvokableIdentify('from-the-container')]); + $http = $this->http($kernel); + + $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $http->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $this->assertSame([[], []], array_map(static fn(Delivery $delivery): array => (array) JsonAssert::at(Json::decode($delivery->body), 'identify_data'), $this->deliveries())); + $this->assertSame([sprintf(self::LITERAL_ID, 'identify')], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'identify')))); + } + /** * Header conflict 5: the HTTP context of a call is the RequestStack's main request, not the SAPI globals, with the * headers the client sent: not the `X-Php-Ob-Level` that Symfony's HttpKernel adds (Plan E1 Task 6 parity). @@ -389,9 +559,24 @@ public function testALoggerServiceGetsTheLines(): void $this->http($kernel)->listTools(); $this->assertContains('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', $logger->messages()); + $this->assertSame([], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'agentcat.logger')))); $this->assertDirectoryDoesNotExist($kernel->getLogDir()); } + /** A logger that the application chose (`Log::setLogger()`, a fake's) keeps the lines: the key is not read then. */ + #[MaximumDuration(1000)] + public function testAChosenLoggerLeavesTheLoggerKeyUnread(): void + { + $logger = new MemoryLogger(); + Log::setLogger($logger); + $kernel = $this->kernel(agentcat: [...self::ON, 'logger' => 'app.missing_logger']); + + $this->http($kernel)->listTools(); + + $this->assertContains('AgentCat setup started | project ' . self::PROJECT . ' | sdk mcp-sdk/builder | signals mcpSdkBuilder', $logger->messages()); + $this->assertSame([], array_values(array_filter($logger->messages(), static fn(string $line): bool => str_contains($line, 'agentcat.logger')))); + } + /** @return iterable */ public static function badLoggers(): iterable { @@ -494,6 +679,12 @@ private function answers(TestKernel $kernel): array return [$http->listTools(), $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; } + /** @return array the handlers of SIGTERM and SIGINT; none without ext-pcntl */ + private function signalHandlers(): array + { + return function_exists('pcntl_signal_get_handler') ? [\SIGTERM => pcntl_signal_get_handler(\SIGTERM), \SIGINT => pcntl_signal_get_handler(\SIGINT)] : []; + } + /** The prefix that the line starts with, else the line. */ private static function prefixed(string $line, string ...$prefixes): string { diff --git a/tests/Unit/Symfony/AgentCatBundleTest.php b/tests/Unit/Symfony/AgentCatBundleTest.php index 1bd29da..5f2cebc 100644 --- a/tests/Unit/Symfony/AgentCatBundleTest.php +++ b/tests/Unit/Symfony/AgentCatBundleTest.php @@ -60,10 +60,13 @@ public function testTheTreeHasTheBundleKeysAndTheOptions(): void $this->assertEqualsCanonicalizing([...ServerTracker::BRIDGE_KEYS, ...array_keys(ConfigOptions::KEYS)], array_keys($this->process([]))); } - /** Plan E1 final ruling M2: a string is a list of one server. */ - public function testAStringExclusionIsAListOfOneServer(): void + /** + * `exclude_servers` takes any value: a name or a list of names written here, which the pass reads when the + * container compiles, or an environment variable of any type, which the tracker reads when it builds a server. + */ + public function testExclusionsTakeNamesListsAndVariables(): void { - $this->assertSame(['internal'], $this->process(['exclude_servers' => 'internal'])['exclude_servers'] ?? null); + $this->assertSame(['internal', 'admin', '%env(csv:AGENTCAT_EXCLUDE)%'], array_map(fn(mixed $exclusion): mixed => $this->process(['exclude_servers' => $exclusion])['exclude_servers'] ?? null, ['internal', 'admin', '%env(csv:AGENTCAT_EXCLUDE)%'])); $this->assertSame(['internal', 'admin'], $this->process(['exclude_servers' => ['internal', 'admin']])['exclude_servers'] ?? null); } diff --git a/tests/Unit/Symfony/TrackBuildersPassTest.php b/tests/Unit/Symfony/TrackBuildersPassTest.php index fdcc071..8654d7f 100644 --- a/tests/Unit/Symfony/TrackBuildersPassTest.php +++ b/tests/Unit/Symfony/TrackBuildersPassTest.php @@ -88,6 +88,7 @@ public function testEachServerGetsItsOwnConfigurator(): void (new TrackBuildersPass())->process($container); + $this->assertSame(['main', 'internal'], $container->getParameter('agentcat.servers')); // for the names from environment variables foreach (['main', 'internal'] as $server) { $this->assertSame([], $container->getDefinition('mcp.server.' . $server . '.builder')->getMethodCalls()); $this->assertEquals([$server, new Reference('agentcat.server_tracker'), [[new Reference('app.' . $server)]], null], $container->getDefinition('agentcat.builder_configurator.' . $server)->getArguments()); From b0864fc14715a54115a896db83affd5ac7fbb618 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:09:01 +0000 Subject: [PATCH 16/55] test: give each Laravel Content-Length test a maximum duration, as the first Laravel application of a PHP 8.5 process takes about 0.1 s --- tests/Integration/Laravel/AnswerLengthTest.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Laravel/AnswerLengthTest.php b/tests/Integration/Laravel/AnswerLengthTest.php index 59bdbf8..30b93cf 100644 --- a/tests/Integration/Laravel/AnswerLengthTest.php +++ b/tests/Integration/Laravel/AnswerLengthTest.php @@ -22,7 +22,9 @@ /** * D59 through Laravel's HTTP kernel: on a SAPI that sends a response only when the script ends, the plain answer of a * tracked laravel/mcp server gets its `Content-Length`. The tests run under the CLI, so each one that needs the - * listener registers it as `AgentCatServiceProvider` does on such a SAPI. + * listener registers it as `AgentCatServiceProvider` does on such a SAPI. Each test builds a Laravel application and + * sends one or two requests: under 0.02 s measured on PHP 8.3 and 8.5 (the streamed answer about 0.1 s), but the first + * application of a process takes 0.09 to 0.14 s on PHP 8.5, and any test of this class can be the first. */ #[Group('ac-03-1')] final class AnswerLengthTest extends BridgeTestCase @@ -45,6 +47,7 @@ protected function defineRoutes(mixed $router): void } /** Under the CLI the provider adds no listener: the SAPI has no request that ends with the script. */ + #[MaximumDuration(1000)] public function testThisSapiGetsNoListener(): void { $this->assertFalse(FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, function_exists(...))); @@ -55,6 +58,7 @@ public function testThisSapiGetsNoListener(): void $this->assertFalse($response->headers->has('Content-Length')); } + #[MaximumDuration(1000)] public function testATrackedServersAnswerGetsItsLength(): void { AnswerLength::register($this->application(), true); @@ -68,6 +72,7 @@ public function testATrackedServersAnswerGetsItsLength(): void $this->assertCount(1, $this->deliveries(), 'the server was tracked'); } + #[MaximumDuration(1000)] public function testAnExcludedServersAnswerStaysAsItIs(): void { AnswerLength::register($this->application(), true); @@ -80,6 +85,7 @@ public function testAnExcludedServersAnswerStaysAsItIs(): void $this->assertSame([], $this->deliveries()); } + #[MaximumDuration(1000)] public function testAnAnswerWhileAgentCatIsOffStaysAsItIs(): void { AnswerLength::register($this->application(), true); @@ -105,6 +111,7 @@ public function testAStreamedAnswerStaysAsItIs(): void $this->assertCount(1, $this->deliveries(), 'the server was tracked'); } + #[MaximumDuration(1000)] public function testAnotherRouteStaysAsItIs(): void { AnswerLength::register($this->application(), true); @@ -116,6 +123,7 @@ public function testAnotherRouteStaysAsItIs(): void } /** The mark lives on the request: in one application, like an Octane worker, the next request does not inherit it. */ + #[MaximumDuration(1000)] public function testTheNextRequestOfTheApplicationIsNotMarked(): void { AnswerLength::register($this->application(), true); From 707eb6bbfa7c0ee98976ccbb3b260f71b9cd83e5 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:10:16 +0000 Subject: [PATCH 17/55] feat: deliver Symfony events on kernel.terminate, waiting at most 1 s in workers --- composer-dependency-analyser.php | 2 + src/Symfony/AgentCatBundle.php | 5 + src/Symfony/Internal/TerminateListener.php | 30 +++ .../Symfony/CustomerTestSuiteTest.php | 55 +++++ tests/Integration/Symfony/DeliveryTest.php | 215 ++++++++++++++++++ tests/Support/Symfony/HeaderIdentity.php | 30 +++ .../Symfony/Streaming/ProgressTool.php | 26 +++ tests/Support/Symfony/SymfonyHttp.php | 42 ++-- 8 files changed, 391 insertions(+), 14 deletions(-) create mode 100644 src/Symfony/Internal/TerminateListener.php create mode 100644 tests/Integration/Symfony/CustomerTestSuiteTest.php create mode 100644 tests/Integration/Symfony/DeliveryTest.php create mode 100644 tests/Support/Symfony/HeaderIdentity.php create mode 100644 tests/Support/Symfony/Streaming/ProgressTool.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 5dc50f6..23a37a2 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -34,6 +34,8 @@ ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it. ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle tests read the kernel's listeners from it. + ->ignoreErrorsOnPackageAndPath('symfony/event-dispatcher', __DIR__ . '/tests/Integration/Symfony', [ErrorType::SHADOW_DEPENDENCY]) // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index efd2889..10c7f8a 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -7,6 +7,7 @@ use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Symfony\Internal\DependencyInjection\TrackBuildersPass; use AgentCat\Symfony\Internal\ServerTracker; +use AgentCat\Symfony\Internal\TerminateListener; use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; use Symfony\Component\DependencyInjection\Compiler\PassConfig; @@ -16,6 +17,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\DependencyInjection\ServiceLocator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Symfony\Component\HttpKernel\KernelEvents; /** * Tracks every symfony/mcp-bundle server of the application with AgentCat. The Flex recipe registers it and writes @@ -98,6 +100,9 @@ public function loadExtension(array $config, ContainerConfigurator $container, C '%agentcat.servers%', // every server name, which TrackBuildersPass writes $variables, ]); + // D58: the wait for delivery after each response, last of kernel.terminate (header conflict 7). + $builder->register('agentcat.terminate_listener', TerminateListener::class) + ->addTag('kernel.event_listener', ['event' => KernelEvents::TERMINATE, 'priority' => TerminateListener::PRIORITY]); } /** Whether a value of the configuration holds an environment variable, which resolves at runtime only. */ diff --git a/src/Symfony/Internal/TerminateListener.php b/src/Symfony/Internal/TerminateListener.php new file mode 100644 index 0000000..c06c901 --- /dev/null +++ b/src/Symfony/Internal/TerminateListener.php @@ -0,0 +1,30 @@ +flush(RequestEndWait::seconds(console: false)); // kernel.terminate never runs for a command + } catch (\Throwable) { + // delivery never breaks the kernel's terminate phase + } + } +} diff --git a/tests/Integration/Symfony/CustomerTestSuiteTest.php b/tests/Integration/Symfony/CustomerTestSuiteTest.php new file mode 100644 index 0000000..969cc48 --- /dev/null +++ b/tests/Integration/Symfony/CustomerTestSuiteTest.php @@ -0,0 +1,55 @@ +http($this->kernel(withAgentCat: false)); + $plain = [$untracked->listTools(), $untracked->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; + $logger = new MemoryLogger(); + Log::setLogger($logger); + + $http = $this->http($this->kernel()); + $tracked = [$http->listTools(), $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; + + $this->assertEquals($plain, $tracked); + $this->assertSame([], $logger->messages()); + $this->assertFalse(EventQueue::global()->hasWork()); + } + + /** A fake before the kernel boots records each call: the bundle tracks with the fake's client (header conflict 6). */ + #[MaximumDuration(1000)] + public function testAFakeBeforeTheKernelBootsRecordsTheCalls(): void + { + $fake = AgentCat::fake(); + $http = $this->http($this->kernel()); + $http->listTools(); + + $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); + $http->callTool('echo', ['msg' => 'again', 'session_id' => 'start']); + + $fake->assertTrackedCount(2); + $this->assertSame(['echo', 'echo'], array_map(static fn(Event $event): ?string => $event->resourceName, $fake->events())); + $this->assertFalse(EventQueue::global()->hasWork()); // a fake sends nothing + } +} diff --git a/tests/Integration/Symfony/DeliveryTest.php b/tests/Integration/Symfony/DeliveryTest.php new file mode 100644 index 0000000..e907b31 --- /dev/null +++ b/tests/Integration/Symfony/DeliveryTest.php @@ -0,0 +1,215 @@ +stop(); + self::$collector = null; + } + + #[Group('ac-03-12')] + #[Group('ac-02-48')] + #[MaximumDuration(1000)] + public function testKernelTerminateDeliversTheRequestsEvents(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + $http = $this->http($kernel); + $http->listTools(); + $request = $http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start']); + + $response = $kernel->handle($request); + $this->assertCount(0, $this->transport->deliveries); // queued after the call's own pump: nothing sent yet + $kernel->terminate($request, $response); + + $this->assertCount(1, $this->transport->deliveries); + } + + /** Priority -2048: after Symfony's own `kernel.terminate` listeners and the application's. */ + #[MaximumDuration(1000)] + public function testTheListenerRunsLast(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + $dispatcher = $kernel->getContainer()->get('event_dispatcher'); + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + + $listeners = $dispatcher->getListeners(KernelEvents::TERMINATE); // sorted: the last has the lowest priority + $last = end($listeners); + + $this->assertIsCallable($last); + $this->assertInstanceOf(TerminateListener::class, is_array($last) ? $last[0] : $last); + $this->assertSame(-2048, $dispatcher->getListenerPriority(KernelEvents::TERMINATE, $last)); + } + + /** Header conflict 7: a Symfony application without AgentCat's bundle has no such listener. */ + #[MaximumDuration(1000)] + public function testWithoutTheBundleNothingWaits(): void + { + $kernel = $this->kernel(withAgentCat: false); + $dispatcher = $kernel->getContainer()->get('event_dispatcher'); + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + + $listeners = array_map(static fn(mixed $listener): mixed => is_array($listener) ? $listener[0] ?? null : $listener, $dispatcher->getListeners(KernelEvents::TERMINATE)); + + $this->assertSame([], array_filter($listeners, static fn(mixed $listener): bool => $listener instanceof TerminateListener)); + } + + #[Group('ac-03-1')] + #[MaximumDuration(1000)] + public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + $http = $this->http($kernel); + $http->listTools(); + $this->transport->complete = false; // an API that does not answer within the request; the clock is simulated + $request = $http->callRequest('echo', ['msg' => 'one', 'session_id' => 'start']); + $response = $kernel->handle($request); + $before = $this->transport->now(); + + $kernel->terminate($request, $response); + + $this->assertSame(RequestEndWait::WORKER_SECONDS, $this->transport->now() - $before); // the cap (D58), not the 5 s budget + $this->assertSame(1, $this->transport->inFlight()); + $this->transport->complete = true; + $http->callTool('echo', ['msg' => 'two', 'session_id' => 'start']); // the next request's pump() finishes the first delivery + + $this->assertSame(0, $this->transport->inFlight()); + } + + /** + * The real curl transport. The collector answers after 4 s: a listener that waited for the 5 s shutdown budget + * would hold the terminate phase at least that long, and the worker's stop still has time to deliver (ruling P16). + */ + #[MaximumDuration(12000)] + public function testWithASlowApiTerminateReturnsAfterAboutOneSecondAndTheWorkerStopDelivers(): void + { + $collector = self::$collector; + $this->assertInstanceOf(MockCollector::class, $collector); + $scenario = $collector->scenario([['status' => 202, 'delayMs' => 4000]]); + EventQueue::setGlobal(new EventQueue()); // the real curl transport, before the kernel builds a server + $kernel = $this->kernel(agentcat: [...$this->on(), 'api_url' => $scenario]); + $http = $this->http($kernel); + $http->listTools(); + $request = $http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start']); + $response = $kernel->handle($request); + $started = microtime(true); + + $kernel->terminate($request, $response); + + $elapsed = microtime(true) - $started; + $this->assertGreaterThanOrEqual(RequestEndWait::WORKER_SECONDS - 0.1, $elapsed); // it waited for the slow API ... + $this->assertLessThan(RequestEndWait::WORKER_SECONDS + 1.5, $elapsed); // ... for the cap, not for the 5 s shutdown budget + $this->assertSame([], $collector->requests($scenario)); // the collector records a request when it answers + EventQueue::global()->finalShutdown(); // what a worker's shutdown function does when the worker stops + + $this->assertCount(1, $collector->requests($scenario)); // the shutdown waited for the answer inside its 5 s + } + + /** + * Ruling P10 (plan E1 final ruling I1, D04): one kernel, requests with different `X-User`, and an `identify` service + * that reads the `request_stack`, while the AgentCat API backs up. The first five deliveries stay in flight, so the + * later events wait for a free slot past their own request. Each hook still runs before its request's terminate + * phase ends, so it never reads a later request: it finds the stack empty, as `HttpKernel::handle()` pops the request + * before `kernel.terminate`. Hooks read the `ToolCall`, which carries each request's own headers. + */ + #[Group('ac-03-1')] + #[MaximumDuration(2000)] + public function testAHookThatReadsTheRequestStackNeverSeesALaterRequest(): void + { + $kernel = $this->kernel(agentcat: [...$this->on(), 'identify' => 'app.identify'], services: ['app.identify' => HeaderIdentity::class]); + $identity = $kernel->getContainer()->get('app.identify'); + $this->assertInstanceOf(HeaderIdentity::class, $identity); + $http = $this->http($kernel); + $http->listTools(); + $this->transport->complete = false; // a slow AgentCat API: the deliveries stay in flight; the clock is simulated + $users = ['u1', 'u2', 'u3', 'u4', 'u5', 'u6', 'u7', 'u8']; + + foreach ($users as $done => $user) { + $request = $http->callRequest('echo', ['msg' => $user, 'session_id' => 'start'], headers: ['X-User' => $user]); + $kernel->terminate($request, $kernel->handle($request)); + + $this->assertCount($done + 1, $identity->seen, 'the hook of ' . $user . ' ran in its own request'); + } + $this->assertSame(EventQueue::CONCURRENCY, $this->transport->inFlight()); // u6 to u8 wait for a free slot + $this->transport->complete = true; + + $bodies = array_map(static fn(Delivery $d): mixed => Json::decode($d->body), $this->deliveries()); + $this->assertSame($users, array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'parameters', 'request', 'params', 'arguments', 'msg'), $bodies)); + $this->assertSame($users, array_map(static fn(mixed $body): mixed => JsonAssert::at($body, 'identify_actor_given_id'), $bodies)); + $this->assertSame(array_map(static fn(string $user): array => [$user, null], $users), $identity->seen); + } + + /** + * An SSE answer runs the rest of the call in the `StreamedResponse` callback, after `HttpKernel::handle()` popped the + * request; HttpKernel pushes it again around the callback, so the event carries its own request's headers. + */ + #[Group('ac-02-43')] + #[MaximumDuration(1000)] + public function testAStreamedAnswersEventCarriesItsOwnRequestsHeaders(): void + { + $kernel = $this->kernel(agentcat: $this->on(), services: [ProgressTool::class => ProgressTool::class], mcp: ['servers' => ['conformance' => ['registry' => [...TestKernel::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + TestKernel::CONFORMANCE]]); + $http = $this->http($kernel); + $http->listTools(); + $request = $http->callRequest('progress', ['msg' => 'hi', 'session_id' => 'start'], ['progressToken' => 'p1'], ['X-User' => 'streamed']); + $response = $kernel->handle($request); + $this->assertInstanceOf(StreamedResponse::class, $response); + + $body = SymfonyHttp::body($response); // the client reads the stream: the tool finishes now + $kernel->terminate($request, $response); + + $this->assertStringContainsString('"message":"half way"', $body); // the progress notification + $this->assertStringContainsString('done: hi', $body); + $this->assertCount(1, $this->transport->deliveries); + $headers = JsonAssert::at(Json::decode($this->transport->deliveries[0]->body), 'parameters', 'extra', 'requestInfo', 'headers'); + $this->assertSame('streamed', JsonAssert::at($headers, 'x-user')); + } + + /** + * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. + * + * @return array + */ + private function on(): array + { + return ['enabled' => 'true', 'project_id' => self::PROJECT, 'api_url' => MockCollector::closedPortUrl()]; + } +} diff --git a/tests/Support/Symfony/HeaderIdentity.php b/tests/Support/Symfony/HeaderIdentity.php new file mode 100644 index 0000000..fcf101e --- /dev/null +++ b/tests/Support/Symfony/HeaderIdentity.php @@ -0,0 +1,30 @@ + per call: the call's user, and the user of the stack's current request */ + public array $seen = []; + + public function __construct(private readonly RequestStack $requests) {} + + public function __invoke(ToolCall $call): Identity + { + $user = $call->headers['x-user'] ?? 'nobody'; + $this->seen[] = [$user, $this->requests->getCurrentRequest()?->headers->get('X-User')]; + + return new Identity($user); + } +} diff --git a/tests/Support/Symfony/Streaming/ProgressTool.php b/tests/Support/Symfony/Streaming/ProgressTool.php new file mode 100644 index 0000000..bf01bc1 --- /dev/null +++ b/tests/Support/Symfony/Streaming/ProgressTool.php @@ -0,0 +1,26 @@ +`. Outside `Tools/`, so + * only a test that gives it to a server's registry exposes it. + */ +final class ProgressTool +{ + #[McpTool(name: 'progress', description: 'Reports progress')] + public function __invoke(string $msg, ClientGateway $gateway): string + { + $gateway->progress(0.5, 1.0, 'half way'); + ToolCalls::record('progress', ['msg' => $msg]); + + return 'done: ' . $msg; + } +} diff --git a/tests/Support/Symfony/SymfonyHttp.php b/tests/Support/Symfony/SymfonyHttp.php index dd46724..45673f4 100644 --- a/tests/Support/Symfony/SymfonyHttp.php +++ b/tests/Support/Symfony/SymfonyHttp.php @@ -93,12 +93,13 @@ public function callTool(string $name, array $arguments, ?array $meta = null): \ * * @param array $arguments * @param array|null $meta + * @param array $headers more headers of the request */ - public function callRequest(string $name, array $arguments, ?array $meta = null): Request + public function callRequest(string $name, array $arguments, ?array $meta = null, array $headers = []): Request { $this->session(); - return $this->message('tools/call', HttpHarness::callParams($name, $arguments, $meta), [], true); + return $this->message('tools/call', HttpHarness::callParams($name, $arguments, $meta), $headers, true); } /** @@ -166,20 +167,33 @@ private function handle(Request $request): Response return $response; } - /** The message of an answer that answers request `$id`: a JSON body, or a `data:` line of an SSE stream. */ - private function answer(Response $response, int $id): \stdClass + /** + * The body of an answer. A streamed one (SSE) is sent into an output buffer whose handler keeps every chunk: + * mcp/sdk flushes each event (`@ob_flush()`), which would hand a plain buffer's bytes to the buffer below it. + */ + public static function body(Response $response): string { - if ($response instanceof StreamedResponse) { - ob_start(); - try { - $response->sendContent(); - } finally { - $body = (string) ob_get_clean(); - } - } else { - $body = (string) $response->getContent(); + if (!$response instanceof StreamedResponse) { + return (string) $response->getContent(); + } + $chunks = new \ArrayObject(); + ob_start(static function (string $chunk) use ($chunks): string { + $chunks->append($chunk); + + return ''; + }); + try { + $response->sendContent(); + } finally { + ob_end_flush(); // the handler gets the rest and passes on nothing } - return HttpHarness::decode($body, (string) $response->headers->get('Content-Type'), $id); + return implode('', $chunks->getArrayCopy()); + } + + /** The message of an answer that answers request `$id`: a JSON body, or a `data:` line of an SSE stream. */ + private function answer(Response $response, int $id): \stdClass + { + return HttpHarness::decode(self::body($response), (string) $response->headers->get('Content-Type'), $id); } } From 0352863d05ba4f8f26832458ef3960b28796cbce Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:10:45 +0000 Subject: [PATCH 18/55] refactor: move the Laravel bridge's Content-Length rule into one core helper for both bridges --- src/Internal/Delivery/ContentLength.php | 63 ++++++++++ src/Laravel/Internal/AnswerLength.php | 38 ++---- .../Internal/Delivery/ContentLengthTest.php | 110 ++++++++++++++++++ tests/Unit/Laravel/AnswerLengthTest.php | 91 +-------------- 4 files changed, 188 insertions(+), 114 deletions(-) create mode 100644 src/Internal/Delivery/ContentLength.php create mode 100644 tests/Unit/Internal/Delivery/ContentLengthTest.php diff --git a/src/Internal/Delivery/ContentLength.php b/src/Internal/Delivery/ContentLength.php new file mode 100644 index 0000000..8d736c0 --- /dev/null +++ b/src/Internal/Delivery/ContentLength.php @@ -0,0 +1,63 @@ +headersSent = $headersSent ?? static fn(): bool => headers_sent(); + } + + /** + * The length in bytes to give an answer, or null when it goes as it is: a streamed answer (SSE), one with a + * `Content-Length` or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or + * bytes waiting in an output buffer, which go out first and would make the length short). + * + * @param array $headers the answer's headers by name, in any case (`HeaderBag::all()`) + * @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`) + */ + public function of(array $headers, string|false $content): ?string + { + if ($content === false) { + return null; + } + $headers = array_change_key_case($headers); + if (array_key_exists('content-length', $headers) || array_key_exists('transfer-encoding', $headers)) { + return null; + } + if (($this->headersSent)() || $this->outputWaits()) { + return null; // stray output goes out before the answer: a length of the answer alone would cut its end + } + + return (string) strlen($content); + } + + /** Some output buffer level holds bytes, which PHP sends before the answer. */ + private function outputWaits(): bool + { + foreach (ob_get_status(true) as $status) { + $used = is_array($status) ? $status['buffer_used'] ?? null : null; + if (is_int($used) && $used > 0) { + return true; + } + } + + return false; + } +} diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php index 14558cb..93a7ad6 100644 --- a/src/Laravel/Internal/AnswerLength.php +++ b/src/Laravel/Internal/AnswerLength.php @@ -4,6 +4,7 @@ namespace AgentCat\Laravel\Internal; +use AgentCat\Internal\Delivery\ContentLength; use AgentCat\Internal\Delivery\FinishRequest; use AgentCat\LaravelMcp\LaravelMcpAdapter; use Illuminate\Contracts\Foundation\Application; @@ -14,10 +15,8 @@ * D59: laravel/mcp sends its answers without `Content-Length`. On a SAPI that sends a response only when the script * ends (mod_php, php-cgi, `php -S`: `FinishRequest::responseEndsWithTheScript()`), the client of such an answer then * waits for AgentCat's hooks and delivery. So there, in Laravel's `RequestHandled`, the plain answer of a tracked - * server gets its length, and with D57 the client has all of it before the shutdown function delivers. A streamed - * answer, one with a `Content-Length` or `Transfer-Encoding` of its own, and one after stray output (headers that went - * out already, or bytes waiting in an output buffer, which would go out first and make the length short) stay as - * laravel/mcp sends them. PHP-FPM, LiteSpeed, FrankenPHP and Octane get no listener. + * server gets its length, by `ContentLength`'s rule, and with D57 the client has all of it before the shutdown function + * delivers. PHP-FPM, LiteSpeed, FrankenPHP and Octane get no listener. * * The tracker marks the request that a tracked server answers with a request attribute: the mark lives and dies with * the request, so a worker that serves many requests carries none of it to the next one. @@ -29,13 +28,12 @@ /** The request attribute: a laravel/mcp server that AgentCat tracks answers this request. */ public const string TRACKED = 'agentcat.tracked_mcp_server'; - /** @var \Closure(): bool */ - private \Closure $headersSent; + private ContentLength $length; /** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */ public function __construct(?\Closure $headersSent = null) { - $this->headersSent = $headersSent ?? static fn(): bool => headers_sent(); + $this->length = new ContentLength($headersSent); } /** @@ -60,32 +58,16 @@ public static function mark(Server $server): void public function handle(RequestHandled $handled): void { try { - $headers = $handled->response->headers; - if ($handled->request->attributes->get(self::TRACKED) !== true || $headers->has('Content-Length') || $headers->has('Transfer-Encoding')) { + if ($handled->request->attributes->get(self::TRACKED) !== true) { return; } - if (($this->headersSent)() || $this->outputWaits()) { - return; // stray output goes out before the answer: a length of the answer alone would cut its end - } - $content = $handled->response->getContent(); // false for a streamed answer (StreamedResponse) - if (is_string($content)) { - $headers->set('Content-Length', (string) strlen($content)); + $response = $handled->response; + $length = $this->length->of($response->headers->all(), $response->getContent()); + if ($length !== null) { + $response->headers->set('Content-Length', $length); } } catch (\Throwable) { // the answer goes out as it is } } - - /** Some output buffer level holds bytes, which PHP sends before the answer. */ - private function outputWaits(): bool - { - foreach (ob_get_status(true) as $status) { - $used = is_array($status) ? $status['buffer_used'] ?? null : null; - if (is_int($used) && $used > 0) { - return true; - } - } - - return false; - } } diff --git a/tests/Unit/Internal/Delivery/ContentLengthTest.php b/tests/Unit/Internal/Delivery/ContentLengthTest.php new file mode 100644 index 0000000..0452073 --- /dev/null +++ b/tests/Unit/Internal/Delivery/ContentLengthTest.php @@ -0,0 +1,110 @@ +assertSame('78', (new ContentLength())->of(['content-type' => ['application/json']], self::ANSWER), 'bytes, not characters: é is two'); + } + + /** laravel/mcp and mcp/sdk answer a notification with 202 and no body. */ + public function testAnEmptyAnswerGetsALengthOfZero(): void + { + $this->assertSame('0', (new ContentLength())->of([], '')); + } + + /** A streamed answer (SSE) has no content: `StreamedResponse::getContent()` is false. */ + public function testAStreamedAnswerGetsNoLength(): void + { + $this->assertNull((new ContentLength())->of(['content-type' => ['text/event-stream']], false)); + } + + /** @return iterable}> the headers of the answer, as `HeaderBag::all()` gives them */ + public static function framedAnswers(): iterable + { + yield 'its own Content-Length' => [['content-length' => ['7']]]; + yield 'a Transfer-Encoding' => [['transfer-encoding' => ['chunked']]]; + yield 'a Content-Length named in another case' => [['Content-Length' => ['7']]]; + yield 'a Transfer-Encoding named in another case' => [['Transfer-Encoding' => ['chunked']]]; + } + + /** + * An answer that says itself where it ends keeps what it says. + * + * @param array $headers + */ + #[DataProvider('framedAnswers')] + public function testAnAnswerThatFramesItselfGetsNoLength(array $headers): void + { + $this->assertNull((new ContentLength())->of(['content-type' => ['application/json'], ...$headers], self::ANSWER)); + } + + /** @return iterable}> the bytes in each output buffer, from the bottom level up */ + public static function strayOutput(): iterable + { + yield 'a BOM in the top buffer' => [["\xEF\xBB\xBF"]]; + yield 'a newline after a closing tag, in a lower buffer' => [["\n", '']]; + } + + /** + * Bytes written before the answer wait in an output buffer (mod_php and php-cgi with `output_buffering`) and go out + * first: a length of the answer alone would cut the end of the JSON. + * + * @param list $buffers + */ + #[DataProvider('strayOutput')] + public function testStrayOutputInAnOutputBufferLeavesTheAnswerAlone(array $buffers): void + { + $level = ob_get_level(); + foreach ($buffers as $bytes) { + ob_start(); + echo $bytes; + } + + try { + $length = (new ContentLength())->of([], self::ANSWER); + } finally { + while (ob_get_level() > $level) { + ob_end_clean(); + } + } + + $this->assertNull($length); + } + + /** An empty output buffer holds nothing that goes out before the answer. */ + public function testAnEmptyOutputBufferDoesNotCount(): void + { + ob_start(); + + try { + $length = (new ContentLength())->of([], self::ANSWER); + } finally { + ob_end_clean(); + } + + $this->assertSame('78', $length); + } + + /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ + public function testAnAnswerAfterTheHeadersWentOutGetsNoLength(): void + { + $this->assertNull((new ContentLength(static fn(): bool => true))->of([], self::ANSWER)); + } +} diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php index c70c471..dced270 100644 --- a/tests/Unit/Laravel/AnswerLengthTest.php +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -15,7 +15,10 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; -/** D59: the length of a tracked laravel/mcp server's plain answer, on a SAPI that sends a response with the script's end. */ +/** + * D59: the length of a tracked laravel/mcp server's plain answer, on a SAPI that sends a response with the script's end. + * The rule is `ContentLength`'s, which `Unit\Internal\Delivery\ContentLengthTest` tests: here, the bridge's part. + */ #[Group('ac-03-1')] final class AnswerLengthTest extends TestCase { @@ -30,16 +33,6 @@ public function testATrackedServersPlainAnswerGetsItsLengthInBytes(): void $this->assertSame('78', $response->headers->get('Content-Length'), 'bytes, not characters: é is two'); } - /** laravel/mcp answers a notification with 202 and no body. */ - public function testAnEmptyAnswerGetsALengthOfZero(): void - { - $response = new Response('', 202); - - (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); - - $this->assertSame('0', $response->headers->get('Content-Length')); - } - /** @return iterable */ public static function unmarkedRequests(): iterable { @@ -59,22 +52,7 @@ public function testAnAnswerToAnotherRequestStaysAsItIs(Request $request): void $this->assertFalse($response->headers->has('Content-Length')); } - /** A streamed answer has no content (`StreamedResponse::getContent()` is false); `Integration\Laravel` sends a real one. */ - public function testAnAnswerWithoutContentStaysAsItIs(): void - { - $response = new class extends Response { - #[\Override] - public function getContent(): string|false - { - return false; - } - }; - - (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); - - $this->assertFalse($response->headers->has('Content-Length')); - } - + /** The answer's headers reach the rule of `ContentLength` (`Unit\Internal\Delivery`): a length of its own stays. */ public function testAnAnswerWithItsOwnLengthKeepsIt(): void { $response = new Response(self::ANSWER, 200, ['Content-Length' => '7']); @@ -84,16 +62,6 @@ public function testAnAnswerWithItsOwnLengthKeepsIt(): void $this->assertSame('7', $response->headers->get('Content-Length')); } - public function testAnAnswerWithATransferEncodingGetsNoLength(): void - { - $response = new Response(self::ANSWER, 200, ['Transfer-Encoding' => 'chunked']); - - (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); - - $this->assertFalse($response->headers->has('Content-Length')); - $this->assertSame('chunked', $response->headers->get('Transfer-Encoding')); - } - /** D32: the listener runs inside Laravel's HTTP kernel, so nothing it meets may break the request. */ public function testAResponseThatFailsLeavesTheRequestAlone(): void { @@ -110,55 +78,6 @@ public function getContent(): string|false $this->assertFalse($response->headers->has('Content-Length')); } - /** @return iterable}> the bytes in each output buffer, from the bottom level up */ - public static function strayOutput(): iterable - { - yield 'a BOM in the top buffer' => [["\xEF\xBB\xBF"]]; - yield 'a newline after a closing tag, in a lower buffer' => [["\n", '']]; - } - - /** - * Bytes written before the answer wait in an output buffer (mod_php and php-cgi with `output_buffering`) and go out - * first: a length of the answer alone would cut the end of the JSON. The answer stays as laravel/mcp sends it. - * - * @param list $buffers - */ - #[DataProvider('strayOutput')] - public function testStrayOutputInAnOutputBufferLeavesTheAnswerAlone(array $buffers): void - { - $response = new Response(self::ANSWER); - $level = ob_get_level(); - foreach ($buffers as $bytes) { - ob_start(); - echo $bytes; - } - - try { - (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); - } finally { - while (ob_get_level() > $level) { - ob_end_clean(); - } - } - - $this->assertFalse($response->headers->has('Content-Length')); - } - - /** An empty output buffer holds nothing that goes out before the answer. */ - public function testAnEmptyOutputBufferDoesNotCount(): void - { - $response = new Response(self::ANSWER); - ob_start(); - - try { - (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); - } finally { - ob_end_clean(); - } - - $this->assertSame('78', $response->headers->get('Content-Length')); - } - /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ public function testAnAnswerAfterTheHeadersWentOutStaysAsItIs(): void { From 21e89275df5ee742e3b1cd9f17a4b3a553c346fb Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:11:12 +0000 Subject: [PATCH 19/55] fix: give no Content-Length to an answer without a body (1xx, 204, 304) --- src/Internal/Delivery/ContentLength.php | 11 +++--- src/Laravel/Internal/AnswerLength.php | 2 +- .../Internal/Delivery/ContentLengthTest.php | 39 +++++++++++++++---- tests/Unit/Laravel/AnswerLengthTest.php | 10 +++++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/Internal/Delivery/ContentLength.php b/src/Internal/Delivery/ContentLength.php index 8d736c0..297ddc2 100644 --- a/src/Internal/Delivery/ContentLength.php +++ b/src/Internal/Delivery/ContentLength.php @@ -25,16 +25,17 @@ public function __construct(?\Closure $headersSent = null) } /** - * The length in bytes to give an answer, or null when it goes as it is: a streamed answer (SSE), one with a - * `Content-Length` or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or - * bytes waiting in an output buffer, which go out first and would make the length short). + * The length in bytes to give an answer, or null when it goes as it is: an answer without a body (informational, + * 204, 304, as Symfony's `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length` or + * `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in an + * output buffer, which go out first and would make the length short). * * @param array $headers the answer's headers by name, in any case (`HeaderBag::all()`) * @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`) */ - public function of(array $headers, string|false $content): ?string + public function of(int $status, array $headers, string|false $content): ?string { - if ($content === false) { + if ($content === false || $status < 200 || $status === 204 || $status === 304) { return null; } $headers = array_change_key_case($headers); diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php index 93a7ad6..7c47e4e 100644 --- a/src/Laravel/Internal/AnswerLength.php +++ b/src/Laravel/Internal/AnswerLength.php @@ -62,7 +62,7 @@ public function handle(RequestHandled $handled): void return; } $response = $handled->response; - $length = $this->length->of($response->headers->all(), $response->getContent()); + $length = $this->length->of($response->getStatusCode(), $response->headers->all(), $response->getContent()); if ($length !== null) { $response->headers->set('Content-Length', $length); } diff --git a/tests/Unit/Internal/Delivery/ContentLengthTest.php b/tests/Unit/Internal/Delivery/ContentLengthTest.php index 0452073..1d117b6 100644 --- a/tests/Unit/Internal/Delivery/ContentLengthTest.php +++ b/tests/Unit/Internal/Delivery/ContentLengthTest.php @@ -20,19 +20,19 @@ final class ContentLengthTest extends TestCase public function testAPlainAnswerGetsItsLengthInBytes(): void { - $this->assertSame('78', (new ContentLength())->of(['content-type' => ['application/json']], self::ANSWER), 'bytes, not characters: é is two'); + $this->assertSame('78', (new ContentLength())->of(200, ['content-type' => ['application/json']], self::ANSWER), 'bytes, not characters: é is two'); } /** laravel/mcp and mcp/sdk answer a notification with 202 and no body. */ public function testAnEmptyAnswerGetsALengthOfZero(): void { - $this->assertSame('0', (new ContentLength())->of([], '')); + $this->assertSame('0', (new ContentLength())->of(202, [], '')); } /** A streamed answer (SSE) has no content: `StreamedResponse::getContent()` is false. */ public function testAStreamedAnswerGetsNoLength(): void { - $this->assertNull((new ContentLength())->of(['content-type' => ['text/event-stream']], false)); + $this->assertNull((new ContentLength())->of(200, ['content-type' => ['text/event-stream']], false)); } /** @return iterable}> the headers of the answer, as `HeaderBag::all()` gives them */ @@ -52,7 +52,32 @@ public static function framedAnswers(): iterable #[DataProvider('framedAnswers')] public function testAnAnswerThatFramesItselfGetsNoLength(array $headers): void { - $this->assertNull((new ContentLength())->of(['content-type' => ['application/json'], ...$headers], self::ANSWER)); + $this->assertNull((new ContentLength())->of(200, ['content-type' => ['application/json'], ...$headers], self::ANSWER)); + } + + /** @return iterable */ + public static function statuses(): iterable + { + yield '100' => [100, null]; + yield '199' => [199, null]; + yield '200' => [200, '78']; + yield '203' => [203, '78']; + yield '204: mcp/sdk answers OPTIONS so' => [204, null]; + yield '205' => [205, '78']; + yield '303' => [303, '78']; + yield '304' => [304, null]; + yield '305' => [305, '78']; + yield '400' => [400, '78']; + } + + /** + * RFC 9110 §8.6: no `Content-Length` on an informational or a 204 answer. Symfony's `Response::prepare()` removes it + * from those and from 304, which have no body. + */ + #[DataProvider('statuses')] + public function testAnAnswerWithoutABodyGetsNoLength(int $status, ?string $expected): void + { + $this->assertSame($expected, (new ContentLength())->of($status, [], self::ANSWER)); } /** @return iterable}> the bytes in each output buffer, from the bottom level up */ @@ -78,7 +103,7 @@ public function testStrayOutputInAnOutputBufferLeavesTheAnswerAlone(array $buffe } try { - $length = (new ContentLength())->of([], self::ANSWER); + $length = (new ContentLength())->of(200, [], self::ANSWER); } finally { while (ob_get_level() > $level) { ob_end_clean(); @@ -94,7 +119,7 @@ public function testAnEmptyOutputBufferDoesNotCount(): void ob_start(); try { - $length = (new ContentLength())->of([], self::ANSWER); + $length = (new ContentLength())->of(200, [], self::ANSWER); } finally { ob_end_clean(); } @@ -105,6 +130,6 @@ public function testAnEmptyOutputBufferDoesNotCount(): void /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ public function testAnAnswerAfterTheHeadersWentOutGetsNoLength(): void { - $this->assertNull((new ContentLength(static fn(): bool => true))->of([], self::ANSWER)); + $this->assertNull((new ContentLength(static fn(): bool => true))->of(200, [], self::ANSWER)); } } diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php index dced270..c9274c1 100644 --- a/tests/Unit/Laravel/AnswerLengthTest.php +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -62,6 +62,16 @@ public function testAnAnswerWithItsOwnLengthKeepsIt(): void $this->assertSame('7', $response->headers->get('Content-Length')); } + /** The answer's status reaches the rule of `ContentLength`: a 204 has no body and gets no length. */ + public function testAnAnswerWithoutABodyGetsNoLength(): void + { + $response = new Response('', 204); + + (new AnswerLength())->handle(new RequestHandled($this->marked(), $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + /** D32: the listener runs inside Laravel's HTTP kernel, so nothing it meets may break the request. */ public function testAResponseThatFailsLeavesTheRequestAlone(): void { From a3e8cb74fba174f0f2257f0536efd0792d58398a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:16:03 +0000 Subject: [PATCH 20/55] feat: give a tracked symfony/mcp-bundle server's JSON answers their Content-Length where responses end with the script (D62) --- composer-dependency-analyser.php | 8 +- src/Symfony/AgentCatBundle.php | 7 +- src/Symfony/Internal/AnswerLength.php | 88 +++++++ src/Symfony/Internal/ServerTracker.php | 14 ++ tests/Architecture/LayeringTest.php | 1 + tests/Contract/ManifestTest.php | 1 + tests/Contract/PrivateMemberManifest.php | 6 +- tests/Contract/Symfony/BundleServicesTest.php | 23 +- .../Integration/Symfony/AnswerLengthTest.php | 179 ++++++++++++++ tests/Unit/Symfony/AnswerLengthTest.php | 220 ++++++++++++++++++ 10 files changed, 539 insertions(+), 8 deletions(-) create mode 100644 src/Symfony/Internal/AnswerLength.php create mode 100644 tests/Integration/Symfony/AnswerLengthTest.php create mode 100644 tests/Unit/Symfony/AnswerLengthTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 23a37a2..4dff98c 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -34,13 +34,13 @@ ->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/Symfony', [ErrorType::DEV_DEPENDENCY_IN_PROD]) // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it. ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY]) - // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle tests read the kernel's listeners from it. - ->ignoreErrorsOnPackageAndPath('symfony/event-dispatcher', __DIR__ . '/tests/Integration/Symfony', [ErrorType::SHADOW_DEPENDENCY]) + // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle adds its kernel.response listener to the kernel's dispatcher, and the bundle tests read the listeners from it. + ->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY]) // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/E2E/Laravel', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with symfony/framework-bundle; a bundle test runs a console command as an application does. ->ignoreErrorsOnPackageAndPath('symfony/console', __DIR__ . '/tests/Integration/Symfony/BundleTest.php', [ErrorType::SHADOW_DEPENDENCY]) - // Symfony\Component\Routing arrives with symfony/framework-bundle and symfony/mcp-bundle; the test kernel imports the bundle's MCP routes with it. - ->ignoreErrorsOnPackageAndPath('symfony/routing', __DIR__ . '/tests/Support/Symfony/TestKernel.php', [ErrorType::SHADOW_DEPENDENCY]); + // Symfony\Component\Routing arrives with symfony/framework-bundle and symfony/mcp-bundle; the test kernel imports the bundle's MCP routes with it, and a contract test reads them. + ->ignoreErrorsOnPackageAndPaths('symfony/routing', [__DIR__ . '/tests/Support/Symfony/TestKernel.php', __DIR__ . '/tests/Contract/Symfony/BundleServicesTest.php'], [ErrorType::SHADOW_DEPENDENCY]); diff --git a/src/Symfony/AgentCatBundle.php b/src/Symfony/AgentCatBundle.php index 10c7f8a..f8ab102 100644 --- a/src/Symfony/AgentCatBundle.php +++ b/src/Symfony/AgentCatBundle.php @@ -5,6 +5,7 @@ namespace AgentCat\Symfony; use AgentCat\Internal\Support\BridgeConfig; +use AgentCat\Symfony\Internal\AnswerLength; use AgentCat\Symfony\Internal\DependencyInjection\TrackBuildersPass; use AgentCat\Symfony\Internal\ServerTracker; use AgentCat\Symfony\Internal\TerminateListener; @@ -103,6 +104,8 @@ public function loadExtension(array $config, ContainerConfigurator $container, C // D58: the wait for delivery after each response, last of kernel.terminate (header conflict 7). $builder->register('agentcat.terminate_listener', TerminateListener::class) ->addTag('kernel.event_listener', ['event' => KernelEvents::TERMINATE, 'priority' => TerminateListener::PRIORITY]); + // D62: boot() makes it a kernel.response listener where responses end with the script. + $builder->register(AnswerLength::SERVICE, AnswerLength::class)->setArguments([new Reference('agentcat.server_tracker')])->setPublic(true); } /** Whether a value of the configuration holds an environment variable, which resolves at runtime only. */ @@ -124,7 +127,8 @@ public function build(ContainerBuilder $container): void * `agentcat.enabled` and every other name AgentCat reads decide for every client, the shared one too, before the * environment (plan E1 header conflict 12): a literal, a parameter or a secret of the configuration is not in the * environment. Diagnostics get the kernel's environment. A fake still records. The tracker logs the bad values; - * nothing is logged here, and boot tracks nothing. + * nothing is logged here, and boot tracks nothing. Where this process's SAPI sends a response only when the script + * ends, the answers of tracked servers get their length (D62). */ #[\Override] public function boot(): void @@ -140,5 +144,6 @@ public function boot(): void // A variable without a default is not set: the tracker logs it when it tracks a server (D32). } BridgeConfig::fromBridge($config, $this->container->getParameter('kernel.environment')); + AnswerLength::register($this->container); } } diff --git a/src/Symfony/Internal/AnswerLength.php b/src/Symfony/Internal/AnswerLength.php new file mode 100644 index 0000000..9ea906c --- /dev/null +++ b/src/Symfony/Internal/AnswerLength.php @@ -0,0 +1,88 @@ +.controller::handle`; the tracker + * knows which servers it tracked. A server that AgentCat did not track (off, excluded, or failed) keeps its answer. + * + * @internal + */ +final readonly class AnswerLength +{ + /** The listener's service, which `AgentCatBundle::loadExtension()` defines. */ + public const string SERVICE = 'agentcat.answer_length'; + + /** Last of `kernel.response`, after Symfony's own listeners (`Response::prepare()` at 0, the session's at -1000). */ + public const int PRIORITY = -2048; + + /** The controller of a symfony/mcp-bundle server's route: the server's name in it. */ + private const string CONTROLLER = '/^mcp\.server\.(.+)\.controller::handle$/s'; + + private ContentLength $length; + + /** @param (\Closure(): bool)|null $headersSent default `headers_sent()` */ + public function __construct( + private ServerTracker $tracker, + ?\Closure $headersSent = null, + ) { + $this->length = new ContentLength($headersSent); + } + + /** + * Listens for `kernel.response` when responses end with the script: at boot, where this process's SAPI is known (a + * container compiled by `cache:warmup` on the command line serves mod_php too). Nothing runs here, and nothing + * throws (D32). + * + * @param bool|null $responseEndsWithTheScript null: this process's SAPI decides + */ + public static function register(ContainerInterface $container, ?bool $responseEndsWithTheScript = null): void + { + try { + if (!($responseEndsWithTheScript ?? FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, static fn(string $name): bool => function_exists($name)))) { + return; + } + $dispatcher = $container->get('event_dispatcher'); + $listener = $container->get(self::SERVICE); + if ($dispatcher instanceof EventDispatcherInterface && $listener instanceof self) { + $dispatcher->addListener(KernelEvents::RESPONSE, $listener, self::PRIORITY); + } + } catch (\Throwable) { + // no listener: the answers go as symfony/mcp-bundle sends them + } + } + + /** Gives the plain answer of a tracked server's route its length in bytes. Never throws (D32). */ + public function __invoke(ResponseEvent $event): void + { + try { + $controller = $event->getRequest()->attributes->get('_controller'); + if (!$event->isMainRequest() || !is_string($controller) || preg_match(self::CONTROLLER, $controller, $server) !== 1 || !$this->tracker->tracked($server[1])) { + return; + } + $response = $event->getResponse(); + $length = $this->length->of($response->getStatusCode(), $response->headers->all(), $response->getContent()); + if ($length !== null) { + $response->headers->set('Content-Length', $length); + } + } catch (\Throwable) { + // the answer goes out as it is + } + } +} diff --git a/src/Symfony/Internal/ServerTracker.php b/src/Symfony/Internal/ServerTracker.php index 95d79ac..be1fb09 100644 --- a/src/Symfony/Internal/ServerTracker.php +++ b/src/Symfony/Internal/ServerTracker.php @@ -8,6 +8,8 @@ use AgentCat\Client; use AgentCat\Internal\ClientSeams; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Engine\TrackedServer; +use AgentCat\Internal\Engine\TrackedServers; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\Support\BridgeKeys; use AgentCat\Internal\Support\ConfigOptions; @@ -55,6 +57,9 @@ final class ServerTracker /** @var array the lines of bad values, written at the first server that AgentCat tracks */ private array $lines = []; + /** @var array the names of the servers that AgentCat tracks (D62) */ + private array $tracked = []; + /** * @param \Closure(): mixed $config the processed agentcat configuration, an `\ArrayObject`, read when called * @param ContainerInterface $services the services the configuration names, by ID: the hooks and the logger (a @@ -105,12 +110,21 @@ public function track(Builder $builder, string $server): void // A null entry (`%env(default::…)%` of an unset variable) is the configured project; without one, the // client reads AGENTCAT_PROJECT_ID (D36). $client->track($builder, $keys['projects'][$server] ?? $keys['project'], $this->options); + if (TrackedServers::get($builder) instanceof TrackedServer) { + $this->tracked[$server] = true; // a server that AgentCat did not track (off, or failed) keeps its answers + } } catch (\Throwable $e) { $this->adoptLogger(null); Log::write('Warning: Failed to track server - ' . ErrorText::of($e)); } } + /** Whether AgentCat tracks the server of this name, in this container (D62: its answers get their length). */ + public function tracked(string $server): bool + { + return $this->tracked[$server] ?? false; + } + /** * The bundle's own keys, with their types, the configuration of the options, and the line of each bad value. A * service ID from an environment variable is unset, with a line: the locator holds the IDs written in the diff --git a/tests/Architecture/LayeringTest.php b/tests/Architecture/LayeringTest.php index aaaa1ab..f1ad014 100644 --- a/tests/Architecture/LayeringTest.php +++ b/tests/Architecture/LayeringTest.php @@ -70,6 +70,7 @@ public function onlyTheSymfonyBundleDependsOnSymfonyFrameworkParts(): Rule Selector::inNamespace('Symfony\Component\DependencyInjection'), Selector::inNamespace('Symfony\Component\Config'), Selector::inNamespace('Symfony\Component\HttpFoundation'), + Selector::inNamespace('Symfony\Component\EventDispatcher'), Selector::inNamespace('Symfony\Bundle'), ) ->because('Symfony is optional (01 AC-90)'); diff --git a/tests/Contract/ManifestTest.php b/tests/Contract/ManifestTest.php index e45b04b..b152414 100644 --- a/tests/Contract/ManifestTest.php +++ b/tests/Contract/ManifestTest.php @@ -33,6 +33,7 @@ public function testTheManifestListsExactlyTheMembersAgentCatDependsOn(): void 'Mcp\Server\Protocol::$requestHandlers', 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle', 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', ], PrivateMemberManifest::members(), diff --git a/tests/Contract/PrivateMemberManifest.php b/tests/Contract/PrivateMemberManifest.php index 88477ee..464974a 100644 --- a/tests/Contract/PrivateMemberManifest.php +++ b/tests/Contract/PrivateMemberManifest.php @@ -29,12 +29,14 @@ final class PrivateMemberManifest ]; /** - * The symfony/mcp-bundle service IDs, tags and method calls that AgentCat's bundle finds each server builder by, as - * strings: the bundle never names a class of symfony/mcp-bundle. `tests/Contract/Symfony` checks each one. + * The symfony/mcp-bundle service IDs, tags and method calls that AgentCat's bundle finds each server builder by, and + * the route controller it finds each server's answers by, as strings: the bundle never names a class of + * symfony/mcp-bundle. `tests/Contract/Symfony` checks each one. */ public const array BUNDLE_SERVICES = [ 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle', 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', ]; diff --git a/tests/Contract/Symfony/BundleServicesTest.php b/tests/Contract/Symfony/BundleServicesTest.php index dd72e3c..a8dc3f1 100644 --- a/tests/Contract/Symfony/BundleServicesTest.php +++ b/tests/Contract/Symfony/BundleServicesTest.php @@ -11,12 +11,15 @@ use Mcp\Server; use PHPUnit\Framework\Attributes\Group; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouterInterface; /** * AgentCat's bundle finds each symfony/mcp-bundle server by these service definitions, read at compile time (Plan E2 * header conflict 3): the builder's service ID, its tag and `server` attribute, the `addRequestHandlers` method call * with the tagged request handlers, and no configurator of the bundle's own. A bundle release that changes one of them - * breaks the tracking of every bundle server. + * breaks the tracking of every bundle server. The route of each HTTP server names the server's controller, by which + * the bundle finds a server's answers at runtime (D62). */ #[Group('contract')] final class BundleServicesTest extends SymfonyTestCase @@ -47,6 +50,23 @@ public function testTheServerIsBuiltFromItsBuilder(): void $this->assertInstanceOf(Server::class, $kernel->getContainer()->get('mcp.server.conformance')); } + /** + * D62: the bundle's `kernel.response` listener finds a server's answers by the controller of its route, which + * symfony/mcp-bundle's route loader names after the server. + */ + #[MaximumDuration(1000)] + public function testEachHttpServersRouteNamesTheServersController(): void + { + $kernel = $this->kernel(withAgentCat: false); + $router = $kernel->getContainer()->get('router'); + $this->assertInstanceOf(RouterInterface::class, $router); + + $routes = array_filter($router->getRouteCollection()->all(), static fn(Route $route): bool => $route->getPath() === '/mcp/conformance'); + + $this->assertSame(['mcp.server.conformance.controller::handle'], array_values(array_map(static fn(Route $route): mixed => $route->getDefault('_controller'), $routes))); + $this->assertTrue($kernel->getContainer()->has('mcp.server.conformance.controller')); + } + public function testTheManifestNamesTheBundleInternals(): void { $bundle = array_values(array_filter(PrivateMemberManifest::members(), static fn(string $member): bool => str_starts_with($member, 'symfony/mcp-bundle: '))); @@ -54,6 +74,7 @@ public function testTheManifestNamesTheBundleInternals(): void $this->assertSame([ 'symfony/mcp-bundle: builder method call addRequestHandlers(!tagged_iterator mcp.request_handler)', 'symfony/mcp-bundle: builder without a configurator', + 'symfony/mcp-bundle: route of each HTTP server with _controller mcp.server..controller::handle', 'symfony/mcp-bundle: service mcp.server..builder tagged mcp.server.builder {server: }', ], $bundle); } diff --git a/tests/Integration/Symfony/AnswerLengthTest.php b/tests/Integration/Symfony/AnswerLengthTest.php new file mode 100644 index 0000000..26ec9be --- /dev/null +++ b/tests/Integration/Symfony/AnswerLengthTest.php @@ -0,0 +1,179 @@ + [ + 'conformance' => TestKernel::CONFORMANCE, + 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + TestKernel::CONFORMANCE, + ]]; + + /** Under the CLI the bundle adds no listener: the SAPI has no request that ends with the script. */ + #[MaximumDuration(1000)] + public function testThisSapiGetsNoListener(): void + { + $this->assertFalse(FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, function_exists(...))); + $kernel = $this->kernel(agentcat: $this->on()); + + $response = $this->toolCall($kernel); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertSame([], array_filter($this->responseListeners($kernel), static fn(mixed $listener): bool => $listener instanceof AnswerLength)); + } + + /** Last of `kernel.response`, after Symfony's own listeners: the answer is final when it gets its length. */ + #[MaximumDuration(1000)] + public function testTheListenerRunsLast(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + + AnswerLength::register($kernel->getContainer(), true); + + $listeners = $this->responseListeners($kernel); + $this->assertInstanceOf(AnswerLength::class, end($listeners)); + } + + #[MaximumDuration(1000)] + public function testATrackedServersAnswerGetsItsLength(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + AnswerLength::register($kernel->getContainer(), true); + + $response = $this->toolCall($kernel); + + $this->assertSame(200, $response->getStatusCode()); + $content = $response->getContent(); + $this->assertIsString($content); + $this->assertStringContainsString('echo: hi', $content); + $this->assertSame((string) strlen($content), $response->headers->get('Content-Length')); + $this->assertCount(1, $this->deliveries(), 'the server was tracked'); + } + + /** One kernel, two servers: the route of the server AgentCat tracks gets the length, the excluded server's not. */ + #[MaximumDuration(1000)] + public function testOnlyATrackedServersRouteGetsTheLength(): void + { + $kernel = $this->kernel(agentcat: [...$this->on(), 'exclude_servers' => ['second']], mcp: self::TWO_SERVERS); + AnswerLength::register($kernel->getContainer(), true); + + $excluded = $this->toolCall($kernel, '/mcp/second'); + $tracked = $this->toolCall($kernel); + + $this->assertSame(200, $excluded->getStatusCode()); + $this->assertFalse($excluded->headers->has('Content-Length')); + $this->assertTrue($tracked->headers->has('Content-Length')); + $this->assertCount(1, $this->deliveries(), 'only the conformance server was tracked'); + } + + #[MaximumDuration(1000)] + public function testAnAnswerWhileAgentCatIsOffStaysAsItIs(): void + { + $kernel = $this->kernel(agentcat: [...$this->on(), 'enabled' => 'false']); + AnswerLength::register($kernel->getContainer(), true); + + $response = $this->toolCall($kernel); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Length')); + $this->assertSame([], $this->deliveries()); + } + + /** An SSE answer streams after the kernel: it has no length to give. */ + #[MaximumDuration(1000)] + public function testAStreamedAnswerStaysAsItIs(): void + { + $kernel = $this->kernel(agentcat: $this->on(), services: [ProgressTool::class => ProgressTool::class], mcp: ['servers' => ['conformance' => ['registry' => [...TestKernel::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + TestKernel::CONFORMANCE]]); + AnswerLength::register($kernel->getContainer(), true); + $http = $this->http($kernel); + $http->listTools(); + + $response = $kernel->handle($http->callRequest('progress', ['msg' => 'hi', 'session_id' => 'start'], ['progressToken' => 'p1'])); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** mcp/sdk answers OPTIONS with 204, which has no body and must carry no `Content-Length` (RFC 9110 §8.6). */ + #[MaximumDuration(1000)] + public function testAnAnswerWithoutABodyStaysAsItIs(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + AnswerLength::register($kernel->getContainer(), true); + $this->toolCall($kernel); // the server is built and tracked + + $response = $kernel->handle(Request::create('http://127.0.0.1' . SymfonyHttp::PATH, 'OPTIONS')); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** A request no MCP route answers, in the same kernel after a tracked call: its answer stays as it is. */ + #[MaximumDuration(1000)] + public function testAnotherRouteStaysAsItIs(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + AnswerLength::register($kernel->getContainer(), true); + $this->assertTrue($this->toolCall($kernel)->headers->has('Content-Length')); + + $response = $kernel->handle(Request::create('http://127.0.0.1/elsewhere', 'POST')); + + $this->assertSame(404, $response->getStatusCode()); + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** A `tools/call` of the `echo` tool in a new session, handled by the kernel: the raw answer. */ + private function toolCall(TestKernel $kernel, string $path = SymfonyHttp::PATH): Response + { + $http = $this->http($kernel, $path); + + return $kernel->handle($http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start'])); + } + + /** @return list the `kernel.response` listeners, sorted: the last has the lowest priority */ + private function responseListeners(TestKernel $kernel): array + { + $dispatcher = $kernel->getContainer()->get('event_dispatcher'); + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + + return array_values(array_map(static fn(mixed $listener): mixed => is_array($listener) ? $listener[0] ?? null : $listener, $dispatcher->getListeners(KernelEvents::RESPONSE))); + } + + /** + * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. + * + * @return array + */ + private function on(): array + { + return ['enabled' => 'true', 'project_id' => self::PROJECT, 'api_url' => MockCollector::closedPortUrl()]; + } +} diff --git a/tests/Unit/Symfony/AnswerLengthTest.php b/tests/Unit/Symfony/AnswerLengthTest.php new file mode 100644 index 0000000..8b85a1f --- /dev/null +++ b/tests/Unit/Symfony/AnswerLengthTest.php @@ -0,0 +1,220 @@ + 'application/json']); + + ($this->listener())($this->event(self::MAIN, $response)); + + $this->assertSame('78', $response->headers->get('Content-Length'), 'bytes, not characters: é is two'); + } + + /** @return iterable the `_controller` of a request that no tracked server answers */ + public static function otherControllers(): iterable + { + yield 'no controller' => [null]; + yield 'the application\'s controller' => ['App\Controller\HomeController::index']; + yield 'a server AgentCat does not track (excluded, or AgentCat off)' => ['mcp.server.other.controller::handle']; + yield 'another method of the controller' => ['mcp.server.main.controller::handleMore']; + yield 'a service with a longer ID' => ['app.mcp.server.main.controller::handle']; + yield 'a callable that is no string' => [[new \stdClass(), 'handle']]; + } + + #[DataProvider('otherControllers')] + public function testAnAnswerOfAnotherControllerStaysAsItIs(mixed $controller): void + { + $response = new Response(self::ANSWER); + + ($this->listener())($this->event($controller, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** A sub-request's answer is not what the client gets: only the main request's answer gets a length. */ + public function testASubRequestsAnswerStaysAsItIs(): void + { + $response = new Response(self::ANSWER); + + ($this->listener())($this->event(self::MAIN, $response, HttpKernelInterface::SUB_REQUEST)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** The answer's headers reach the rule of `ContentLength`: a length of its own stays. */ + public function testAnAnswerWithItsOwnLengthKeepsIt(): void + { + $response = new Response(self::ANSWER, 200, ['Content-Length' => '7']); + + ($this->listener())($this->event(self::MAIN, $response)); + + $this->assertSame('7', $response->headers->get('Content-Length')); + } + + /** The answer's status reaches the rule of `ContentLength`: mcp/sdk answers OPTIONS with a 204, which has no body. */ + public function testAnAnswerWithoutABodyGetsNoLength(): void + { + $response = new Response('', 204); + + ($this->listener())($this->event(self::MAIN, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ + public function testAnAnswerAfterTheHeadersWentOutStaysAsItIs(): void + { + $response = new Response(self::ANSWER); + + ($this->listener(static fn(): bool => true))($this->event(self::MAIN, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + /** D32: the listener runs inside Symfony's HTTP kernel, so nothing it meets may break the request. */ + public function testAResponseThatFailsLeavesTheRequestAlone(): void + { + $response = new class (self::ANSWER) extends Response { + #[\Override] + public function getContent(): string|false + { + throw new \RuntimeException('no content'); + } + }; + + ($this->listener())($this->event(self::MAIN, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + + public function testWhereResponsesEndWithTheScriptTheListenerRunsLastOfKernelResponse(): void + { + $dispatcher = new EventDispatcher(); + $dispatcher->addListener(KernelEvents::RESPONSE, static function (): void {}, -2047); + $listener = $this->listener(); + + AnswerLength::register($this->container($dispatcher, $listener), true); + + $listeners = $dispatcher->getListeners(KernelEvents::RESPONSE); + $this->assertCount(2, $listeners); + $this->assertSame($listener, end($listeners)); + $this->assertSame(AnswerLength::PRIORITY, $dispatcher->getListenerPriority(KernelEvents::RESPONSE, $listener)); + } + + /** PHP-FPM, LiteSpeed, FrankenPHP and the command line (Symfony Runtime workers) send a response before the drain. */ + public function testElsewhereNoListenerIsAdded(): void + { + $dispatcher = new EventDispatcher(); + + AnswerLength::register($this->container($dispatcher, $this->listener()), false); + AnswerLength::register($this->container($dispatcher, $this->listener())); // PHPUnit runs on the cli SAPI + + $this->assertSame([], $dispatcher->getListeners(KernelEvents::RESPONSE)); + } + + /** D32: `AgentCatBundle::boot()` registers it, and a container without the services it needs leaves the kernel alone. */ + public function testAContainerWithoutTheServicesAddsNothingAndNeverThrows(): void + { + $dispatcher = new EventDispatcher(); + $missingListener = new Container(); + $missingListener->set('event_dispatcher', $dispatcher); + $otherListener = $this->container($dispatcher, new \stdClass()); + + AnswerLength::register(new Container(), true); + AnswerLength::register($missingListener, true); + AnswerLength::register($otherListener, true); + + $this->assertSame([], $dispatcher->getListeners(KernelEvents::RESPONSE)); + } + + /** @param (\Closure(): bool)|null $headersSent */ + private function listener(?\Closure $headersSent = null): AnswerLength + { + $tracker = new ServerTracker(static fn(): \ArrayObject => new \ArrayObject(['enabled' => 'true', 'project_id' => 'proj_aaaaaaaaaaaaaaaaaaaaaaaaaaa']), new ServiceLocator([]), null, '/nonexistent/agentcat.log'); + $tracker->track(Server::builder(), 'main'); + $this->assertTrue($tracker->tracked('main')); + + return new AnswerLength($tracker, $headersSent); + } + + private function event(mixed $controller, Response $response, int $type = HttpKernelInterface::MAIN_REQUEST): ResponseEvent + { + $request = Request::create('/mcp', 'POST'); + if ($controller !== null) { + $request->attributes->set('_controller', $controller); + } + $kernel = new class implements HttpKernelInterface { + #[\Override] + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + }; + + return new ResponseEvent($kernel, $request, $type, $response); + } + + private function container(EventDispatcher $dispatcher, object $listener): Container + { + $container = new Container(); + $container->set('event_dispatcher', $dispatcher); + $container->set(AnswerLength::SERVICE, $listener); + + return $container; + } +} From 2ea8ec305af87f8b92b43cf892bc070555e9c432 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 19:34:49 +0000 Subject: [PATCH 21/55] test: read a kernel event's listeners the same way on Symfony 7 and 8 --- composer-dependency-analyser.php | 2 +- .../Integration/Symfony/AnswerLengthTest.php | 14 ++------------ tests/Integration/Symfony/DeliveryTest.php | 13 ------------- tests/Support/Symfony/SymfonyTestCase.php | 19 +++++++++++++++++++ 4 files changed, 22 insertions(+), 26 deletions(-) diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 4dff98c..57fbcdb 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -35,7 +35,7 @@ // Psr\Container\ContainerInterface arrives with symfony/dependency-injection; the tracker reads the hook locator through it. ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/Symfony', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\EventDispatcher arrives with symfony/http-kernel; the bundle adds its kernel.response listener to the kernel's dispatcher, and the bundle tests read the listeners from it. - ->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY]) + ->ignoreErrorsOnPackageAndPaths('symfony/event-dispatcher', [__DIR__ . '/src/Symfony', __DIR__ . '/tests/Support/Symfony', __DIR__ . '/tests/Unit/Symfony', __DIR__ . '/tests/Integration/Symfony'], [ErrorType::SHADOW_DEPENDENCY]) // Orchestra\Testbench\TestCase lives in orchestra/testbench-core, which orchestra/testbench requires; the Laravel tests extend it. ->ignoreErrorsOnPackageAndPath('orchestra/testbench-core', __DIR__ . '/tests', [ErrorType::SHADOW_DEPENDENCY]) // Symfony\Component\Console arrives with laravel/framework; the artisan fixture hands its input and output to Laravel's console kernel. diff --git a/tests/Integration/Symfony/AnswerLengthTest.php b/tests/Integration/Symfony/AnswerLengthTest.php index 26ec9be..b725fae 100644 --- a/tests/Integration/Symfony/AnswerLengthTest.php +++ b/tests/Integration/Symfony/AnswerLengthTest.php @@ -13,7 +13,6 @@ use AgentCat\Tests\Support\Symfony\TestKernel; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\Group; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -47,7 +46,7 @@ public function testThisSapiGetsNoListener(): void $this->assertSame(200, $response->getStatusCode()); $this->assertFalse($response->headers->has('Content-Length')); - $this->assertSame([], array_filter($this->responseListeners($kernel), static fn(mixed $listener): bool => $listener instanceof AnswerLength)); + $this->assertSame([], array_filter($this->listeners($kernel, KernelEvents::RESPONSE), static fn(mixed $listener): bool => $listener instanceof AnswerLength)); } /** Last of `kernel.response`, after Symfony's own listeners: the answer is final when it gets its length. */ @@ -58,7 +57,7 @@ public function testTheListenerRunsLast(): void AnswerLength::register($kernel->getContainer(), true); - $listeners = $this->responseListeners($kernel); + $listeners = $this->listeners($kernel, KernelEvents::RESPONSE); $this->assertInstanceOf(AnswerLength::class, end($listeners)); } @@ -158,15 +157,6 @@ private function toolCall(TestKernel $kernel, string $path = SymfonyHttp::PATH): return $kernel->handle($http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start'])); } - /** @return list the `kernel.response` listeners, sorted: the last has the lowest priority */ - private function responseListeners(TestKernel $kernel): array - { - $dispatcher = $kernel->getContainer()->get('event_dispatcher'); - $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); - - return array_values(array_map(static fn(mixed $listener): mixed => is_array($listener) ? $listener[0] ?? null : $listener, $dispatcher->getListeners(KernelEvents::RESPONSE))); - } - /** * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. * diff --git a/tests/Integration/Symfony/DeliveryTest.php b/tests/Integration/Symfony/DeliveryTest.php index e907b31..614df9f 100644 --- a/tests/Integration/Symfony/DeliveryTest.php +++ b/tests/Integration/Symfony/DeliveryTest.php @@ -80,19 +80,6 @@ public function testTheListenerRunsLast(): void $this->assertSame(-2048, $dispatcher->getListenerPriority(KernelEvents::TERMINATE, $last)); } - /** Header conflict 7: a Symfony application without AgentCat's bundle has no such listener. */ - #[MaximumDuration(1000)] - public function testWithoutTheBundleNothingWaits(): void - { - $kernel = $this->kernel(withAgentCat: false); - $dispatcher = $kernel->getContainer()->get('event_dispatcher'); - $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); - - $listeners = array_map(static fn(mixed $listener): mixed => is_array($listener) ? $listener[0] ?? null : $listener, $dispatcher->getListeners(KernelEvents::TERMINATE)); - - $this->assertSame([], array_filter($listeners, static fn(mixed $listener): bool => $listener instanceof TerminateListener)); - } - #[Group('ac-03-1')] #[MaximumDuration(1000)] public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index b0b1ced..887d24f 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -19,6 +19,7 @@ use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * Symfony applications with symfony/mcp-bundle, for the bundle tests. Skipped where the bundle is not installed (the @@ -120,6 +121,24 @@ private function exceptionHandler(): ?callable return $handler; } + /** + * The listeners of a kernel event, sorted: the last has the lowest priority. A `[service, method]` listener is given + * as its service. + * + * @return list + */ + protected function listeners(TestKernel $kernel, string $event): array + { + $dispatcher = $kernel->getContainer()->get('event_dispatcher'); + $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $listeners = []; + foreach ($dispatcher->getListeners($event) as $listener) { + $listeners[] = is_array($listener) ? reset($listener) : $listener; + } + + return $listeners; + } + /** * Flushes the global queue (1 s) and returns every delivery the API got. * From b61e026666f0ca5a649dd161c2a60d0708ec009f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 20:17:02 +0000 Subject: [PATCH 22/55] fix: give no Content-Length to an answer to a HEAD request --- src/Internal/Delivery/ContentLength.php | 14 ++++---- src/Laravel/Internal/AnswerLength.php | 2 +- src/Symfony/Internal/AnswerLength.php | 2 +- .../Integration/Symfony/AnswerLengthTest.php | 17 +++++++++ .../Internal/Delivery/ContentLengthTest.php | 35 ++++++++++++++----- tests/Unit/Laravel/AnswerLengthTest.php | 12 +++++++ tests/Unit/Symfony/AnswerLengthTest.php | 14 ++++++-- 7 files changed, 78 insertions(+), 18 deletions(-) diff --git a/src/Internal/Delivery/ContentLength.php b/src/Internal/Delivery/ContentLength.php index 297ddc2..ba293ca 100644 --- a/src/Internal/Delivery/ContentLength.php +++ b/src/Internal/Delivery/ContentLength.php @@ -25,17 +25,19 @@ public function __construct(?\Closure $headersSent = null) } /** - * The length in bytes to give an answer, or null when it goes as it is: an answer without a body (informational, - * 204, 304, as Symfony's `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length` or - * `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in an - * output buffer, which go out first and would make the length short). + * The length in bytes to give an answer, or null when it goes as it is: an answer to a HEAD request (its length + * would be the GET answer's, RFC 9110 §8.6, but `Response::prepare()` has emptied the body), an answer without a body + * (informational, 204, 304, as `Response::prepare()` treats them), a streamed one (SSE), one with a `Content-Length` + * or `Transfer-Encoding` of its own, and one after stray output (headers that went out already, or bytes waiting in + * an output buffer, which go out first and would make the length short). * + * @param string $method the request's method (`Request::getMethod()`, upper case) * @param array $headers the answer's headers by name, in any case (`HeaderBag::all()`) * @param string|false $content the answer's body; false for a streamed answer (`StreamedResponse`) */ - public function of(int $status, array $headers, string|false $content): ?string + public function of(string $method, int $status, array $headers, string|false $content): ?string { - if ($content === false || $status < 200 || $status === 204 || $status === 304) { + if ($content === false || $method === 'HEAD' || $status < 200 || $status === 204 || $status === 304) { return null; } $headers = array_change_key_case($headers); diff --git a/src/Laravel/Internal/AnswerLength.php b/src/Laravel/Internal/AnswerLength.php index 7c47e4e..053c438 100644 --- a/src/Laravel/Internal/AnswerLength.php +++ b/src/Laravel/Internal/AnswerLength.php @@ -62,7 +62,7 @@ public function handle(RequestHandled $handled): void return; } $response = $handled->response; - $length = $this->length->of($response->getStatusCode(), $response->headers->all(), $response->getContent()); + $length = $this->length->of($handled->request->getMethod(), $response->getStatusCode(), $response->headers->all(), $response->getContent()); if ($length !== null) { $response->headers->set('Content-Length', $length); } diff --git a/src/Symfony/Internal/AnswerLength.php b/src/Symfony/Internal/AnswerLength.php index 9ea906c..6b59667 100644 --- a/src/Symfony/Internal/AnswerLength.php +++ b/src/Symfony/Internal/AnswerLength.php @@ -77,7 +77,7 @@ public function __invoke(ResponseEvent $event): void return; } $response = $event->getResponse(); - $length = $this->length->of($response->getStatusCode(), $response->headers->all(), $response->getContent()); + $length = $this->length->of($event->getRequest()->getMethod(), $response->getStatusCode(), $response->headers->all(), $response->getContent()); if ($length !== null) { $response->headers->set('Content-Length', $length); } diff --git a/tests/Integration/Symfony/AnswerLengthTest.php b/tests/Integration/Symfony/AnswerLengthTest.php index b725fae..8dc0946 100644 --- a/tests/Integration/Symfony/AnswerLengthTest.php +++ b/tests/Integration/Symfony/AnswerLengthTest.php @@ -135,6 +135,23 @@ public function testAnAnswerWithoutABodyStaysAsItIs(): void $this->assertFalse($response->headers->has('Content-Length')); } + /** + * Symfony serves a HEAD request to the route as GET (mcp/sdk answers it with an error), and `Response::prepare()` + * empties the body: a length of what is left would not be the GET answer's (RFC 9110 §8.6). + */ + #[MaximumDuration(1000)] + public function testAnAnswerToAHeadRequestStaysAsItIs(): void + { + $kernel = $this->kernel(agentcat: $this->on()); + AnswerLength::register($kernel->getContainer(), true); + $this->toolCall($kernel); // the server is built and tracked + + $response = $kernel->handle(Request::create('http://127.0.0.1' . SymfonyHttp::PATH, 'HEAD')); + + $this->assertSame('', $response->getContent()); + $this->assertFalse($response->headers->has('Content-Length')); + } + /** A request no MCP route answers, in the same kernel after a tracked call: its answer stays as it is. */ #[MaximumDuration(1000)] public function testAnotherRouteStaysAsItIs(): void diff --git a/tests/Unit/Internal/Delivery/ContentLengthTest.php b/tests/Unit/Internal/Delivery/ContentLengthTest.php index 1d117b6..581a069 100644 --- a/tests/Unit/Internal/Delivery/ContentLengthTest.php +++ b/tests/Unit/Internal/Delivery/ContentLengthTest.php @@ -20,19 +20,19 @@ final class ContentLengthTest extends TestCase public function testAPlainAnswerGetsItsLengthInBytes(): void { - $this->assertSame('78', (new ContentLength())->of(200, ['content-type' => ['application/json']], self::ANSWER), 'bytes, not characters: é is two'); + $this->assertSame('78', (new ContentLength())->of('POST', 200, ['content-type' => ['application/json']], self::ANSWER), 'bytes, not characters: é is two'); } /** laravel/mcp and mcp/sdk answer a notification with 202 and no body. */ public function testAnEmptyAnswerGetsALengthOfZero(): void { - $this->assertSame('0', (new ContentLength())->of(202, [], '')); + $this->assertSame('0', (new ContentLength())->of('POST', 202, [], '')); } /** A streamed answer (SSE) has no content: `StreamedResponse::getContent()` is false. */ public function testAStreamedAnswerGetsNoLength(): void { - $this->assertNull((new ContentLength())->of(200, ['content-type' => ['text/event-stream']], false)); + $this->assertNull((new ContentLength())->of('POST', 200, ['content-type' => ['text/event-stream']], false)); } /** @return iterable}> the headers of the answer, as `HeaderBag::all()` gives them */ @@ -52,7 +52,7 @@ public static function framedAnswers(): iterable #[DataProvider('framedAnswers')] public function testAnAnswerThatFramesItselfGetsNoLength(array $headers): void { - $this->assertNull((new ContentLength())->of(200, ['content-type' => ['application/json'], ...$headers], self::ANSWER)); + $this->assertNull((new ContentLength())->of('POST', 200, ['content-type' => ['application/json'], ...$headers], self::ANSWER)); } /** @return iterable */ @@ -77,7 +77,26 @@ public static function statuses(): iterable #[DataProvider('statuses')] public function testAnAnswerWithoutABodyGetsNoLength(int $status, ?string $expected): void { - $this->assertSame($expected, (new ContentLength())->of($status, [], self::ANSWER)); + $this->assertSame($expected, (new ContentLength())->of('POST', $status, [], self::ANSWER)); + } + + /** @return iterable */ + public static function methods(): iterable + { + yield 'POST: an MCP message' => ['POST', '78']; + yield 'GET' => ['GET', '78']; + yield 'DELETE: the end of a session' => ['DELETE', '78']; + yield 'HEAD: served as GET, with the body emptied' => ['HEAD', null]; + } + + /** + * RFC 9110 §8.6: the `Content-Length` of a HEAD answer is the length the GET answer would have. Symfony serves HEAD as + * GET and `Response::prepare()` empties its body, so the length of what is left would be wrong: no length. + */ + #[DataProvider('methods')] + public function testAnAnswerToAHeadRequestGetsNoLength(string $method, ?string $expected): void + { + $this->assertSame($expected, (new ContentLength())->of($method, 200, [], self::ANSWER)); } /** @return iterable}> the bytes in each output buffer, from the bottom level up */ @@ -103,7 +122,7 @@ public function testStrayOutputInAnOutputBufferLeavesTheAnswerAlone(array $buffe } try { - $length = (new ContentLength())->of(200, [], self::ANSWER); + $length = (new ContentLength())->of('POST', 200, [], self::ANSWER); } finally { while (ob_get_level() > $level) { ob_end_clean(); @@ -119,7 +138,7 @@ public function testAnEmptyOutputBufferDoesNotCount(): void ob_start(); try { - $length = (new ContentLength())->of(200, [], self::ANSWER); + $length = (new ContentLength())->of('POST', 200, [], self::ANSWER); } finally { ob_end_clean(); } @@ -130,6 +149,6 @@ public function testAnEmptyOutputBufferDoesNotCount(): void /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ public function testAnAnswerAfterTheHeadersWentOutGetsNoLength(): void { - $this->assertNull((new ContentLength(static fn(): bool => true))->of(200, [], self::ANSWER)); + $this->assertNull((new ContentLength(static fn(): bool => true))->of('POST', 200, [], self::ANSWER)); } } diff --git a/tests/Unit/Laravel/AnswerLengthTest.php b/tests/Unit/Laravel/AnswerLengthTest.php index c9274c1..4a0575a 100644 --- a/tests/Unit/Laravel/AnswerLengthTest.php +++ b/tests/Unit/Laravel/AnswerLengthTest.php @@ -72,6 +72,18 @@ public function testAnAnswerWithoutABodyGetsNoLength(): void $this->assertFalse($response->headers->has('Content-Length')); } + /** The request's method reaches the rule of `ContentLength`: a HEAD answer gets no length. */ + public function testAnAnswerToAHeadRequestGetsNoLength(): void + { + $request = $this->marked(); + $request->setMethod('HEAD'); + $response = new Response(self::ANSWER); + + (new AnswerLength())->handle(new RequestHandled($request, $response)); + + $this->assertFalse($response->headers->has('Content-Length')); + } + /** D32: the listener runs inside Laravel's HTTP kernel, so nothing it meets may break the request. */ public function testAResponseThatFailsLeavesTheRequestAlone(): void { diff --git a/tests/Unit/Symfony/AnswerLengthTest.php b/tests/Unit/Symfony/AnswerLengthTest.php index 8b85a1f..550bc1e 100644 --- a/tests/Unit/Symfony/AnswerLengthTest.php +++ b/tests/Unit/Symfony/AnswerLengthTest.php @@ -116,6 +116,16 @@ public function testAnAnswerWithoutABodyGetsNoLength(): void $this->assertFalse($response->headers->has('Content-Length')); } + /** The request's method reaches the rule of `ContentLength`: a HEAD answer gets no length. */ + public function testAnAnswerToAHeadRequestGetsNoLength(): void + { + $response = new Response(self::ANSWER); + + ($this->listener())($this->event(self::MAIN, $response, method: 'HEAD')); + + $this->assertFalse($response->headers->has('Content-Length')); + } + /** Headers that went out already (stray output without a buffer) cannot carry a length any more. */ public function testAnAnswerAfterTheHeadersWentOutStaysAsItIs(): void { @@ -192,9 +202,9 @@ private function listener(?\Closure $headersSent = null): AnswerLength return new AnswerLength($tracker, $headersSent); } - private function event(mixed $controller, Response $response, int $type = HttpKernelInterface::MAIN_REQUEST): ResponseEvent + private function event(mixed $controller, Response $response, int $type = HttpKernelInterface::MAIN_REQUEST, string $method = 'POST'): ResponseEvent { - $request = Request::create('/mcp', 'POST'); + $request = Request::create('/mcp', $method); if ($controller !== null) { $request->attributes->set('_controller', $controller); } From ca342a29a00d3296fb8e23856b1c2db517dd8435 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 20:18:26 +0000 Subject: [PATCH 23/55] test: share the Symfony tests' AgentCat-on configuration, streaming kernel and two-server configuration --- .../Integration/Symfony/AnswerLengthTest.php | 36 +++++-------------- tests/Integration/Symfony/BundleTest.php | 18 ++++------ tests/Integration/Symfony/DeliveryTest.php | 24 ++++--------- tests/Support/Symfony/SymfonyTestCase.php | 23 ++++++++++++ tests/Support/Symfony/TestKernel.php | 12 +++++++ 5 files changed, 56 insertions(+), 57 deletions(-) diff --git a/tests/Integration/Symfony/AnswerLengthTest.php b/tests/Integration/Symfony/AnswerLengthTest.php index 8dc0946..5750293 100644 --- a/tests/Integration/Symfony/AnswerLengthTest.php +++ b/tests/Integration/Symfony/AnswerLengthTest.php @@ -6,8 +6,6 @@ use AgentCat\Internal\Delivery\FinishRequest; use AgentCat\Symfony\Internal\AnswerLength; -use AgentCat\Tests\Integration\Support\MockCollector; -use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\Symfony\SymfonyHttp; use AgentCat\Tests\Support\Symfony\SymfonyTestCase; use AgentCat\Tests\Support\Symfony\TestKernel; @@ -29,18 +27,12 @@ final class AnswerLengthTest extends SymfonyTestCase { private const string PROJECT = 'proj_lllllllllllllllllllllllllll'; - /** Two servers: `conformance` and `second` (`second-server`, on `/mcp/second`), with the same tools. */ - private const array TWO_SERVERS = ['servers' => [ - 'conformance' => TestKernel::CONFORMANCE, - 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + TestKernel::CONFORMANCE, - ]]; - /** Under the CLI the bundle adds no listener: the SAPI has no request that ends with the script. */ #[MaximumDuration(1000)] public function testThisSapiGetsNoListener(): void { $this->assertFalse(FinishRequest::responseEndsWithTheScript(PHP_SAPI, $_SERVER, function_exists(...))); - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); $response = $this->toolCall($kernel); @@ -53,7 +45,7 @@ public function testThisSapiGetsNoListener(): void #[MaximumDuration(1000)] public function testTheListenerRunsLast(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); @@ -64,7 +56,7 @@ public function testTheListenerRunsLast(): void #[MaximumDuration(1000)] public function testATrackedServersAnswerGetsItsLength(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); $response = $this->toolCall($kernel); @@ -81,7 +73,7 @@ public function testATrackedServersAnswerGetsItsLength(): void #[MaximumDuration(1000)] public function testOnlyATrackedServersRouteGetsTheLength(): void { - $kernel = $this->kernel(agentcat: [...$this->on(), 'exclude_servers' => ['second']], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: [...$this->on(self::PROJECT), 'exclude_servers' => ['second']], mcp: TestKernel::TWO_SERVERS); AnswerLength::register($kernel->getContainer(), true); $excluded = $this->toolCall($kernel, '/mcp/second'); @@ -96,7 +88,7 @@ public function testOnlyATrackedServersRouteGetsTheLength(): void #[MaximumDuration(1000)] public function testAnAnswerWhileAgentCatIsOffStaysAsItIs(): void { - $kernel = $this->kernel(agentcat: [...$this->on(), 'enabled' => 'false']); + $kernel = $this->kernel(agentcat: [...$this->on(self::PROJECT), 'enabled' => 'false']); AnswerLength::register($kernel->getContainer(), true); $response = $this->toolCall($kernel); @@ -110,7 +102,7 @@ public function testAnAnswerWhileAgentCatIsOffStaysAsItIs(): void #[MaximumDuration(1000)] public function testAStreamedAnswerStaysAsItIs(): void { - $kernel = $this->kernel(agentcat: $this->on(), services: [ProgressTool::class => ProgressTool::class], mcp: ['servers' => ['conformance' => ['registry' => [...TestKernel::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + TestKernel::CONFORMANCE]]); + $kernel = $this->streamingKernel($this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); $http = $this->http($kernel); $http->listTools(); @@ -125,7 +117,7 @@ public function testAStreamedAnswerStaysAsItIs(): void #[MaximumDuration(1000)] public function testAnAnswerWithoutABodyStaysAsItIs(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); $this->toolCall($kernel); // the server is built and tracked @@ -142,7 +134,7 @@ public function testAnAnswerWithoutABodyStaysAsItIs(): void #[MaximumDuration(1000)] public function testAnAnswerToAHeadRequestStaysAsItIs(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); $this->toolCall($kernel); // the server is built and tracked @@ -156,7 +148,7 @@ public function testAnAnswerToAHeadRequestStaysAsItIs(): void #[MaximumDuration(1000)] public function testAnotherRouteStaysAsItIs(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); AnswerLength::register($kernel->getContainer(), true); $this->assertTrue($this->toolCall($kernel)->headers->has('Content-Length')); @@ -173,14 +165,4 @@ private function toolCall(TestKernel $kernel, string $path = SymfonyHttp::PATH): return $kernel->handle($http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start'])); } - - /** - * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. - * - * @return array - */ - private function on(): array - { - return ['enabled' => 'true', 'project_id' => self::PROJECT, 'api_url' => MockCollector::closedPortUrl()]; - } } diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index c046482..d5f452e 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -54,12 +54,6 @@ final class BundleTest extends SymfonyTestCase private const string LITERAL_ID = 'WARN: AgentCat option "agentcat.%s" must be a literal service ID, not an environment variable; the default is used'; - /** Two servers: `conformance` and `second` (`second-server`, on `/mcp/second`), with the same tools. */ - private const array TWO_SERVERS = ['servers' => [ - 'conformance' => TestKernel::CONFORMANCE, - 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + TestKernel::CONFORMANCE, - ]]; - private ?Environment $variables = null; #[\Override] @@ -211,7 +205,7 @@ public function testAWrittenNameIsRemovedWhenTheContainerCompiles(mixed $exclusi putenv('AGENTCAT_TEST_EXCLUDE=second'); $probe = new DefinitionsProbe(); - $this->kernel(agentcat: [...self::ON, 'exclude_servers' => $exclusion], probe: $probe, mcp: ['servers' => [...self::TWO_SERVERS['servers'], 'my-third' => ['name' => 'third-server', 'http' => ['path' => '/mcp/third']] + TestKernel::CONFORMANCE]]); + $this->kernel(agentcat: [...self::ON, 'exclude_servers' => $exclusion], probe: $probe, mcp: ['servers' => [...TestKernel::TWO_SERVERS['servers'], 'my-third' => ['name' => 'third-server', 'http' => ['path' => '/mcp/third']] + TestKernel::CONFORMANCE]]); $this->assertEquals($configurators, array_map(static fn(array $builder): mixed => $builder['configurator'], $probe->builders)); } @@ -256,7 +250,7 @@ public function testAVariableOfAWrongTypeLogsOneLine(string $exclusion, string $ #[MaximumDuration(1000)] public function testAnExclusionLeavesTheOtherServersTracked(): void { - $kernel = $this->kernel(agentcat: [...self::ON, 'exclude_servers' => ['conformance']], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: [...self::ON, 'exclude_servers' => ['conformance']], mcp: TestKernel::TWO_SERVERS); $tools = $this->http($kernel, '/mcp/second')->listTools()->tools; @@ -318,7 +312,7 @@ public function testAServerCanGoToItsOwnProject(): void #[MaximumDuration(1000)] public function testTwoServersSendToTwoProjects(): void { - $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['second' => self::OTHER_PROJECT]], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['second' => self::OTHER_PROJECT]], mcp: TestKernel::TWO_SERVERS); $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); $this->http($kernel, '/mcp/second')->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); @@ -331,7 +325,7 @@ public function testTwoServersSendToTwoProjects(): void #[MaximumDuration(1000)] public function testANullProjectEntryGoesToTheProjectId(): void { - $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['conformance' => '%env(default::AGENTCAT_BILLING_PROJECT_ID)%', 'second' => self::OTHER_PROJECT]], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: [...self::ON, 'projects' => ['conformance' => '%env(default::AGENTCAT_BILLING_PROJECT_ID)%', 'second' => self::OTHER_PROJECT]], mcp: TestKernel::TWO_SERVERS); $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); @@ -355,7 +349,7 @@ public function testBundleKeysOfAWrongTypeLogOneLineEachAndFallBack(): void putenv('AGENTCAT_PROJECT_ID=' . self::PROJECT); // the default of a project_id that is no string $logger = new MemoryLogger(); Log::setLogger($logger); - $kernel = $this->kernel(agentcat: ['enabled' => true, 'project_id' => 12345, 'projects' => ['conformance' => 7, 'second' => self::OTHER_PROJECT], 'logger' => 5, 'diagnostics_endpoint' => 5, 'diagnostics_token' => false], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: ['enabled' => true, 'project_id' => 12345, 'projects' => ['conformance' => 7, 'second' => self::OTHER_PROJECT], 'logger' => 5, 'diagnostics_endpoint' => 5, 'diagnostics_token' => false], mcp: TestKernel::TWO_SERVERS); $this->http($kernel)->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); $this->http($kernel, '/mcp/second')->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); @@ -494,7 +488,7 @@ public function testEachBadValueLogsOnceForEveryServer(): void { $logger = new MemoryLogger(); Log::setLogger($logger); - $kernel = $this->kernel(agentcat: ['enabled' => 'maybe', 'enable_report_missing' => 'maybe'], mcp: self::TWO_SERVERS); + $kernel = $this->kernel(agentcat: ['enabled' => 'maybe', 'enable_report_missing' => 'maybe'], mcp: TestKernel::TWO_SERVERS); $this->http($kernel)->listTools(); $this->http($kernel, '/mcp/second')->listTools(); diff --git a/tests/Integration/Symfony/DeliveryTest.php b/tests/Integration/Symfony/DeliveryTest.php index 614df9f..aeeb811 100644 --- a/tests/Integration/Symfony/DeliveryTest.php +++ b/tests/Integration/Symfony/DeliveryTest.php @@ -12,10 +12,8 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\JsonAssert; use AgentCat\Tests\Support\Symfony\HeaderIdentity; -use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\Symfony\SymfonyHttp; use AgentCat\Tests\Support\Symfony\SymfonyTestCase; -use AgentCat\Tests\Support\Symfony\TestKernel; use Ergebnis\PHPUnit\SlowTestDetector\Attribute\MaximumDuration; use PHPUnit\Framework\Attributes\Group; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -52,7 +50,7 @@ public static function tearDownAfterClass(): void #[MaximumDuration(1000)] public function testKernelTerminateDeliversTheRequestsEvents(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); $http = $this->http($kernel); $http->listTools(); $request = $http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start']); @@ -68,7 +66,7 @@ public function testKernelTerminateDeliversTheRequestsEvents(): void #[MaximumDuration(1000)] public function testTheListenerRunsLast(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); $dispatcher = $kernel->getContainer()->get('event_dispatcher'); $this->assertInstanceOf(EventDispatcherInterface::class, $dispatcher); @@ -84,7 +82,7 @@ public function testTheListenerRunsLast(): void #[MaximumDuration(1000)] public function testAWorkerWaitsAtMostTheCapAndTheNextRequestDeliversTheRest(): void { - $kernel = $this->kernel(agentcat: $this->on()); + $kernel = $this->kernel(agentcat: $this->on(self::PROJECT)); $http = $this->http($kernel); $http->listTools(); $this->transport->complete = false; // an API that does not answer within the request; the clock is simulated @@ -113,7 +111,7 @@ public function testWithASlowApiTerminateReturnsAfterAboutOneSecondAndTheWorkerS $this->assertInstanceOf(MockCollector::class, $collector); $scenario = $collector->scenario([['status' => 202, 'delayMs' => 4000]]); EventQueue::setGlobal(new EventQueue()); // the real curl transport, before the kernel builds a server - $kernel = $this->kernel(agentcat: [...$this->on(), 'api_url' => $scenario]); + $kernel = $this->kernel(agentcat: [...$this->on(self::PROJECT), 'api_url' => $scenario]); $http = $this->http($kernel); $http->listTools(); $request = $http->callRequest('echo', ['msg' => 'hi', 'session_id' => 'start']); @@ -142,7 +140,7 @@ public function testWithASlowApiTerminateReturnsAfterAboutOneSecondAndTheWorkerS #[MaximumDuration(2000)] public function testAHookThatReadsTheRequestStackNeverSeesALaterRequest(): void { - $kernel = $this->kernel(agentcat: [...$this->on(), 'identify' => 'app.identify'], services: ['app.identify' => HeaderIdentity::class]); + $kernel = $this->kernel(agentcat: [...$this->on(self::PROJECT), 'identify' => 'app.identify'], services: ['app.identify' => HeaderIdentity::class]); $identity = $kernel->getContainer()->get('app.identify'); $this->assertInstanceOf(HeaderIdentity::class, $identity); $http = $this->http($kernel); @@ -173,7 +171,7 @@ public function testAHookThatReadsTheRequestStackNeverSeesALaterRequest(): void #[MaximumDuration(1000)] public function testAStreamedAnswersEventCarriesItsOwnRequestsHeaders(): void { - $kernel = $this->kernel(agentcat: $this->on(), services: [ProgressTool::class => ProgressTool::class], mcp: ['servers' => ['conformance' => ['registry' => [...TestKernel::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + TestKernel::CONFORMANCE]]); + $kernel = $this->streamingKernel($this->on(self::PROJECT)); $http = $this->http($kernel); $http->listTools(); $request = $http->callRequest('progress', ['msg' => 'hi', 'session_id' => 'start'], ['progressToken' => 'p1'], ['X-User' => 'streamed']); @@ -189,14 +187,4 @@ public function testAStreamedAnswersEventCarriesItsOwnRequestsHeaders(): void $headers = JsonAssert::at(Json::decode($this->transport->deliveries[0]->body), 'parameters', 'extra', 'requestInfo', 'headers'); $this->assertSame('streamed', JsonAssert::at($headers, 'x-user')); } - - /** - * AgentCat on, with a project. A test that forgets its collector fails closed: the API URL is a closed local port. - * - * @return array - */ - private function on(): array - { - return ['enabled' => 'true', 'project_id' => self::PROJECT, 'api_url' => MockCollector::closedPortUrl()]; - } } diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 887d24f..66f687c 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -12,9 +12,11 @@ use AgentCat\Internal\Support\BridgeConfig; use AgentCat\Internal\Support\TestMode; use AgentCat\Internal\Transport\Delivery; +use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; use AgentCat\Tests\Support\RecordingTransport; +use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; @@ -102,6 +104,27 @@ protected function kernel(array $agentcat = [], array $services = [], bool $with return $kernel; } + /** + * A kernel whose `conformance` server also has the `progress` tool, which answers with an SSE stream. + * + * @param array $agentcat the agentcat configuration + */ + protected function streamingKernel(array $agentcat): TestKernel + { + return $this->kernel(agentcat: $agentcat, services: [ProgressTool::class => ProgressTool::class], mcp: TestKernel::STREAMING); + } + + /** + * The agentcat configuration with AgentCat on and a project. A test that forgets its collector fails closed: the API + * URL is a closed local port. + * + * @return array + */ + protected function on(string $project): array + { + return ['enabled' => 'true', 'project_id' => $project, 'api_url' => MockCollector::closedPortUrl()]; + } + /** `HttpHarness`'s API over the kernel's MCP route at `$path`: the `conformance` server's by default. */ protected function http(TestKernel $kernel, string $path = SymfonyHttp::PATH): SymfonyHttp { diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index 6647332..d846f37 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -34,6 +34,18 @@ final class TestKernel extends Kernel /** The default mcp configuration: the `conformance` server only. */ public const array MCP = ['servers' => ['conformance' => self::CONFORMANCE]]; + /** Two servers: `conformance` and `second` (`second-server`, on `/mcp/second`), with the same tools. */ + public const array TWO_SERVERS = ['servers' => [ + 'conformance' => self::CONFORMANCE, + 'second' => ['name' => 'second-server', 'http' => ['path' => '/mcp/second']] + self::CONFORMANCE, + ]]; + + /** + * The `conformance` server with `Streaming\ProgressTool` too, whose answer is an SSE stream. The kernel needs the + * tool as a service: `SymfonyTestCase::streamingKernel()` gives it both. + */ + public const array STREAMING = ['servers' => ['conformance' => ['registry' => [...self::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + self::CONFORMANCE]]; + /** * @param string $directory the directory of the cache and log directories * @param array $agentcat the agentcat configuration From 9bfc571f987d703c81877947a83c728383579a46 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 20:19:51 +0000 Subject: [PATCH 24/55] test: end each request of the Symfony customer test suites with the kernel's terminate phase, as KernelBrowser does --- .../Symfony/CustomerTestSuiteTest.php | 11 ++++++----- tests/Support/Symfony/SymfonyHttp.php | 16 +++++++++++++--- tests/Support/Symfony/SymfonyTestCase.php | 9 ++++++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/Integration/Symfony/CustomerTestSuiteTest.php b/tests/Integration/Symfony/CustomerTestSuiteTest.php index 969cc48..51f62dc 100644 --- a/tests/Integration/Symfony/CustomerTestSuiteTest.php +++ b/tests/Integration/Symfony/CustomerTestSuiteTest.php @@ -15,8 +15,9 @@ /** * A customer's Symfony functional tests with AgentCat's bundle registered (plan E2 Review Focus 2, D31, ruling P9): a * kernel booted by the test, requests sent to the MCP route, and the `agentcat` configuration empty, as the Flex recipe - * leaves it in the test environment. Each test boots its kernels, which compile a container: about 0.3 s each, - * measured on PHP 8.3 and 8.5. + * leaves it in the test environment. As a customer's `KernelBrowser` does, each request ends with the kernel's terminate + * phase, where AgentCat's `kernel.terminate` listener runs. Each test boots its kernels, which compile a container: + * about 0.3 s each, measured on PHP 8.3 and 8.5. */ final class CustomerTestSuiteTest extends SymfonyTestCase { @@ -24,12 +25,12 @@ final class CustomerTestSuiteTest extends SymfonyTestCase #[MaximumDuration(2000)] public function testWithoutAFakeTheAnswersAreTheBundlesOwn(): void { - $untracked = $this->http($this->kernel(withAgentCat: false)); + $untracked = $this->http($this->kernel(withAgentCat: false), terminate: true); $plain = [$untracked->listTools(), $untracked->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; $logger = new MemoryLogger(); Log::setLogger($logger); - $http = $this->http($this->kernel()); + $http = $this->http($this->kernel(), terminate: true); $tracked = [$http->listTools(), $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start'])]; $this->assertEquals($plain, $tracked); @@ -42,7 +43,7 @@ public function testWithoutAFakeTheAnswersAreTheBundlesOwn(): void public function testAFakeBeforeTheKernelBootsRecordsTheCalls(): void { $fake = AgentCat::fake(); - $http = $this->http($this->kernel()); + $http = $this->http($this->kernel(), terminate: true); $http->listTools(); $http->callTool('echo', ['msg' => 'hi', 'session_id' => 'start']); diff --git a/tests/Support/Symfony/SymfonyHttp.php b/tests/Support/Symfony/SymfonyHttp.php index 45673f4..30c8ba2 100644 --- a/tests/Support/Symfony/SymfonyHttp.php +++ b/tests/Support/Symfony/SymfonyHttp.php @@ -9,11 +9,13 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\HttpKernel\TerminableInterface; /** * `HttpHarness`'s API over a Symfony kernel: each message is one request to the bundle's route of an MCP server (the - * `conformance` server's by default), handled by `$kernel->handle()` in process, with no sockets and no `terminate()`: - * a test that needs the terminate phase builds the request with `callRequest()` and runs both itself. Handshake-era + * `conformance` server's by default), handled by `$kernel->handle()` in process, with no sockets. By default there is no + * `terminate()`: a test that needs the terminate phase builds the request with `callRequest()` and runs both itself. + * With `$terminate`, each request ends with `terminate()`, as a customer's `KernelBrowser` does. Handshake-era * calls carry the `Mcp-Session-Id` that `initialize()` received; `listTools()`, `callTool()` and `callRequest()` send * `initialize()` first when there is no session yet. `modern()` sends one self-contained 2026-07-28 request instead. */ @@ -29,10 +31,15 @@ final class SymfonyHttp private string $protocolVersion = '2025-06-18'; - /** @param string $path the HTTP path of the server: the `conformance` server's by default */ + /** + * @param string $path the HTTP path of the server: the `conformance` server's by default + * @param bool $terminate whether each request ends with the kernel's `terminate()`, as in Symfony's + * `HttpKernelBrowser::doRequest()` + */ public function __construct( private readonly KernelInterface $kernel, private readonly string $path = self::PATH, + private readonly bool $terminate = false, ) {} /** The `Mcp-Session-Id` the server issued at `initialize()`; null before. */ @@ -159,6 +166,9 @@ private function build(array $message, array $headers, bool $withSession): Reque private function handle(Request $request): Response { $response = $this->kernel->handle($request); + if ($this->terminate && $this->kernel instanceof TerminableInterface) { + $this->kernel->terminate($request, $response); + } $session = $response->headers->get('Mcp-Session-Id'); if (is_string($session) && $session !== '') { $this->sessionId = $session; diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 66f687c..60057a3 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -125,10 +125,13 @@ protected function on(string $project): array return ['enabled' => 'true', 'project_id' => $project, 'api_url' => MockCollector::closedPortUrl()]; } - /** `HttpHarness`'s API over the kernel's MCP route at `$path`: the `conformance` server's by default. */ - protected function http(TestKernel $kernel, string $path = SymfonyHttp::PATH): SymfonyHttp + /** + * `HttpHarness`'s API over the kernel's MCP route at `$path`: the `conformance` server's by default. With `$terminate`, + * each request ends with the kernel's terminate phase, as with a customer's `KernelBrowser`. + */ + protected function http(TestKernel $kernel, string $path = SymfonyHttp::PATH, bool $terminate = false): SymfonyHttp { - return new SymfonyHttp($kernel, $path); + return new SymfonyHttp($kernel, $path, $terminate); } /** From 0b5480527475470c3ace9c17d44f7b960a364c96 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 20:35:50 +0000 Subject: [PATCH 25/55] test: warm up one Laravel application and one Symfony kernel per process before the first measured test --- tests/Integration/Laravel/DeliveryTest.php | 2 + tests/Integration/Symfony/DeliveryTest.php | 2 + tests/Support/GlobalHandlers.php | 89 ++++++++++++++++++++ tests/Support/LaravelMcp/LaravelTestCase.php | 44 ++++++++++ tests/Support/Symfony/SymfonyTestCase.php | 46 ++++++++++ tests/Unit/GlobalHandlersTest.php | 48 +++++++++++ 6 files changed, 231 insertions(+) create mode 100644 tests/Support/GlobalHandlers.php create mode 100644 tests/Unit/GlobalHandlersTest.php diff --git a/tests/Integration/Laravel/DeliveryTest.php b/tests/Integration/Laravel/DeliveryTest.php index b53371a..beccaff 100644 --- a/tests/Integration/Laravel/DeliveryTest.php +++ b/tests/Integration/Laravel/DeliveryTest.php @@ -48,6 +48,7 @@ final class DeliveryTest extends BridgeTestCase #[\Override] public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); self::$collector = MockCollector::start(); } @@ -56,6 +57,7 @@ public static function tearDownAfterClass(): void { self::$collector?->stop(); self::$collector = null; + parent::tearDownAfterClass(); } /** diff --git a/tests/Integration/Symfony/DeliveryTest.php b/tests/Integration/Symfony/DeliveryTest.php index aeeb811..3a73170 100644 --- a/tests/Integration/Symfony/DeliveryTest.php +++ b/tests/Integration/Symfony/DeliveryTest.php @@ -35,6 +35,7 @@ final class DeliveryTest extends SymfonyTestCase #[\Override] public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); self::$collector = MockCollector::start(); } @@ -43,6 +44,7 @@ public static function tearDownAfterClass(): void { self::$collector?->stop(); self::$collector = null; + parent::tearDownAfterClass(); } #[Group('ac-03-12')] diff --git a/tests/Support/GlobalHandlers.php b/tests/Support/GlobalHandlers.php new file mode 100644 index 0000000..cc76e68 --- /dev/null +++ b/tests/Support/GlobalHandlers.php @@ -0,0 +1,89 @@ + $errors from the bottom of the stack up + * @param list $exceptions from the bottom of the stack up + */ + private function __construct( + private array $errors, + private array $exceptions, + ) {} + + public static function save(): self + { + $errors = self::takeErrorHandlers(); + $exceptions = self::takeExceptionHandlers(); + self::push($errors, $exceptions); + + return new self($errors, $exceptions); + } + + /** Puts back the stacks as they were saved. */ + public function restore(): void + { + self::takeErrorHandlers(); + self::takeExceptionHandlers(); + self::push($this->errors, $this->exceptions); + } + + /** @return list the error handlers, from the bottom up; the stack is left empty */ + private static function takeErrorHandlers(): array + { + $handlers = []; + while (true) { + $previous = set_error_handler(static fn(): bool => false); + restore_error_handler(); + if ($previous === null) { + break; + } + $handlers[] = $previous; + restore_error_handler(); + } + + return array_reverse($handlers); + } + + /** @return list the exception handlers, from the bottom up; the stack is left empty */ + private static function takeExceptionHandlers(): array + { + $handlers = []; + while (true) { + $previous = set_exception_handler(static function (): void {}); + restore_exception_handler(); + if ($previous === null) { + break; + } + $handlers[] = $previous; + restore_exception_handler(); + } + + return array_reverse($handlers); + } + + /** + * @param list $errors + * @param list $exceptions + */ + private static function push(array $errors, array $exceptions): void + { + foreach ($errors as $handler) { + set_error_handler($handler); + } + foreach ($exceptions as $handler) { + set_exception_handler($handler); + } + } +} diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 633ab07..2b153eb 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -13,6 +13,7 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\ToolCalls; use Illuminate\Foundation\Application; use Laravel\Mcp\Server\McpServiceProvider; @@ -39,8 +40,51 @@ abstract class LaravelTestCase extends TestCase /** This test's directory under the test HOME. */ protected string $laravelDirectory = ''; + /** Whether this process built its warm-up application (`setUpBeforeClass()`). */ + private static bool $warm = false; + private ?Environment $environment = null; + /** + * The first Laravel application of a PHP process is slow: PHP compiles the framework's files then (about 0.07 s on + * PHP 8.5, up to 0.3 s on a loaded host, against 0.01 s for each later one). So, once per process and before the + * first test class runs, a test case of its own builds one application, sends it one request and tears it down, + * outside the time the slow-test detector measures for a test. + */ + #[\Override] + public static function setUpBeforeClass(): void + { + if (!self::$warm) { + self::$warm = true; + self::warmUp(); + } + parent::setUpBeforeClass(); + } + + /** Runs one test case's whole lifecycle, as PHPUnit does, with one request: a 404, through Laravel's HTTP kernel. */ + private static function warmUp(): void + { + $warmUp = new class ('testWarmUp') extends LaravelTestCase { + public function testWarmUp(): void + { + $this->get('/agentcat-warm-up'); // a 404; no assertion, which PHPUnit would count for the next test + } + }; + $handlers = GlobalHandlers::save(); // the framework changes them, and no test puts them back + try { + $warmUp::setUpBeforeClass(); + $warmUp->setUp(); + try { + $warmUp->testWarmUp(); + } finally { + $warmUp->tearDown(); + $warmUp::tearDownAfterClass(); + } + } finally { + $handlers->restore(); + } + } + #[\Override] protected function setUp(): void { diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 60057a3..009224c 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -15,6 +15,7 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; +use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; @@ -44,6 +45,51 @@ abstract class SymfonyTestCase extends TestCase private ?Environment $environment = null; + /** Whether this process ran its warm-up kernel (`setUpBeforeClass()`). */ + private static bool $warm = false; + + /** + * The first container that a PHP process compiles is slow: PHP compiles the files of Symfony, symfony/mcp-bundle and + * mcp/sdk then (about 0.2 s more on PHP 8.5, up to 1.5 s on a loaded host). So, once per process and before the + * first test class runs, a test case of its own boots one kernel with AgentCat on, sends it a tool call and tears it + * down, outside the time the slow-test detector measures for a test. + */ + #[\Override] + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + if (!self::$warm && class_exists(McpBundle::class)) { + self::$warm = true; + self::warmUp(); + } + } + + /** Runs one test case's whole lifecycle, as PHPUnit does: a kernel, its handshake, a listing and a tool call. */ + private static function warmUp(): void + { + $warmUp = new class ('testWarmUp') extends SymfonyTestCase { + public function testWarmUp(): void + { + $http = $this->http($this->kernel(agentcat: $this->on('proj_wwwwwwwwwwwwwwwwwwwwwwwwwww')), terminate: true); + $http->listTools(); + $http->callTool('echo', ['msg' => 'warm-up', 'session_id' => 'start']); + } + }; + $handlers = GlobalHandlers::save(); // the framework changes them, and no test puts them back + try { + $warmUp::setUpBeforeClass(); + $warmUp->setUp(); + try { + $warmUp->testWarmUp(); + } finally { + $warmUp->tearDown(); + $warmUp::tearDownAfterClass(); + } + } finally { + $handlers->restore(); + } + } + #[\Override] protected function setUp(): void { diff --git a/tests/Unit/GlobalHandlersTest.php b/tests/Unit/GlobalHandlersTest.php new file mode 100644 index 0000000..2b535a9 --- /dev/null +++ b/tests/Unit/GlobalHandlersTest.php @@ -0,0 +1,48 @@ + false; + $exception = static function (): void {}; + set_error_handler($error); + set_exception_handler($exception); + + try { + $saved = GlobalHandlers::save(); + $this->assertSame([$error, $exception], $this->top(), 'saving leaves the stacks as they are'); + restore_error_handler(); // as Laravel's HandleExceptions::flushState() removes every handler + restore_exception_handler(); + set_error_handler(static fn(): bool => true); // as Symfony's ErrorHandler stays after a kernel boots + $saved->restore(); + + $this->assertSame([$error, $exception], $this->top()); + } finally { + restore_error_handler(); + restore_exception_handler(); + } + } + + /** @return array{mixed, mixed} the error handler and the exception handler on top of their stacks */ + private function top(): array + { + $error = set_error_handler(static fn(): bool => false); + restore_error_handler(); + $exception = set_exception_handler(static function (): void {}); + restore_exception_handler(); + + return [$error, $exception]; + } +} From b046791b9580259706610fb376840d03b8cada0a Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 21:06:12 +0000 Subject: [PATCH 26/55] test: fail a bridge test class when its warm-up meets a notice, warning or deprecation from src/ --- tests/Support/LaravelMcp/LaravelTestCase.php | 12 ++- tests/Support/Symfony/SymfonyTestCase.php | 12 ++- tests/Support/WarmUp.php | 46 ++++++++++ tests/Unit/WarmUpTest.php | 92 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 tests/Support/WarmUp.php create mode 100644 tests/Unit/WarmUpTest.php diff --git a/tests/Support/LaravelMcp/LaravelTestCase.php b/tests/Support/LaravelMcp/LaravelTestCase.php index 2b153eb..d344890 100644 --- a/tests/Support/LaravelMcp/LaravelTestCase.php +++ b/tests/Support/LaravelMcp/LaravelTestCase.php @@ -13,8 +13,8 @@ use AgentCat\Internal\Support\TestMode; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; -use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\ToolCalls; +use AgentCat\Tests\Support\WarmUp; use Illuminate\Foundation\Application; use Laravel\Mcp\Server\McpServiceProvider; use Orchestra\Testbench\TestCase; @@ -49,7 +49,8 @@ abstract class LaravelTestCase extends TestCase * The first Laravel application of a PHP process is slow: PHP compiles the framework's files then (about 0.07 s on * PHP 8.5, up to 0.3 s on a loaded host, against 0.01 s for each later one). So, once per process and before the * first test class runs, a test case of its own builds one application, sends it one request and tears it down, - * outside the time the slow-test detector measures for a test. + * outside the time the slow-test detector measures for a test. `WarmUp::run()` makes a + * notice, warning or deprecation from a file of `src/` fail the class, as it would fail the test that included it. */ #[\Override] public static function setUpBeforeClass(): void @@ -70,8 +71,7 @@ public function testWarmUp(): void $this->get('/agentcat-warm-up'); // a 404; no assertion, which PHPUnit would count for the next test } }; - $handlers = GlobalHandlers::save(); // the framework changes them, and no test puts them back - try { + WarmUp::run(static function () use ($warmUp): void { $warmUp::setUpBeforeClass(); $warmUp->setUp(); try { @@ -80,9 +80,7 @@ public function testWarmUp(): void $warmUp->tearDown(); $warmUp::tearDownAfterClass(); } - } finally { - $handlers->restore(); - } + }); } #[\Override] diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 009224c..16ea606 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -15,10 +15,10 @@ use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\FileTree; -use AgentCat\Tests\Support\GlobalHandlers; use AgentCat\Tests\Support\RecordingTransport; use AgentCat\Tests\Support\Symfony\Streaming\ProgressTool; use AgentCat\Tests\Support\ToolCalls; +use AgentCat\Tests\Support\WarmUp; use PHPUnit\Framework\TestCase; use Symfony\AI\McpBundle\McpBundle; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -52,7 +52,8 @@ abstract class SymfonyTestCase extends TestCase * The first container that a PHP process compiles is slow: PHP compiles the files of Symfony, symfony/mcp-bundle and * mcp/sdk then (about 0.2 s more on PHP 8.5, up to 1.5 s on a loaded host). So, once per process and before the * first test class runs, a test case of its own boots one kernel with AgentCat on, sends it a tool call and tears it - * down, outside the time the slow-test detector measures for a test. + * down, outside the time the slow-test detector measures for a test. `WarmUp::run()` makes a + * notice, warning or deprecation from a file of `src/` fail the class, as it would fail the test that included it. */ #[\Override] public static function setUpBeforeClass(): void @@ -75,8 +76,7 @@ public function testWarmUp(): void $http->callTool('echo', ['msg' => 'warm-up', 'session_id' => 'start']); } }; - $handlers = GlobalHandlers::save(); // the framework changes them, and no test puts them back - try { + WarmUp::run(static function () use ($warmUp): void { $warmUp::setUpBeforeClass(); $warmUp->setUp(); try { @@ -85,9 +85,7 @@ public function testWarmUp(): void $warmUp->tearDown(); $warmUp::tearDownAfterClass(); } - } finally { - $handlers->restore(); - } + }); } #[\Override] diff --git a/tests/Support/WarmUp.php b/tests/Support/WarmUp.php new file mode 100644 index 0000000..0762cb4 --- /dev/null +++ b/tests/Support/WarmUp.php @@ -0,0 +1,46 @@ + false); + restore_error_handler(); + set_error_handler(static function (int $level, string $message, string $file, int $line) use ($source, $inPlace): bool { + if (($level & self::ISSUES) !== 0 && (error_reporting() & ~self::INSUPPRESSIBLE) !== 0 && str_starts_with($file, $source)) { + throw new \ErrorException(sprintf('%s (raised from %s:%d during a test case warm-up)', $message, $file, $line), 0, $level, $file, $line); + } + + return $inPlace !== null && $inPlace($level, $message, $file, $line) !== false; // false: PHP's own handling + }); + try { + $warmUp(); + } finally { + $handlers->restore(); + } + } +} diff --git a/tests/Unit/WarmUpTest.php b/tests/Unit/WarmUpTest.php new file mode 100644 index 0000000..620c371 --- /dev/null +++ b/tests/Unit/WarmUpTest.php @@ -0,0 +1,92 @@ + */ + public static function issues(): iterable + { + yield 'a deprecation' => [E_USER_DEPRECATED]; + yield 'a notice' => [E_USER_NOTICE]; + yield 'a warning' => [E_USER_WARNING]; + } + + /** An issue that a source file raises while the warm-up includes it is thrown, out of `setUpBeforeClass()`. */ + #[DataProvider('issues')] + public function testAnIssueFromASourceFileFailsTheWarmUp(int $level): void + { + $thrown = Thrown::by(\ErrorException::class, static function () use ($level): void { + WarmUp::run(static function () use ($level): void { + trigger_error('raised at include time', $level); + }, __DIR__ . '/'); + }); + + $this->assertStringStartsWith('raised at include time (raised from ' . __FILE__ . ':', $thrown->getMessage()); + $this->assertSame($level, $thrown->getSeverity()); + $this->assertSame(__FILE__, $thrown->getFile()); + } + + /** @return iterable the source directory, and whether the issue is suppressed */ + public static function otherIssues(): iterable + { + yield 'from another file, as vendor/' => ['/nonexistent/src/', false]; + yield 'from a source file, suppressed with @' => [__DIR__ . '/', true]; + } + + /** Any other issue goes to the handler that was in place, as without the guard. */ + #[DataProvider('otherIssues')] + public function testAnyOtherIssueGoesToTheHandlerInPlace(string $source, bool $suppressed): void + { + $seen = new \ArrayObject(); + set_error_handler(static function (int $level, string $message) use ($seen): bool { + $seen->append($message); + + return true; + }); + + try { + WarmUp::run(static function () use ($suppressed): void { + $suppressed ? @trigger_error('not ours', E_USER_DEPRECATED) : trigger_error('not ours', E_USER_DEPRECATED); + }, $source); + } finally { + restore_error_handler(); + } + + $this->assertSame(['not ours'], $seen->getArrayCopy()); + } + + /** An issue that the handler in place declines (it returns false) goes on to PHP's own handling, as before. */ + public function testAnIssueThatTheHandlerInPlaceDeclinesGoesOnToPhp(): void + { + $declined = new \ArrayObject(); + set_error_handler(static function (int $level, string $message) use ($declined): bool { + $declined->append($message); + + return false; + }); + error_clear_last(); + + try { + WarmUp::run(static function (): void { + @trigger_error('declined', E_USER_DEPRECATED); // suppressed, so PHP records it and prints nothing + }, '/nonexistent/src/'); + } finally { + restore_error_handler(); + } + + $this->assertSame(['declined'], $declined->getArrayCopy()); + $this->assertSame('declined', error_get_last()['message'] ?? null); + } +} From 790802996367141b0d098057fad22dd655706679 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 21:41:40 +0000 Subject: [PATCH 27/55] test: run the adapter conformance suite on symfony/mcp-bundle --- phpstan.no-bundle.neon | 3 +- .../Conformance/SymfonyConformanceTest.php | 178 +++++++++++++++++ tests/Integration/Symfony/BundleTest.php | 2 +- .../Symfony/FullPage/FullPageTools.php | 98 +++++++++ .../Symfony/SymfonyConformanceHarness.php | 187 ++++++++++++++++++ tests/Support/Symfony/SymfonyTestCase.php | 14 +- tests/Support/Symfony/TestKernel.php | 21 +- 7 files changed, 491 insertions(+), 12 deletions(-) create mode 100644 tests/Integration/Conformance/SymfonyConformanceTest.php create mode 100644 tests/Support/Symfony/FullPage/FullPageTools.php create mode 100644 tests/Support/Symfony/SymfonyConformanceHarness.php diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon index e8f256d..613ee13 100644 --- a/phpstan.no-bundle.neon +++ b/phpstan.no-bundle.neon @@ -3,9 +3,8 @@ includes: - phpstan.neon.dist parameters: tmpDir: .cache/phpstan-no-bundle - # (?) marks a path that PHPStan accepts before its tests exist. excludePaths: - tests/Support/Symfony - tests/Integration/Symfony - tests/Contract/Symfony - - tests/Integration/Conformance/SymfonyConformanceTest.php (?) + - tests/Integration/Conformance/SymfonyConformanceTest.php diff --git a/tests/Integration/Conformance/SymfonyConformanceTest.php b/tests/Integration/Conformance/SymfonyConformanceTest.php new file mode 100644 index 0000000..83fd6ae --- /dev/null +++ b/tests/Integration/Conformance/SymfonyConformanceTest.php @@ -0,0 +1,178 @@ +setUp(); // it fails unless HOME is the test HOME + self::$containers = getenv('HOME') . '/symfony-conformance-' . bin2hex(random_bytes(6)); + $compiled = false; + try { + $compile->conformanceKernel(new Options()); + $compile->conformanceKernel(new Options(enableTracing: false)); + $compile->fullPageKernel(); + $compiled = true; + } finally { + $compile->tearDown(); + if (!$compiled) { + FileTree::remove(self::$containers); // PHPUnit runs no tearDownAfterClass() after this method fails + } + } + }); + } + + #[\Override] + public static function tearDownAfterClass(): void + { + FileTree::remove(self::$containers); + parent::tearDownAfterClass(); + } + + /** + * Library difference: the bundle serves mcp/sdk, so a handshake-era call has mcp/sdk's transport session (the + * `Mcp-Session-Id`) in `extra`, never as the event's session (ac-02-46), as on the mcp/sdk leg. Unlike plain + * mcp/sdk, whose adapter reads the SAPI, the bundle gives the adapter the `RequestStack` (plan E2 header conflict + * 5): a request in this process has its `requestInfo`. + */ + #[Group('ac-02-43')] + #[Group('ac-02-46')] + public function testExtraHasTheTransportSessionOfTheHandshakeAndTheRequestOfTheRequestStack(): void + { + $harness = $this->conformance(); + $this->assertInstanceOf(SymfonyConformanceHarness::class, $harness); + $harness->listTools(); + $fresh = $this->conformance(fresh: true); + + $harness->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + $fresh->callTool('echo', ['msg' => 'hi', 'session_id' => self::SESSION]); + + $event = $this->onlyEvent($harness); + $extra = JsonAssert::at($event->parameters, 'extra'); + $this->assertSame(['sessionId', 'requestId', 'requestInfo'], array_keys((array) $extra)); + $session = $harness->sessionId(); + $this->assertIsString($session); + $this->assertSame([$session, 'http://127.0.0.1/mcp/conformance', $session], [JsonAssert::at($extra, 'sessionId'), JsonAssert::at($extra, 'requestInfo', 'url'), JsonAssert::at($extra, 'requestInfo', 'headers', 'mcp-session-id')]); + $this->assertSame(self::SESSION, $event->sessionId); + $modern = $this->onlyEvent($fresh); + $extra = JsonAssert::at($modern->parameters, 'extra'); + $this->assertSame(['requestId', '_meta', 'requestInfo'], array_keys((array) $extra)); + $this->assertSame(['http://127.0.0.1/mcp/conformance', 'tools/call'], [JsonAssert::at($extra, 'requestInfo', 'url'), JsonAssert::at($extra, 'requestInfo', 'headers', 'mcp-method')]); + } + + #[\Override] + protected function conformance(Options $options = new Options(), bool $fresh = false): ConformanceHarness + { + return SymfonyConformanceHarness::of(fn(): TestKernel => $this->conformanceKernel($options), $fresh); + } + + #[\Override] + protected function reusedObjectId(): ConformanceHarness + { + return SymfonyConformanceHarness::reusedObjectId($this->config(new Options()), self::$containers . '/agentcat.log'); + } + + #[\Override] + protected function fullPage(): ConformanceHarness + { + return SymfonyConformanceHarness::of($this->fullPageKernel(...)); + } + + /** The adapter's reader with the bundle's request context: a `RequestStack` whose main request has these values. */ + #[\Override] + protected function requestInfoOf(array $server): \stdClass + { + $stack = new RequestStack(); + $stack->push(new Request(server: $server)); + $reader = new RequestReader(RequestStackContext::of($stack)); + $request = (new CallToolRequest('echo', ['msg' => 'hi', 'session_id' => self::SESSION]))->withId(1); + + $info = JsonAssert::at($reader->read($request, new Session(new InMemorySessionStore()))->extra, 'requestInfo'); + $this->assertInstanceOf(\stdClass::class, $info); + + return $info; + } + + /** A kernel of the `conformance` server, tracked with these options. */ + private function conformanceKernel(Options $options): TestKernel + { + return $this->compiled($this->config($options)); + } + + /** A kernel of `TestKernel::FULL_PAGE`. */ + private function fullPageKernel(): TestKernel + { + return $this->compiled($this->config(new Options()), TestKernel::FULL_PAGE, [FullPageTools::class => FullPageTools::class]); + } + + /** + * A kernel whose container this class compiled for its configuration. + * + * @param array $agentcat + * @param array $mcp + * @param array $services + */ + private function compiled(array $agentcat, array $mcp = TestKernel::MCP, array $services = []): TestKernel + { + $services += SymfonyConformanceHarness::SERVICES; + $directory = self::$containers . '/' . hash('xxh3', serialize([$agentcat, $mcp, $services])); + + return $this->kernel(agentcat: $agentcat, services: $services, mcp: $mcp, directory: $directory); + } + + /** + * The agentcat configuration of these options (the subset the suite varies), always on, with AgentCat's lines in + * the harness's logger. + * + * @return array + */ + private function config(Options $options): array + { + return ['api_url' => self::$apiUrl, 'logger' => SymfonyConformanceHarness::LOGGER, 'enable_tracing' => $options->enableTracing] + $this->on(self::PROJECT); + } +} diff --git a/tests/Integration/Symfony/BundleTest.php b/tests/Integration/Symfony/BundleTest.php index d5f452e..6d7edeb 100644 --- a/tests/Integration/Symfony/BundleTest.php +++ b/tests/Integration/Symfony/BundleTest.php @@ -609,7 +609,7 @@ public function testAConfigurationThatCannotBeReadLeavesTheServerUntracked(): vo $tools = $http->listTools()->tools; $this->assertIsArray($tools); - $this->assertSame('structured', JsonAssert::at(end($tools), 'name')); + $this->assertSame('own', JsonAssert::at(end($tools), 'name')); $this->assertSame(['Warning: Failed to track server - Symfony\Component\DependencyInjection\Exception\EnvNotFoundException: Environment variable not found: "AGENTCAT_UNSET_PROJECT_ID".'], $this->agentCatLines($kernel)); } diff --git a/tests/Support/Symfony/FullPage/FullPageTools.php b/tests/Support/Symfony/FullPage/FullPageTools.php new file mode 100644 index 0000000..376de2b --- /dev/null +++ b/tests/Support/Symfony/FullPage/FullPageTools.php @@ -0,0 +1,98 @@ + MemoryLogger::class]; + + private readonly RecordingExporter $exporter; + + private readonly EventQueue $queue; + + /** @var list the logger of each kernel, in the order of the boots */ + private array $loggers = []; + + /** The one server; null when fresh. */ + private HttpHarness|SymfonyHttp|null $server = null; + + /** @param (\Closure(): TestKernel)|null $boot boots a kernel; null: the harness made its server itself */ + private function __construct(private readonly ?\Closure $boot) + { + $this->exporter = new RecordingExporter(); + $this->queue = new EventQueue(transport: new RecordingTransport(), exporters: $this->exporter); + } + + /** + * The `conformance` server of the kernels that `$boot` boots: one, after the handshake, which tracks the server + * with this harness's queue; a new kernel per request when `$fresh`. + * + * @param \Closure(): TestKernel $boot + */ + public static function of(\Closure $boot, bool $fresh = false): self + { + $harness = new self($boot); + if (!$fresh) { + $harness->server = $harness->kernel(); + $harness->server->initialize(); + } + + return $harness; + } + + /** + * The conformance server, tracked by the bundle's configurator after an earlier one, which a tracker with tracing + * off had tracked and built, was freed: each `ServerTracker` serves one container, as each request of PHP-FPM has + * its own. Asserts that the new Builder has the old one's object ID. The harness makes both Builders, with the five + * tools of `Tools/`. + * + * @param array $agentcat the agentcat configuration of the new server, which names `LOGGER` + * @param string $logFile the tracker's file of AgentCat's lines without a logger + */ + public static function reusedObjectId(array $agentcat, string $logFile): self + { + $harness = new self(null); + $logger = new MemoryLogger(); + $harness->loggers[] = $logger; + EventQueue::setGlobal($harness->queue); + $old = self::builder(); + $harness->configurator(['enable_tracing' => false] + $agentcat, $logger, $logFile)->configure($old); + $old->build(); + $id = spl_object_id($old); + $gone = \WeakReference::create($old); + unset($old); + gc_collect_cycles(); // AgentCat's handlers and the Builder hold each other + Assert::assertNull($gone->get(), 'the old Builder is freed'); + $held = ReusedId::hold($id); + + $builder = $held->reuse(self::builder(...)); + + $harness->configurator($agentcat, $logger, $logFile)->configure($builder); + $harness->server = new HttpHarness($builder->build()); + $harness->server->initialize(); + + return $harness; + } + + /** The `Mcp-Session-Id` of the handshake; null when fresh. */ + public function sessionId(): ?string + { + return $this->server?->sessionId(); + } + + #[\Override] + public function listTools(): \stdClass + { + return $this->server?->listTools() ?? HttpHarness::result($this->kernel()->modern('tools/list', [])); + } + + #[\Override] + public function callTool(string $name, array $arguments, array $meta = []): \stdClass + { + $server = $this->server ?? $this->kernel(); + if ($meta !== []) { + // modern() adds the protocol version and the client capabilities; the client info is in $meta, if anywhere. + return $server->modern('tools/call', ['name' => $name, 'arguments' => (object) $arguments, '_meta' => $meta], $name, null); + } + + return $this->server === null ? $server->modern('tools/call', HttpHarness::callParams($name, $arguments, null), $name) : $server->callTool($name, $arguments); + } + + #[\Override] + public function events(): array + { + Assert::assertTrue($this->queue->flush(), 'flush'); + + return $this->exporter->events; + } + + #[\Override] + public function received(string $tool): array + { + return ToolCalls::of($tool); + } + + #[\Override] + public function lines(): array + { + return array_merge(...array_map(static fn(MemoryLogger $logger): array => $logger->messages(), $this->loggers)); + } + + /** Boots a kernel, whose tracker will take this harness's queue; its route of the `conformance` server. */ + private function kernel(): SymfonyHttp + { + if (!$this->boot instanceof \Closure) { + throw new \LogicException('This harness boots no kernel'); + } + EventQueue::setGlobal($this->queue); + $kernel = ($this->boot)(); + $logger = $kernel->getContainer()->get(self::LOGGER); + Assert::assertInstanceOf(MemoryLogger::class, $logger); + $this->loggers[] = $logger; + + return new SymfonyHttp($kernel); + } + + /** + * The bundle's configurator of a `conformance` builder, with a tracker of its own, as one container has. + * + * @param array $agentcat + */ + private function configurator(array $agentcat, MemoryLogger $logger, string $logFile): BuilderConfigurator + { + $services = new ServiceLocator([self::LOGGER => static fn(): MemoryLogger => $logger]); + + return new BuilderConfigurator('conformance', new ServerTracker(static fn(): \ArrayObject => new \ArrayObject($agentcat), $services, null, $logFile, ['conformance']), []); + } + + /** A Builder of `conformance-server` 2.0.0 with the tools of `TestKernel::TOOLS`, which it creates first. */ + private static function builder(): Builder + { + $builder = Server::builder()->setServerInfo('conformance-server', '2.0.0'); + foreach (TestKernel::TOOLS as $name => $class) { + $builder->addTool([$class, '__invoke'], $name); + } + + return $builder; + } +} diff --git a/tests/Support/Symfony/SymfonyTestCase.php b/tests/Support/Symfony/SymfonyTestCase.php index 16ea606..5dd969e 100644 --- a/tests/Support/Symfony/SymfonyTestCase.php +++ b/tests/Support/Symfony/SymfonyTestCase.php @@ -131,13 +131,17 @@ protected function tearDown(): void /** * Boots a kernel with its own directory, so its container is compiled for this configuration. * - * @param array $agentcat the agentcat configuration - * @param array $services more services: id => class name or instance (synthetic) - * @param array|null $mcp the mcp configuration; null: the `conformance` server only + * @param array $agentcat the agentcat configuration + * @param array $services more services: id => class name or instance (synthetic) + * @param array|null $mcp the mcp configuration; null: the `conformance` server only + * @param string|null $directory the kernel's directory; null: a new one of this test. A kernel + * loads the container that an earlier kernel of its directory + * compiled, as each request of PHP-FPM does. A test that passes a + * directory gives each configuration its own and removes them */ - protected function kernel(array $agentcat = [], array $services = [], bool $withAgentCat = true, ?CompilerPassInterface $probe = null, ?array $mcp = null): TestKernel + protected function kernel(array $agentcat = [], array $services = [], bool $withAgentCat = true, ?CompilerPassInterface $probe = null, ?array $mcp = null, ?string $directory = null): TestKernel { - $kernel = new TestKernel($this->directory . '/kernel-' . count($this->kernels), $agentcat, $services, $mcp ?? TestKernel::MCP, $withAgentCat, $probe); + $kernel = new TestKernel($directory ?? $this->directory . '/kernel-' . count($this->kernels), $agentcat, $services, $mcp ?? TestKernel::MCP, $withAgentCat, $probe); $this->kernels[] = $kernel; $handler = $this->exceptionHandler(); $kernel->boot(); diff --git a/tests/Support/Symfony/TestKernel.php b/tests/Support/Symfony/TestKernel.php index d846f37..2653e83 100644 --- a/tests/Support/Symfony/TestKernel.php +++ b/tests/Support/Symfony/TestKernel.php @@ -19,10 +19,11 @@ /** * A Symfony application with symfony/mcp-bundle and one MCP server, `conformance` (`conformance-server` 2.0.0, HTTP on - * `/mcp/conformance`), which exposes the tools under `Tools/`. Its cache and log directories live in the directory it - * is given (`SymfonyTestCase` gives each kernel one under the test HOME). The `logger` service is a `MemoryLogger`, so - * nothing reaches `error_log()`; a test that passes its own `logger` replaces it. Every `mcp.server.*` service is - * public, so a test reads the servers and builders from the container. + * `/mcp/conformance`), which exposes the tools under `Tools/` in the order of the conformance suite: `echo`, + * `structured`, `refuses`, `boom`, `own`. Its cache and log directories live in the directory it is given + * (`SymfonyTestCase` gives each kernel one under the test HOME). The `logger` service is a `MemoryLogger`, so nothing + * reaches `error_log()`; a test that passes its own `logger` replaces it. Every `mcp.server.*` service is public, so a + * test reads the servers and builders from the container. */ final class TestKernel extends Kernel { @@ -31,6 +32,9 @@ final class TestKernel extends Kernel /** The `mcp.servers` entry of the `conformance` server. */ public const array CONFORMANCE = ['name' => 'conformance-server', 'version' => '2.0.0', 'registry' => ['AgentCat\Tests\Support\Symfony\Tools\\'], 'http' => ['path' => '/mcp/conformance']]; + /** The tools of `Tools/` by name, in the conformance suite's order; a tool not named here comes after them. */ + public const array TOOLS = ['echo' => Tools\EchoTool::class, 'structured' => Tools\StructuredTool::class, 'refuses' => Tools\RefusesTool::class, 'boom' => Tools\BoomTool::class, 'own' => Tools\OwnTool::class]; + /** The default mcp configuration: the `conformance` server only. */ public const array MCP = ['servers' => ['conformance' => self::CONFORMANCE]]; @@ -46,6 +50,12 @@ final class TestKernel extends Kernel */ public const array STREAMING = ['servers' => ['conformance' => ['registry' => [...self::CONFORMANCE['registry'], 'AgentCat\Tests\Support\Symfony\Streaming\\']] + self::CONFORMANCE]]; + /** + * One full page: the `conformance` server with only `echo` and `FullPage\FullPageTools` (`t01` to `t14`), and a + * page size of 15, laravel/mcp's default page. The kernel needs the tools class as a service. + */ + public const array FULL_PAGE = ['servers' => ['conformance' => ['registry' => [Tools\EchoTool::class, FullPage\FullPageTools::class], 'pagination_limit' => 15] + self::CONFORMANCE]]; + /** * @param string $directory the directory of the cache and log directories * @param array $agentcat the agentcat configuration @@ -142,6 +152,9 @@ protected function configureContainer(ContainerConfigurator $container): void $container->extension('agentcat', $this->agentcat); } $services = $container->services()->defaults()->autowire()->autoconfigure(); + foreach (self::TOOLS as $tool) { + $services->set($tool); // the bundle lists a server's tools in the order of their services + } $services->load('AgentCat\Tests\Support\Symfony\Tools\\', __DIR__ . '/Tools'); $services->set('logger', MemoryLogger::class)->public(); foreach ($this->services as $id => $service) { From bec134862f0b1b6d40e172383a89d9fbbde5fda4 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 22:00:34 +0000 Subject: [PATCH 28/55] feat: add the Symfony Flex recipe for AgentCatBundle --- .gitattributes | 1 + .../sdk/1.0/config/packages/agentcat.yaml | 7 +++ recipe/agentcat/sdk/1.0/manifest.json | 11 ++++ recipe/agentcat/sdk/1.0/post-install.txt | 2 + tests/Unit/Symfony/RecipeTest.php | 55 +++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml create mode 100644 recipe/agentcat/sdk/1.0/manifest.json create mode 100644 recipe/agentcat/sdk/1.0/post-install.txt create mode 100644 tests/Unit/Symfony/RecipeTest.php diff --git a/.gitattributes b/.gitattributes index e244b02..a5a3a9e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,4 +10,5 @@ /phpunit.xml.dist export-ignore /infection.json5 export-ignore /docs export-ignore +/recipe export-ignore /composer-dependency-analyser.php export-ignore diff --git a/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml new file mode 100644 index 0000000..286136c --- /dev/null +++ b/recipe/agentcat/sdk/1.0/config/packages/agentcat.yaml @@ -0,0 +1,7 @@ +# AgentCat tracks every symfony/mcp-bundle server of this application. +# Set AGENTCAT_PROJECT_ID (proj_…) in .env.local. Reference: bin/console config:dump-reference agentcat +agentcat: + project_id: '%env(default::AGENTCAT_PROJECT_ID)%' + # exclude_servers: [internal] + # projects: + # billing: '%env(AGENTCAT_BILLING_PROJECT_ID)%' diff --git a/recipe/agentcat/sdk/1.0/manifest.json b/recipe/agentcat/sdk/1.0/manifest.json new file mode 100644 index 0000000..915e872 --- /dev/null +++ b/recipe/agentcat/sdk/1.0/manifest.json @@ -0,0 +1,11 @@ +{ + "bundles": { + "AgentCat\\Symfony\\AgentCatBundle": ["all"] + }, + "copy-from-recipe": { + "config/": "%CONFIG_DIR%/" + }, + "env": { + "AGENTCAT_PROJECT_ID": "" + } +} diff --git a/recipe/agentcat/sdk/1.0/post-install.txt b/recipe/agentcat/sdk/1.0/post-install.txt new file mode 100644 index 0000000..d7f8261 --- /dev/null +++ b/recipe/agentcat/sdk/1.0/post-install.txt @@ -0,0 +1,2 @@ + * Set AGENTCAT_PROJECT_ID in .env.local to your AgentCat project ID (proj_…). + * AgentCat is off while your tests run; call AgentCat::fake() in a test to record its events. diff --git a/tests/Unit/Symfony/RecipeTest.php b/tests/Unit/Symfony/RecipeTest.php new file mode 100644 index 0000000..4e2b15b --- /dev/null +++ b/tests/Unit/Symfony/RecipeTest.php @@ -0,0 +1,55 @@ +assertEquals((object) [\AgentCat\Symfony\AgentCatBundle::class => ['all']], JsonAssert::at($manifest, 'bundles')); + $implements = class_implements(AgentCatBundle::class); + $this->assertIsArray($implements); + $this->assertContains(BundleInterface::class, $implements); + $this->assertEquals((object) ['config/' => '%CONFIG_DIR%/'], JsonAssert::at($manifest, 'copy-from-recipe')); + $this->assertEquals((object) ['AGENTCAT_PROJECT_ID' => ''], JsonAssert::at($manifest, 'env')); + } + + public function testTheCopiedConfigIsValidForTheBundle(): void + { + $yaml = Yaml::parseFile(self::RECIPE . '/config/packages/agentcat.yaml'); + $this->assertIsArray($yaml); + $extension = (new AgentCatBundle())->getContainerExtension(); + $this->assertInstanceOf(ConfigurationExtensionInterface::class, $extension); + $configuration = $extension->getConfiguration([], new ContainerBuilder()); + $this->assertInstanceOf(ConfigurationInterface::class, $configuration); + + $config = (new Processor())->processConfiguration($configuration, [$yaml['agentcat'] ?? []]); + + $this->assertArrayHasKey('project_id', $config); + $this->assertArrayHasKey('exclude_servers', $config); + $this->assertSame('%env(default::AGENTCAT_PROJECT_ID)%', $config['project_id']); + $this->assertSame([], $config['exclude_servers']); + } + + public function testTheRecipeIsNotShipped(): void + { + $this->assertStringContainsString("/recipe export-ignore\n", (string) file_get_contents(__DIR__ . '/../../../.gitattributes')); + } +} From f5248080f6a8cb3190cb57ebf92c69f2c64779aa Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 22:04:16 +0000 Subject: [PATCH 29/55] test: make the recipe test fail when the recipe YAML or the export-ignore line breaks --- tests/Unit/Symfony/RecipeTest.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/Unit/Symfony/RecipeTest.php b/tests/Unit/Symfony/RecipeTest.php index 4e2b15b..9d85cf8 100644 --- a/tests/Unit/Symfony/RecipeTest.php +++ b/tests/Unit/Symfony/RecipeTest.php @@ -23,7 +23,7 @@ public function testTheManifestRegistersTheBundleCopiesTheConfigAndAddsTheProjec { $manifest = Json::decode((string) file_get_contents(self::RECIPE . '/manifest.json')); - $this->assertEquals((object) [\AgentCat\Symfony\AgentCatBundle::class => ['all']], JsonAssert::at($manifest, 'bundles')); + $this->assertEquals((object) [AgentCatBundle::class => ['all']], JsonAssert::at($manifest, 'bundles')); $implements = class_implements(AgentCatBundle::class); $this->assertIsArray($implements); $this->assertContains(BundleInterface::class, $implements); @@ -35,12 +35,16 @@ public function testTheCopiedConfigIsValidForTheBundle(): void { $yaml = Yaml::parseFile(self::RECIPE . '/config/packages/agentcat.yaml'); $this->assertIsArray($yaml); + $this->assertArrayHasKey('agentcat', $yaml); + $this->assertIsArray($yaml['agentcat']); + $this->assertArrayHasKey('project_id', $yaml['agentcat']); + $this->assertSame('%env(default::AGENTCAT_PROJECT_ID)%', $yaml['agentcat']['project_id']); $extension = (new AgentCatBundle())->getContainerExtension(); $this->assertInstanceOf(ConfigurationExtensionInterface::class, $extension); $configuration = $extension->getConfiguration([], new ContainerBuilder()); $this->assertInstanceOf(ConfigurationInterface::class, $configuration); - $config = (new Processor())->processConfiguration($configuration, [$yaml['agentcat'] ?? []]); + $config = (new Processor())->processConfiguration($configuration, [$yaml['agentcat']]); $this->assertArrayHasKey('project_id', $config); $this->assertArrayHasKey('exclude_servers', $config); @@ -50,6 +54,8 @@ public function testTheCopiedConfigIsValidForTheBundle(): void public function testTheRecipeIsNotShipped(): void { - $this->assertStringContainsString("/recipe export-ignore\n", (string) file_get_contents(__DIR__ . '/../../../.gitattributes')); + $lines = file(__DIR__ . '/../../../.gitattributes', FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + $this->assertContains('/recipe export-ignore', array_map(trim(...), $lines)); } } From 2edb59777ad357329fdc5479f4bf5ce7f954816d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 22:33:03 +0000 Subject: [PATCH 30/55] test: add a Docker harness for runtime delivery tests --- .github/workflows/ci.yml | 25 +- phpunit.xml.dist | 4 + tests/Runtime/Fixtures/smoke.php | 15 + tests/Runtime/SmokeTest.php | 38 +++ tests/Runtime/Support/DockerRuntime.php | 335 ++++++++++++++++++++++ tests/Runtime/Support/FastCgiClient.php | 232 +++++++++++++++ tests/Runtime/Support/Images.php | 27 ++ tests/Runtime/Support/RuntimeTestCase.php | 184 ++++++++++++ tests/Runtime/docker/Dockerfile | 3 + 9 files changed, 862 insertions(+), 1 deletion(-) create mode 100644 tests/Runtime/Fixtures/smoke.php create mode 100644 tests/Runtime/SmokeTest.php create mode 100644 tests/Runtime/Support/DockerRuntime.php create mode 100644 tests/Runtime/Support/FastCgiClient.php create mode 100644 tests/Runtime/Support/Images.php create mode 100644 tests/Runtime/Support/RuntimeTestCase.php create mode 100644 tests/Runtime/docker/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9792f..111e5f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,10 +153,33 @@ jobs: - run: vendor/bin/phpstan analyse --no-progress --error-format=github --configuration=phpstan.no-bundle.neon - run: vendor/bin/phpunit --testsuite Contract,Integration,E2E + runtime: + name: Runtime delivery (Docker, PHP 8.3) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.3' + coverage: none + - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + with: + dependency-versions: highest + - name: Build the CLI image and pull the others + run: | + docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker + for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm phpswoole/swoole:6.2.3-php8.3 unit:1.34.2-php8.3; do docker pull "$image"; done + - run: vendor/bin/phpunit --testsuite Runtime --group runtime + env: + AGENTCAT_RUNTIME_TESTS: '1' + all-green: name: All green if: always() - needs: [static, tests, coverage, mutation, mcp-sdk-main] + needs: [static, tests, coverage, mutation, mcp-sdk-main, runtime] runs-on: ubuntu-latest steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 38cd911..4cd36c8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -26,6 +26,9 @@ tests/E2E + + tests/Runtime + tests/Architecture @@ -35,6 +38,7 @@ network + runtime diff --git a/tests/Runtime/Fixtures/smoke.php b/tests/Runtime/Fixtures/smoke.php new file mode 100644 index 0000000..b19871c --- /dev/null +++ b/tests/Runtime/Fixtures/smoke.php @@ -0,0 +1,15 @@ + */ + public static function images(): iterable + { + yield 'fpm' => [Images::FPM]; + yield 'apache' => [Images::APACHE]; + yield 'cli' => [Images::CLI]; + yield 'frankenphp' => [Images::FRANKENPHP]; + yield 'swoole' => [Images::SWOOLE]; + yield 'unit' => [Images::UNIT]; + } + + #[DataProvider('images')] + #[MaximumDuration(60000)] + public function testTheImageRunsTheRepositorysPhp(string $image): void + { + $run = DockerRuntime::run($image, ['php', self::repo() . '/tests/Runtime/Fixtures/smoke.php'], $this->env(), []); + + $this->assertSame(0, $run['exitCode'], $run['stderr']); + $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout']); + } +} diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php new file mode 100644 index 0000000..8928947 --- /dev/null +++ b/tests/Runtime/Support/DockerRuntime.php @@ -0,0 +1,335 @@ +-` and runs with `--rm`; `removeAll()` removes the ones that outlive their docker client. + * Every docker command runs with a timeout. + */ +final class DockerRuntime +{ + /** How long one docker command may take: the docker client, not the work of a detached container. */ + private const float DOCKER_SECONDS = 30.0; + + /** How long a container of `run()` may run. */ + private const float RUN_SECONDS = 60.0; + + /** The longest timeout a caller may give. */ + private const float MAX_SECONDS = 600.0; + + /** How long one connection attempt to the port of a starting container waits. */ + private const float CONNECT_SECONDS = 0.5; + + /** How often a start checks that the container still runs. */ + private const float RUNNING_CHECK_SECONDS = 1.0; + + private const int POLL_MICROSECONDS = 50_000; + + private const string FAIL_CLOSED_PREFIX = 'http://127.0.0.1:'; + + private static ?bool $available = null; + + private function __construct(private readonly string $name) {} + + /** `AGENTCAT_RUNTIME_TESTS=1` and `docker info` exits 0 (checked once per process). */ + public static function available(): bool + { + if (getenv('AGENTCAT_RUNTIME_TESTS') !== '1') { + return false; + } + + try { + return self::$available ??= self::docker(['info'])->isSuccessful(); + } catch (\RuntimeException) { + return self::$available = false; // the docker client is missing or timed out + } + } + + /** The repository's absolute path, which every container mounts read-only at the same path. */ + public static function repo(): string + { + return dirname(__DIR__, 3); + } + + /** + * Starts a detached container and waits until `$port` accepts TCP connections on 127.0.0.1. + * + * @param list $command the command, after the image's entrypoint + * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own + * @param array $mounts host path (under the test HOME) => container path + * @param float $timeout how long the port may take to accept a connection + * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) + */ + public static function start( + string $image, + array $command, + array $env, + array $mounts, + ?int $port = null, + bool $init = false, + float $timeout = 30.0, + bool $remove = true, + ): self { + $timeout = self::seconds($timeout); + $runtime = new self(self::newName()); + $run = self::docker(['run', '--detach', ...self::options($runtime->name, $env, $mounts, $init, $remove), $image, ...$command]); + if (!$run->isSuccessful()) { + throw new \RuntimeException(sprintf('docker run %s failed: %s', $image, $run->getErrorOutput())); + } + if ($port !== null) { + $runtime->waitForPort($port, $timeout); + } + + return $runtime; + } + + /** + * Runs a container to its end (with `--init`) and returns what it wrote. + * + * @param list $command + * @param array $env + * @param array $mounts host path (under the test HOME) => container path + * + * @return array{exitCode: int, stdout: string, stderr: string} + */ + public static function run(string $image, array $command, array $env, array $mounts): array + { + $run = self::docker(['run', ...self::options(self::newName(), $env, $mounts, true, true), $image, ...$command], self::RUN_SECONDS); + + return ['exitCode' => $run->getExitCode() ?? -1, 'stdout' => $run->getOutput(), 'stderr' => $run->getErrorOutput()]; + } + + /** + * The `docker run --interactive` line of a container whose stdin and stdout the caller drives (for + * `StdioMcpClient::command()`); the caller's process gives it its timeout. + * + * @param list $command + * @param array $env + * @param array $mounts host path (under the test HOME) => container path + * + * @return list + */ + public static function command(string $image, array $command, array $env, array $mounts, bool $init = true): array + { + return ['docker', 'run', '--interactive', ...self::options(self::newName(), $env, $mounts, $init, true), $image, ...$command]; + } + + /** + * Removes every container of this process (`docker rm --force`), also one whose docker client was killed or timed + * out: `RuntimeTestCase` calls it after each test and after each class. + */ + public static function removeAll(): void + { + $list = self::docker(['ps', '--all', '--quiet', '--filter', 'name=^/' . self::namePrefix()]); + if (!$list->isSuccessful()) { + throw new \RuntimeException('docker ps failed: ' . $list->getErrorOutput()); + } + $ids = preg_split('/\s+/', trim($list->getOutput()), -1, PREG_SPLIT_NO_EMPTY); + if ($ids === false || $ids === []) { + return; + } + $remove = self::docker(['rm', '--force', ...$ids]); + if (!$remove->isSuccessful() && !str_contains($remove->getErrorOutput(), 'No such container')) { + throw new \RuntimeException('docker rm failed: ' . $remove->getErrorOutput()); + } + } + + /** + * Makes everything under `$dir` writable for everyone, so the test can remove what a container wrote there as + * root or www-data (ruling P14). The container sees only `$dir`, at `/out`, and no network. + */ + public static function makeRemovable(string $dir): void + { + $name = self::newName(); + $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::underTestHome($dir), '/out'), Images::CLI, 'chmod', '-R', 'a+rwX', '/out']); + if (!$run->isSuccessful()) { + throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::CLI, $run->getErrorOutput())); + } + } + + /** What the container wrote to stdout and stderr so far. With `--rm`, read it before `stopGracefully()`. */ + public function logs(): string + { + $logs = self::docker(['logs', $this->name]); + if (!$logs->isSuccessful()) { + throw new \RuntimeException(sprintf('docker logs %s failed: %s', $this->name, $logs->getErrorOutput())); + } + + return $logs->getOutput() . $logs->getErrorOutput(); + } + + /** + * `docker stop -t`: SIGTERM, the runtime's own graceful stop, and SIGKILL after `$timeout` seconds. With `--rm` + * the container is gone afterwards: read `logs()` first. + */ + public function stopGracefully(float $timeout): void + { + $seconds = (int) ceil(self::seconds($timeout)); + $stop = self::docker(['stop', '-t', (string) $seconds, $this->name], $seconds + self::DOCKER_SECONDS); + if (!$stop->isSuccessful()) { + throw new \RuntimeException(sprintf('docker stop %s failed: %s', $this->name, $stop->getErrorOutput())); + } + } + + /** `docker rm --force`; a container that is already gone is fine. */ + public function stop(): void + { + $remove = self::docker(['rm', '--force', $this->name]); + if (!$remove->isSuccessful() && !str_contains($remove->getErrorOutput(), 'No such container')) { + throw new \RuntimeException(sprintf('docker rm %s failed: %s', $this->name, $remove->getErrorOutput())); + } + } + + private function waitForPort(int $port, float $timeout): void + { + $deadline = microtime(true) + $timeout; + $nextRunningCheck = microtime(true) + self::RUNNING_CHECK_SECONDS; + while (!$this->accepts($port)) { + if (microtime(true) >= $nextRunningCheck) { + if (!$this->running()) { + throw new \RuntimeException(sprintf('Container %s ended before port %d accepted a connection: %s', $this->name, $port, $this->logsOrWhy())); + } + $nextRunningCheck = microtime(true) + self::RUNNING_CHECK_SECONDS; + } + if (microtime(true) >= $deadline) { + throw new \RuntimeException(sprintf('Port %d of container %s accepted no connection within %.1f s: %s', $port, $this->name, $timeout, $this->logsOrWhy())); + } + usleep(self::POLL_MICROSECONDS); + } + } + + private function accepts(int $port): bool + { + set_error_handler(static fn(): bool => true); // connection refused while the runtime boots + + try { + $socket = stream_socket_client('tcp://127.0.0.1:' . $port, $errorCode, $errorMessage, self::CONNECT_SECONDS); + } finally { + restore_error_handler(); + } + if ($socket === false) { + return false; + } + fclose($socket); + + return true; + } + + private function running(): bool + { + $inspect = self::docker(['inspect', '--format', '{{.State.Running}}', $this->name]); + + return $inspect->isSuccessful() && trim($inspect->getOutput()) === 'true'; + } + + private function logsOrWhy(): string + { + try { + return $this->logs(); + } catch (\RuntimeException $e) { + return $e->getMessage() . ' (a container with --rm is removed when it ends: start it with remove: false to keep its logs)'; + } + } + + /** + * The options every container gets. + * + * @param array $env + * @param array $mounts + * + * @return list + */ + private static function options(string $name, array $env, array $mounts, bool $init, bool $remove): array + { + foreach (['AGENTCAT_API_URL', 'AGENTCAT_DIAGNOSTICS_ENDPOINT'] as $required) { + if (!str_starts_with($env[$required] ?? '', self::FAIL_CLOSED_PREFIX)) { + throw new \InvalidArgumentException(sprintf('A container needs %s on 127.0.0.1, the collector of the test or a closed port: pass RuntimeTestCase::env()', $required)); + } + } + $repo = self::repo(); + $options = ['--pull', 'never', '--name', $name, '--network', 'host', '--mount', self::bind($repo, $repo, readonly: true), '--workdir', $repo]; + if ($remove) { + $options[] = '--rm'; + } + if ($init) { + $options[] = '--init'; + } + foreach ($mounts as $host => $container) { + $options[] = '--mount'; + $options[] = self::bind(self::underTestHome($host), $container); + } + foreach ($env as $variable => $value) { + $options[] = '--env'; + $options[] = $variable . '=' . $value; + } + + return $options; + } + + /** A `--mount` value: an existing host path, never created by docker. */ + private static function bind(string $host, string $container, bool $readonly = false): string + { + foreach ([$host, $container] as $path) { + if (!str_starts_with($path, '/') || str_contains($path, ',')) { + throw new \InvalidArgumentException('Not an absolute path without commas: ' . $path); + } + } + + return sprintf('type=bind,source=%s,target=%s%s', $host, $container, $readonly ? ',readonly' : ''); + } + + /** The real path of `$host`, which must exist under the test HOME: the only host paths a container may write. */ + private static function underTestHome(string $host): string + { + $home = getenv('HOME'); + $realHome = is_string($home) ? realpath($home) : false; + $real = realpath($host); + if ($realHome === false || $real === false || !str_starts_with($real, $realHome . '/')) { + throw new \InvalidArgumentException(sprintf('A mount source must exist under the test HOME (%s): %s', var_export($home, true), $host)); + } + + return $real; + } + + /** @param list $arguments */ + private static function docker(array $arguments, float $timeout = self::DOCKER_SECONDS): Process + { + $process = new Process(['docker', ...$arguments], null, null, null, $timeout); + $process->run(); + + return $process; + } + + private static function newName(): string + { + return self::namePrefix() . bin2hex(random_bytes(4)); + } + + private static function namePrefix(): string + { + $pid = getmypid(); + + return 'agentcat-rt-' . ($pid === false ? 0 : $pid) . '-'; + } + + /** A timeout the docker commands and the deadlines can use: finite, positive and at most `MAX_SECONDS`. */ + private static function seconds(float $seconds): float + { + if (!is_finite($seconds) || $seconds <= 0.0 || $seconds > self::MAX_SECONDS) { + throw new \InvalidArgumentException(sprintf('A timeout must be above 0 and at most %d s: %s', (int) self::MAX_SECONDS, var_export($seconds, true))); + } + + return $seconds; + } +} diff --git a/tests/Runtime/Support/FastCgiClient.php b/tests/Runtime/Support/FastCgiClient.php new file mode 100644 index 0000000..5bf1b60 --- /dev/null +++ b/tests/Runtime/Support/FastCgiClient.php @@ -0,0 +1,232 @@ + $headers request headers, sent as `HTTP_*` parameters (`Content-Type` as `CONTENT_TYPE`) + * + * @return array{status: int, headers: array, body: string, endedAt: float} lower-case header names; + * `endedAt`: when `FCGI_END_REQUEST` arrived + */ + public function request(string $script, string $method, string $uri, array $headers, string $body, float $timeout = 10.0): array + { + if (!is_finite($timeout) || $timeout <= 0.0 || $timeout > self::MAX_SECONDS) { + throw new \InvalidArgumentException(sprintf('A FastCGI timeout must be above 0 and at most %d s: %s', (int) self::MAX_SECONDS, var_export($timeout, true))); + } + $deadline = microtime(true) + $timeout; + set_error_handler(static function (int $severity, string $message): never { + throw new \RuntimeException('FastCGI: ' . $message); + }); + + try { + $socket = stream_socket_client(sprintf('tcp://%s:%d', $this->host, $this->port), $code, $message, $timeout); + if ($socket === false) { + throw new \RuntimeException(sprintf('FastCGI: cannot connect to %s:%d: %s', $this->host, $this->port, $message)); + } + + try { + $this->write($socket, $this->records($script, $method, $uri, $headers, $body), $deadline); + [$stdout, $endedAt] = $this->readAnswer($socket, $deadline); + } finally { + fclose($socket); + } + } finally { + restore_error_handler(); + } + + return $this->answer($stdout, $endedAt); + } + + /** + * The request's records: `FCGI_BEGIN_REQUEST`, the parameters and the body, each stream ended by an empty record. + * + * @param array $headers + */ + private function records(string $script, string $method, string $uri, array $headers, string $body): string + { + $path = strstr($uri, '?', true); + $params = [ + 'GATEWAY_INTERFACE' => 'CGI/1.1', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'SERVER_NAME' => '127.0.0.1', + 'SERVER_PORT' => '80', + 'REMOTE_ADDR' => '127.0.0.1', + 'REQUEST_METHOD' => $method, + 'REQUEST_URI' => $uri, + 'SCRIPT_NAME' => $path === false ? $uri : $path, + 'QUERY_STRING' => $path === false ? '' : substr($uri, strlen($path) + 1), + 'SCRIPT_FILENAME' => $script, + 'CONTENT_TYPE' => 'application/json', + 'CONTENT_LENGTH' => (string) strlen($body), + ]; + foreach ($headers as $name => $value) { + $key = strtoupper(str_replace('-', '_', $name)); + if ($key === 'CONTENT_TYPE') { + $params['CONTENT_TYPE'] = $value; // nginx sends it without the HTTP_ prefix + } elseif ($key !== 'CONTENT_LENGTH') { + $params['HTTP_' . $key] = $value; + } + } + // PHP-FPM parses the pairs of each record on their own: a pair never spans two records. + $records = ''; + $pairs = ''; + foreach ($params as $name => $value) { + $pair = $this->length(strlen($name)) . $this->length(strlen($value)) . $name . $value; + if (strlen($pair) > self::MAX_CONTENT) { + throw new \InvalidArgumentException(sprintf('FastCGI: the parameter %s does not fit in one record', $name)); + } + if (strlen($pairs) + strlen($pair) > self::MAX_CONTENT) { + $records .= $this->record(self::PARAMS, $pairs); + $pairs = ''; + } + $pairs .= $pair; + } + $records .= $this->record(self::PARAMS, $pairs) . $this->record(self::PARAMS, ''); + for ($offset = 0; $offset < strlen($body); $offset += self::MAX_CONTENT) { + $records .= $this->record(self::STDIN, substr($body, $offset, self::MAX_CONTENT)); + } + + return $this->record(self::BEGIN_REQUEST, pack('nCx5', self::RESPONDER, 0)) // flags 0: FPM closes the connection + . $records + . $this->record(self::STDIN, ''); + } + + private function record(int $type, string $content): string + { + return pack('CCnnCx', self::VERSION, $type, self::REQUEST_ID, strlen($content), 0) . $content; + } + + /** @param int<0, max> $length */ + private function length(int $length): string + { + return $length < 128 ? chr($length) : pack('N', $length | 0x80000000); + } + + /** + * Reads records up to `FCGI_END_REQUEST`; keeps `FCGI_STDOUT` and drops the others (`FCGI_STDERR`). + * + * @param resource $socket + * + * @return array{string, float} stdout, and when `FCGI_END_REQUEST` arrived + */ + private function readAnswer(mixed $socket, float $deadline): array + { + $stdout = ''; + while (true) { + $header = unpack('Cversion/Ctype/nid/nlength/Cpadding', $this->read($socket, self::HEADER_BYTES, $deadline)); + $type = is_array($header) ? $header['type'] ?? null : null; + $length = is_array($header) ? $header['length'] ?? null : null; + $padding = is_array($header) ? $header['padding'] ?? null : null; + if (!is_int($type) || !is_int($length) || !is_int($padding)) { + throw new \RuntimeException('FastCGI: a bad record header'); + } + $content = $this->read($socket, $length, $deadline); + $this->read($socket, $padding, $deadline); + if ($type === self::END_REQUEST) { + return [$stdout, microtime(true)]; + } + if ($type === self::STDOUT) { + $stdout .= $content; + } + } + } + + /** @return array{status: int, headers: array, body: string, endedAt: float} */ + private function answer(string $stdout, float $endedAt): array + { + [$head, $body] = explode("\r\n\r\n", $stdout, 2) + ['', '']; + $headers = []; + foreach (explode("\r\n", $head) as $line) { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $name = strtolower(trim($parts[0])); + $value = trim($parts[1]); + $headers[$name] = isset($headers[$name]) ? $headers[$name] . ', ' . $value : $value; + } + } + $status = preg_match('/\A(\d{3})\b/', $headers['status'] ?? '200', $match) === 1 ? (int) $match[1] : 0; + + return ['status' => $status, 'headers' => $headers, 'body' => $body, 'endedAt' => $endedAt]; + } + + /** @param resource $socket */ + private function write(mixed $socket, string $data, float $deadline): void + { + while ($data !== '') { + $this->waitFor($socket, $deadline, strlen($data) . ' bytes unsent'); + $written = fwrite($socket, $data); + if ($written === false) { + throw new \RuntimeException('FastCGI: the write failed'); + } + $data = substr($data, $written); + } + } + + /** @param resource $socket */ + private function read(mixed $socket, int $bytes, float $deadline): string + { + $data = ''; + while (($missing = $bytes - strlen($data)) > 0) { + $this->waitFor($socket, $deadline, sprintf('%d of %d bytes read', strlen($data), $bytes)); + $chunk = fread($socket, $missing); + if ($chunk === false || ($chunk === '' && feof($socket))) { + throw new \RuntimeException(sprintf('FastCGI: the connection ended after %d of %d bytes', strlen($data), $bytes)); + } + $data .= $chunk; + } + + return $data; + } + + /** + * Gives the next socket operation the time left until the deadline. + * + * @param resource $socket + */ + private function waitFor(mixed $socket, float $deadline, string $progress): void + { + $left = $deadline - microtime(true); + if ($left <= 0.0) { + throw new \RuntimeException('FastCGI: no complete answer within the timeout (' . $progress . ')'); + } + $seconds = (int) $left; // finite and at most MAX_SECONDS: request() checked the timeout + stream_set_timeout($socket, $seconds, (int) (($left - $seconds) * 1_000_000)); + } +} diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php new file mode 100644 index 0000000..fb8d11a --- /dev/null +++ b/tests/Runtime/Support/Images.php @@ -0,0 +1,27 @@ +stop(); + } + } + + #[\Override] + protected function setUp(): void + { + if (!DockerRuntime::available()) { + self::markTestSkipped('The runtime tests need AGENTCAT_RUNTIME_TESTS=1 and a working docker info'); + } + } + + #[\Override] + protected function tearDown(): void + { + if (!DockerRuntime::available()) { + return; + } + + try { + DockerRuntime::removeAll(); + } finally { + $out = $this->out; + $this->out = null; + if ($out !== null) { + DockerRuntime::makeRemovable($out); // files of root and www-data that this user cannot remove + FileTree::remove($out); + } + } + } + + /** The repository's absolute path, which every container mounts read-only at the same path. */ + final protected static function repo(): string + { + return DockerRuntime::repo(); + } + + /** + * The test's output directory under the test HOME (mode 0777, with `sessions/` and the Laravel application's + * `app/` directories): mount it at `/out`, the only place a container writes. Removed after the test. + */ + final protected function out(): string + { + if ($this->out !== null) { + return $this->out; + } + $home = getenv('HOME'); + $this->assertIsString($home); + $out = $home . '/runtime-' . bin2hex(random_bytes(6)); + foreach (['', ...self::OUT_DIRECTORIES] as $directory) { + $path = $directory === '' ? $out : $out . '/' . $directory; + $this->assertTrue(mkdir($path, 0o777) && chmod($path, 0o777), 'Cannot create ' . $path); // chmod: the umask + } + + return $this->out = $out; + } + + /** A free local port: the port of a URL where nothing listens. */ + final protected function freePort(): int + { + $port = parse_url(MockCollector::closedPortUrl(), PHP_URL_PORT); + $this->assertIsInt($port); + + return $port; + } + + /** + * The environment of every container (ruling P15): AgentCat on, with `HOME=/out`; the API at `$collector`, or at a + * closed local port so that a fixture which forgets its collector fails closed; diagnostics off and at a closed + * port (a fixture turns them on for one request with `X-E2E-Diagnostics`); the fixtures' sessions, markers and + * Laravel application under `/out`. + * + * @return array + */ + final protected function env(?string $collector = null): array + { + $collector ??= MockCollector::closedPortUrl(); + + return [ + 'AGENTCAT_ENABLED' => 'true', + 'AGENTCAT_PROJECT_ID' => self::PROJECT_ID, + 'AGENTCAT_API_URL' => $collector, + 'AGENTCAT_E2E_COLLECTOR' => $collector, + 'HOME' => '/out', + 'AGENTCAT_DISABLE_DIAGNOSTICS' => 'true', + 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => MockCollector::closedPortUrl(), + 'AGENTCAT_E2E_SESSIONS' => '/out/sessions', + 'AGENTCAT_E2E_MARKERS' => '/out', + 'APP_BASE_PATH' => '/out/app', + ]; + } + + /** + * Waits until the collector's scenario has at least `$count` requests, and fails the test after `$timeout` seconds. + * + * @return list<\stdClass> every request of the scenario so far, in arrival order + */ + final protected function waitForRequests(string $scenario, int $count, float $timeout): array + { + $deadline = microtime(true) + $timeout; + $requests = self::$collector->requests($scenario); + while (count($requests) < $count && microtime(true) < $deadline) { + usleep(self::POLL_MICROSECONDS); + $requests = self::$collector->requests($scenario); + } + $this->assertGreaterThanOrEqual($count, count($requests), sprintf('%d of %d requests reached the collector within %.1f s', count($requests), $count, $timeout)); + + return $requests; + } + + /** Without the group, `--group runtime` (the runtime CI job) never runs the class. */ + private static function assertTheRuntimeGroup(): void + { + foreach ((new \ReflectionClass(static::class))->getAttributes(Group::class) as $attribute) { + if ($attribute->newInstance()->name() === self::GROUP) { + return; + } + } + self::fail(sprintf("%s must carry #[Group('%s')]: PHPUnit does not inherit it from %s", static::class, self::GROUP, self::class)); + } +} diff --git a/tests/Runtime/docker/Dockerfile b/tests/Runtime/docker/Dockerfile new file mode 100644 index 0000000..66cb64a --- /dev/null +++ b/tests/Runtime/docker/Dockerfile @@ -0,0 +1,3 @@ +# The CLI image of the runtime tests: the official image plus ext-pcntl for the signal drain (D39). +FROM php:8.3.35-cli-bookworm +RUN docker-php-ext-install pcntl From 8cfaa25869d158f4b202e8637325bb02aaffce9f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 22:56:46 +0000 Subject: [PATCH 31/55] test: build the Swoole image of the runtime tests with ext-pcntl --- .github/workflows/ci.yml | 5 +++-- tests/Runtime/SmokeTest.php | 13 +++++++++++++ tests/Runtime/Support/Images.php | 10 +++++++--- tests/Runtime/docker/swoole/Dockerfile | 3 +++ 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 tests/Runtime/docker/swoole/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 111e5f3..5d76c4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,10 +168,11 @@ jobs: - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 with: dependency-versions: highest - - name: Build the CLI image and pull the others + - name: Build the CLI and Swoole images and pull the others run: | docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker - for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm phpswoole/swoole:6.2.3-php8.3 unit:1.34.2-php8.3; do docker pull "$image"; done + docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole + for image in php:8.3.35-fpm-bookworm php:8.3.35-apache-bookworm dunglas/frankenphp:1.13.1-php8.3.35-bookworm unit:1.34.2-php8.3; do docker pull "$image"; done - run: vendor/bin/phpunit --testsuite Runtime --group runtime env: AGENTCAT_RUNTIME_TESTS: '1' diff --git a/tests/Runtime/SmokeTest.php b/tests/Runtime/SmokeTest.php index c50fa1a..00465cb 100644 --- a/tests/Runtime/SmokeTest.php +++ b/tests/Runtime/SmokeTest.php @@ -35,4 +35,17 @@ public function testTheImageRunsTheRepositorysPhp(string $image): void $this->assertSame(0, $run['exitCode'], $run['stderr']); $this->assertMatchesRegularExpression('/\A8\.3\.\d+ curl mbstring\z/', $run['stdout']); } + + /** The Swoole guard test needs ext-pcntl next to ext-swoole: `docker/swoole/Dockerfile` adds it to the image. */ + #[MaximumDuration(60000)] + public function testTheSwooleImageLoadsSwooleAndPcntl(): void + { + $run = DockerRuntime::run(Images::SWOOLE, ['php', '-m'], $this->env(), []); + + $this->assertSame(0, $run['exitCode'], $run['stderr']); + $modules = preg_split('/\R/', $run['stdout']); + $this->assertIsArray($modules); + $this->assertContains('swoole', $modules); + $this->assertContains('pcntl', $modules); + } } diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php index fb8d11a..8dc7e94 100644 --- a/tests/Runtime/Support/Images.php +++ b/tests/Runtime/Support/Images.php @@ -6,7 +6,7 @@ /** * The Docker images of the runtime tests, pinned by tag (header conflict 10). The CI job pulls them before the suite, - * and builds `CLI`; a test never pulls (`docker run --pull never`). + * and builds `CLI` and `SWOOLE`; a test never pulls (`docker run --pull never`). */ final class Images { @@ -19,8 +19,12 @@ final class Images public const string FRANKENPHP = 'dunglas/frankenphp:1.13.1-php8.3.35-bookworm'; - /** PHP 8.3.33, without ext-pcntl. Its entrypoint splits the command at spaces (`exec $@`): run a script, not `php -r`. */ - public const string SWOOLE = 'phpswoole/swoole:6.2.3-php8.3'; + /** + * Built locally: `docker build --tag agentcat-runtime-swoole:6.2.3-php8.3 tests/Runtime/docker/swoole` + * (`phpswoole/swoole:6.2.3-php8.3`, PHP 8.3.33, plus ext-pcntl). Its entrypoint splits the command at spaces + * (`exec $@`): run a script, not `php -r`. + */ + public const string SWOOLE = 'agentcat-runtime-swoole:6.2.3-php8.3'; /** NGINX Unit with PHP 8.3.26 (ruling P19). */ public const string UNIT = 'unit:1.34.2-php8.3'; diff --git a/tests/Runtime/docker/swoole/Dockerfile b/tests/Runtime/docker/swoole/Dockerfile new file mode 100644 index 0000000..2598c1c --- /dev/null +++ b/tests/Runtime/docker/swoole/Dockerfile @@ -0,0 +1,3 @@ +# The Swoole image of the runtime tests: phpswoole/swoole plus ext-pcntl, so the Swoole guard test has a signal handler to guard. +FROM phpswoole/swoole:6.2.3-php8.3 +RUN docker-php-ext-install pcntl From 48749ff3ac4043d04cc658f147aeb2d6c1c0b051 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 22:58:10 +0000 Subject: [PATCH 32/55] test: fail the runtime tests when Docker does not answer, and run every cleanup step after a failed one --- tests/Runtime/HarnessTest.php | 97 +++++++++++++++++++++++ tests/Runtime/Support/DockerRuntime.php | 35 ++++++-- tests/Runtime/Support/RuntimeTestCase.php | 80 ++++++++++++++----- 3 files changed, 183 insertions(+), 29 deletions(-) create mode 100644 tests/Runtime/HarnessTest.php diff --git a/tests/Runtime/HarnessTest.php b/tests/Runtime/HarnessTest.php new file mode 100644 index 0000000..c5e9657 --- /dev/null +++ b/tests/Runtime/HarnessTest.php @@ -0,0 +1,97 @@ +smokeRun(['AGENTCAT_RUNTIME_TESTS' => '1', 'DOCKER_HOST' => self::BROKEN_DOCKER_HOST]); + $off = $this->smokeRun(['AGENTCAT_RUNTIME_TESTS' => '0', 'DOCKER_HOST' => self::BROKEN_DOCKER_HOST]); + + $this->assertSame(1, $broken->getExitCode(), $broken->getOutput() . $broken->getErrorOutput()); + $this->assertStringContainsString('docker info failed', $broken->getOutput()); + $this->assertStringContainsString(self::BROKEN_DOCKER_HOST, $broken->getOutput()); + $this->assertStringNotContainsString('Skipped', $broken->getOutput()); + $this->assertSame(0, $off->getExitCode(), $off->getOutput() . $off->getErrorOutput()); + $this->assertMatchesRegularExpression('/Tests: (\d+), Assertions: 0, Skipped: \1\./', $off->getOutput()); + } + + /** The output directory goes even when the container sweep and the chmod container fail; the first error stays. */ + #[MaximumDuration(60000)] + public function testEveryCleanupStepAfterATestRunsWhenAnEarlierOneFails(): void + { + $out = $this->out(); + file_put_contents($out . '/written-by-the-test', 'x'); + + $error = $this->withBrokenDocker(fn(): \Throwable => Thrown::by(\RuntimeException::class, fn() => $this->tearDown())); + + $this->assertStringContainsString('docker ps failed', $error->getMessage()); + $this->assertDirectoryDoesNotExist($out); + } + + /** The collector stops even when the container sweep after the class fails. */ + #[MaximumDuration(60000)] + public function testEveryCleanupStepAfterTheClassRunsWhenAnEarlierOneFails(): void + { + $port = parse_url(self::$collector->scenario(), PHP_URL_PORT); + $this->assertIsInt($port); + + try { + $error = $this->withBrokenDocker(static fn(): \Throwable => Thrown::by(\RuntimeException::class, static fn() => self::tearDownAfterClass())); + $refused = @stream_socket_client('tcp://127.0.0.1:' . $port, $errorCode, $errorMessage, 1.0) === false; + } finally { + self::$collector = MockCollector::start(); // for the other tests of this class + } + + $this->assertStringContainsString('docker ps failed', $error->getMessage()); + $this->assertTrue($refused, 'the collector still listens'); + } + + /** + * Runs the smoke test in a new PHPUnit process. + * + * @param array $env + */ + private function smokeRun(array $env): Process + { + $process = new Process([PHP_BINARY, self::repo() . '/vendor/bin/phpunit', '--testsuite', 'Runtime', '--group', 'runtime', '--filter', 'SmokeTest', '--colors=never'], self::repo(), $env, null, 50.0); + $process->run(); + + return $process; + } + + /** + * Runs `$code` while every docker command of this process reaches a socket where no daemon listens. + * + * @param \Closure(): \Throwable $code + */ + private function withBrokenDocker(\Closure $code): \Throwable + { + $environment = Environment::clear('DOCKER_HOST'); + putenv('DOCKER_HOST=' . self::BROKEN_DOCKER_HOST); + $_SERVER['DOCKER_HOST'] = $_ENV['DOCKER_HOST'] = self::BROKEN_DOCKER_HOST; // Symfony Process reads these + + try { + return $code(); + } finally { + $environment->restore(); + } + } +} diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index 8928947..318b0bc 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -38,22 +38,41 @@ final class DockerRuntime private const string FAIL_CLOSED_PREFIX = 'http://127.0.0.1:'; - private static ?bool $available = null; + private static bool $checked = false; + + private static ?string $problem = null; private function __construct(private readonly string $name) {} - /** `AGENTCAT_RUNTIME_TESTS=1` and `docker info` exits 0 (checked once per process). */ - public static function available(): bool + /** `AGENTCAT_RUNTIME_TESTS=1`: the runtime tests run, and a Docker that does not answer fails them. */ + public static function requested(): bool { - if (getenv('AGENTCAT_RUNTIME_TESTS') !== '1') { - return false; + return getenv('AGENTCAT_RUNTIME_TESTS') === '1'; + } + + /** Why `docker info` failed or timed out, with `DOCKER_HOST`; null when it exits 0 (checked once per process). */ + public static function problem(): ?string + { + if (self::$checked) { + return self::$problem; } + self::$checked = true; try { - return self::$available ??= self::docker(['info'])->isSuccessful(); - } catch (\RuntimeException) { - return self::$available = false; // the docker client is missing or timed out + $info = self::docker(['info']); + $error = $info->isSuccessful() ? null : sprintf('exit %s: %s', var_export($info->getExitCode(), true), trim($info->getErrorOutput()) !== '' ? $info->getErrorOutput() : $info->getOutput()); + } catch (\RuntimeException $e) { + $error = $e->getMessage(); // the docker client is missing or timed out } + $host = getenv('DOCKER_HOST'); + + return self::$problem = $error === null ? null : sprintf('docker info failed (DOCKER_HOST=%s): %s', is_string($host) ? $host : 'unset', trim($error)); + } + + /** `requested()`, and `docker info` exits 0. */ + public static function available(): bool + { + return self::requested() && self::problem() === null; } /** The repository's absolute path, which every container mounts read-only at the same path. */ diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index 9aa3dad..afa58d1 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -10,8 +10,10 @@ use PHPUnit\Framework\TestCase; /** - * The base of the Docker runtime tests (header conflict 10): they run only with `AGENTCAT_RUNTIME_TESTS=1` and a working - * `docker info` (`AGENTCAT_RUNTIME_TESTS=1 vendor/bin/phpunit --testsuite Runtime --group runtime`), and skip otherwise. + * The base of the Docker runtime tests (header conflict 10): they run only with `AGENTCAT_RUNTIME_TESTS=1` + * (`AGENTCAT_RUNTIME_TESTS=1 vendor/bin/phpunit --testsuite Runtime --group runtime`) and skip otherwise. With the + * variable, a `docker info` that fails or times out fails every test with its output, so the runtime CI job cannot + * pass without running them. * * Each class has one mock collector on 127.0.0.1 (`self::$collector`). After each test and each class, every container * of this process is removed, also when the test failed (ruling P14). A test's output directory (`out()`, mounted at @@ -57,19 +59,29 @@ public static function setUpBeforeClass(): void #[\Override] public static function tearDownAfterClass(): void { - if (DockerRuntime::available()) { - DockerRuntime::removeAll(); // also a container whose docker client a test's timeout killed - } - if (isset(self::$collector)) { - self::$collector->stop(); - } + self::runEach([ + static function (): void { + if (DockerRuntime::available()) { + DockerRuntime::removeAll(); // also a container whose docker client a test's timeout killed + } + }, + static function (): void { + if (isset(self::$collector)) { + self::$collector->stop(); + } + }, + ]); } #[\Override] protected function setUp(): void { - if (!DockerRuntime::available()) { - self::markTestSkipped('The runtime tests need AGENTCAT_RUNTIME_TESTS=1 and a working docker info'); + if (!DockerRuntime::requested()) { + self::markTestSkipped('The runtime tests run with AGENTCAT_RUNTIME_TESTS=1'); + } + $problem = DockerRuntime::problem(); + if ($problem !== null) { + self::fail('AGENTCAT_RUNTIME_TESTS=1, but ' . $problem); } } @@ -79,17 +91,23 @@ protected function tearDown(): void if (!DockerRuntime::available()) { return; } - - try { - DockerRuntime::removeAll(); - } finally { - $out = $this->out; - $this->out = null; - if ($out !== null) { - DockerRuntime::makeRemovable($out); // files of root and www-data that this user cannot remove - FileTree::remove($out); - } - } + $out = $this->out; + $this->out = null; + self::runEach([ + static function (): void { + DockerRuntime::removeAll(); + }, + static function () use ($out): void { + if ($out !== null) { + DockerRuntime::makeRemovable($out); // files of root and www-data that this user cannot remove + } + }, + static function () use ($out): void { + if ($out !== null) { + FileTree::remove($out); + } + }, + ]); } /** The repository's absolute path, which every container mounts read-only at the same path. */ @@ -171,6 +189,26 @@ final protected function waitForRequests(string $scenario, int $count, float $ti return $requests; } + /** + * Runs every cleanup step, also after one throws, and then throws the first error. + * + * @param list<\Closure(): void> $steps + */ + private static function runEach(array $steps): void + { + $first = null; + foreach ($steps as $step) { + try { + $step(); + } catch (\Throwable $e) { + $first ??= $e; + } + } + if ($first instanceof \Throwable) { + throw $first; + } + } + /** Without the group, `--group runtime` (the runtime CI job) never runs the class. */ private static function assertTheRuntimeGroup(): void { From 89cd48d5f35dc3e57def16d098e2003a3d14b628 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:03:27 +0000 Subject: [PATCH 33/55] test: remove the runtime containers of dead test processes, and end every started container after its lifetime --- tests/Runtime/HarnessTest.php | 65 ++++++++++++ tests/Runtime/Support/DockerRuntime.php | 120 ++++++++++++++++++---- tests/Runtime/Support/RuntimeTestCase.php | 7 ++ 3 files changed, 172 insertions(+), 20 deletions(-) diff --git a/tests/Runtime/HarnessTest.php b/tests/Runtime/HarnessTest.php index c5e9657..f88eee3 100644 --- a/tests/Runtime/HarnessTest.php +++ b/tests/Runtime/HarnessTest.php @@ -5,6 +5,8 @@ namespace AgentCat\Tests\Runtime; use AgentCat\Tests\Integration\Support\MockCollector; +use AgentCat\Tests\Runtime\Support\DockerRuntime; +use AgentCat\Tests\Runtime\Support\Images; use AgentCat\Tests\Runtime\Support\RuntimeTestCase; use AgentCat\Tests\Support\Environment; use AgentCat\Tests\Support\Thrown; @@ -64,6 +66,46 @@ public function testEveryCleanupStepAfterTheClassRunsWhenAnEarlierOneFails(): vo $this->assertTrue($refused, 'the collector still listens'); } + /** A killed PHPUnit run leaves its containers: the next run removes them, and leaves those of live processes. */ + #[MaximumDuration(60000)] + public function testTheSweepRemovesTheContainersOfDeadTestProcessesOnly(): void + { + $pidMax = file_get_contents('/proc/sys/kernel/pid_max'); + $this->assertIsString($pidMax); + $dead = $this->detachedSleep('agentcat-rt-' . ((int) trim($pidMax) + 1) . '-' . bin2hex(random_bytes(4))); // no process has this PID + $live = $this->detachedSleep('agentcat-rt-1-' . bin2hex(random_bytes(4))); // PID 1 always runs + + try { + DockerRuntime::removeStale(); + $deadLeft = $this->containerIds($dead); + $liveLeft = $this->containerIds($live); + } finally { + (new Process(['docker', 'rm', '--force', $dead, $live], null, null, null, 30.0))->run(); + } + + $this->assertSame('', $deadLeft); + $this->assertNotSame('', $liveLeft); + } + + /** A container that a killed run leaves behind ends on its own after its lifetime. */ + #[MaximumDuration(60000)] + public function testAStartedContainerEndsOnItsOwnAfterItsLifetime(): void + { + $prefix = 'agentcat-rt-' . getmypid() . '-'; + $started = microtime(true); + + DockerRuntime::start(Images::CLI, ['php', '-r', 'sleep(120);'], $this->env(), [], lifetime: 2.0); + + $this->assertNotSame('', $this->containerIds($prefix)); + $deadline = $started + 20.0; + while ($this->containerIds($prefix) !== '' && microtime(true) < $deadline) { + usleep(100_000); + } + $lived = microtime(true) - $started; + $this->assertSame('', $this->containerIds($prefix), 'the container outlived its lifetime'); + $this->assertGreaterThan(1.5, $lived, sprintf('the container ended %.2f s after its start, before its lifetime', $lived)); + } + /** * Runs the smoke test in a new PHPUnit process. * @@ -77,6 +119,29 @@ private function smokeRun(array $env): Process return $process; } + /** Starts a container under `$name` that sleeps 60 s and is removed when it ends. */ + private function detachedSleep(string $name): string + { + $run = new Process(['docker', 'run', '--detach', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', Images::CLI, 'sleep', '60'], null, null, null, 30.0); + $run->run(); + $this->assertTrue($run->isSuccessful(), $run->getErrorOutput()); + + return $name; + } + + /** + * The IDs of the containers whose names start with `$name`. + * + * @phpstan-impure containers come and go + */ + private function containerIds(string $name): string + { + $list = new Process(['docker', 'ps', '--all', '--quiet', '--filter', 'name=^/' . $name], null, null, null, 30.0); + $list->mustRun(); + + return trim($list->getOutput()); + } + /** * Runs `$code` while every docker command of this process reaches a socket where no daemon listens. * diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index 318b0bc..be25b3e 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -14,7 +14,8 @@ * `AGENTCAT_DIAGNOSTICS_ENDPOINT` there (`RuntimeTestCase::env()`), so a fixture that forgets its collector fails * closed. The repository is mounted read-only at its own path and is the working directory; every other mount is an * existing path under the test HOME (`--mount` never creates a host path). Every container is named - * `agentcat-rt--` and runs with `--rm`; `removeAll()` removes the ones that outlive their docker client. + * `agentcat-rt--` and runs with `--rm`; `removeAll()` removes the ones that outlive their docker client, + * `removeStale()` those of a test process that died, and a started container ends on its own after its lifetime. * Every docker command runs with a timeout. */ final class DockerRuntime @@ -38,6 +39,17 @@ final class DockerRuntime private const string FAIL_CLOSED_PREFIX = 'http://127.0.0.1:'; + private const string NAME_PREFIX = 'agentcat-rt-'; + + /** + * Run by `sh -c` in front of a started container's entrypoint (`$1` is the lifetime): after the lifetime it sends + * SIGTERM to PID 1 (with `--init`, tini passes it on), and 10 s later SIGKILL to every other process. + */ + private const string LIFETIME_SCRIPT = '(sleep "$1"; kill -TERM 1; sleep 10; kill -KILL -1) >/dev/null 2>&1 & shift; exec "$@"'; + + /** @var array> the entrypoint of each image */ + private static array $entrypoints = []; + private static bool $checked = false; private static ?string $problem = null; @@ -82,13 +94,15 @@ public static function repo(): string } /** - * Starts a detached container and waits until `$port` accepts TCP connections on 127.0.0.1. + * Starts a detached container and waits until `$port` accepts TCP connections on 127.0.0.1. The container ends on + * its own after `$lifetime` seconds, also when the test process dies before its cleanup. * - * @param list $command the command, after the image's entrypoint - * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own - * @param array $mounts host path (under the test HOME) => container path - * @param float $timeout how long the port may take to accept a connection - * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) + * @param non-empty-list $command the command, after the image's entrypoint + * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own + * @param array $mounts host path (under the test HOME) => container path + * @param float $timeout how long the port may take to accept a connection + * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) + * @param float $lifetime how long the container may run at most */ public static function start( string $image, @@ -99,10 +113,13 @@ public static function start( bool $init = false, float $timeout = 30.0, bool $remove = true, + float $lifetime = 300.0, ): self { $timeout = self::seconds($timeout); + $lifetime = (string) (int) ceil(self::seconds($lifetime)); $runtime = new self(self::newName()); - $run = self::docker(['run', '--detach', ...self::options($runtime->name, $env, $mounts, $init, $remove), $image, ...$command]); + $options = self::options($runtime->name, $env, $mounts, $init, $remove); + $run = self::docker(['run', '--detach', ...$options, '--entrypoint', 'sh', $image, '-c', self::LIFETIME_SCRIPT, 'sh', $lifetime, ...self::entrypoint($image), ...$command]); if (!$run->isSuccessful()) { throw new \RuntimeException(sprintf('docker run %s failed: %s', $image, $run->getErrorOutput())); } @@ -150,18 +167,26 @@ public static function command(string $image, array $command, array $env, array */ public static function removeAll(): void { - $list = self::docker(['ps', '--all', '--quiet', '--filter', 'name=^/' . self::namePrefix()]); - if (!$list->isSuccessful()) { - throw new \RuntimeException('docker ps failed: ' . $list->getErrorOutput()); - } - $ids = preg_split('/\s+/', trim($list->getOutput()), -1, PREG_SPLIT_NO_EMPTY); - if ($ids === false || $ids === []) { + self::remove(self::names(self::namePrefix())); + } + + /** + * Removes the containers of runtime test processes that no longer run on this host, for example a PHPUnit run that + * was killed before its cleanup. `RuntimeTestCase` calls it once per process, before the first container. Names + * of another form, and those of a live process, stay; without `/proc`, nothing is removed. + */ + public static function removeStale(): void + { + if (!is_dir('/proc/self')) { return; } - $remove = self::docker(['rm', '--force', ...$ids]); - if (!$remove->isSuccessful() && !str_contains($remove->getErrorOutput(), 'No such container')) { - throw new \RuntimeException('docker rm failed: ' . $remove->getErrorOutput()); + $stale = []; + foreach (self::names(self::NAME_PREFIX) as $name) { + if (preg_match('/\A' . self::NAME_PREFIX . '(\d+)-[0-9a-f]+\z/', $name, $match) === 1 && !is_dir('/proc/' . $match[1])) { + $stale[] = $name; + } } + self::remove($stale); } /** @@ -204,12 +229,67 @@ public function stopGracefully(float $timeout): void /** `docker rm --force`; a container that is already gone is fine. */ public function stop(): void { - $remove = self::docker(['rm', '--force', $this->name]); + self::remove([$this->name]); + } + + /** + * The names of all containers, running or not, whose names start with `$prefix`. + * + * @return list + */ + private static function names(string $prefix): array + { + $list = self::docker(['ps', '--all', '--format', '{{.Names}}', '--filter', 'name=^/' . $prefix]); + if (!$list->isSuccessful()) { + throw new \RuntimeException('docker ps failed: ' . $list->getErrorOutput()); + } + $names = preg_split('/\s+/', trim($list->getOutput()), -1, PREG_SPLIT_NO_EMPTY); + + return $names === false ? [] : $names; + } + + /** + * `docker rm --force`; a container that is already gone is fine. + * + * @param list $names + */ + private static function remove(array $names): void + { + if ($names === []) { + return; + } + $remove = self::docker(['rm', '--force', ...$names]); if (!$remove->isSuccessful() && !str_contains($remove->getErrorOutput(), 'No such container')) { - throw new \RuntimeException(sprintf('docker rm %s failed: %s', $this->name, $remove->getErrorOutput())); + throw new \RuntimeException('docker rm failed: ' . $remove->getErrorOutput()); } } + /** + * The image's entrypoint, which `start()` runs after its lifetime script. + * + * @return list + */ + private static function entrypoint(string $image): array + { + if (isset(self::$entrypoints[$image])) { + return self::$entrypoints[$image]; + } + $inspect = self::docker(['image', 'inspect', '--format', '{{json .Config.Entrypoint}}', $image]); + if (!$inspect->isSuccessful()) { + throw new \RuntimeException(sprintf('docker image inspect %s failed: %s', $image, $inspect->getErrorOutput())); + } + $decoded = json_decode(trim($inspect->getOutput()), true, 8, JSON_THROW_ON_ERROR); + $entrypoint = []; + foreach (is_array($decoded) ? $decoded : [] as $part) { + if (!is_string($part)) { + throw new \RuntimeException(sprintf('The entrypoint of %s is not a list of strings: %s', $image, $inspect->getOutput())); + } + $entrypoint[] = $part; + } + + return self::$entrypoints[$image] = $entrypoint; + } + private function waitForPort(int $port, float $timeout): void { $deadline = microtime(true) + $timeout; @@ -339,7 +419,7 @@ private static function namePrefix(): string { $pid = getmypid(); - return 'agentcat-rt-' . ($pid === false ? 0 : $pid) . '-'; + return self::NAME_PREFIX . ($pid === false ? 0 : $pid) . '-'; } /** A timeout the docker commands and the deadlines can use: finite, positive and at most `MAX_SECONDS`. */ diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index afa58d1..1c464f5 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -45,6 +45,9 @@ abstract class RuntimeTestCase extends TestCase protected static MockCollector $collector; + /** Whether this process removed the containers of dead runs yet. */ + private static bool $swept = false; + private ?string $out = null; #[\Override] @@ -52,6 +55,10 @@ public static function setUpBeforeClass(): void { self::assertTheRuntimeGroup(); if (DockerRuntime::available()) { + if (!self::$swept) { + self::$swept = true; + DockerRuntime::removeStale(); // a killed run's containers + } self::$collector = MockCollector::start(); } } From d675d1e5ff8d3555c444a8bede0353d1d37467a0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:05:33 +0000 Subject: [PATCH 34/55] test: mount only the output directory, give PHP CLI images an init, and send AgentCat's default hosts to 127.0.0.1 in runtime containers --- tests/Runtime/HarnessTest.php | 61 +++++++++++++++++++++ tests/Runtime/Support/DockerRuntime.php | 65 ++++++++++++++++------- tests/Runtime/Support/Images.php | 7 ++- tests/Runtime/Support/RuntimeTestCase.php | 2 +- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/tests/Runtime/HarnessTest.php b/tests/Runtime/HarnessTest.php index f88eee3..027caeb 100644 --- a/tests/Runtime/HarnessTest.php +++ b/tests/Runtime/HarnessTest.php @@ -4,6 +4,7 @@ namespace AgentCat\Tests\Runtime; +use AgentCat\Internal\Constants; use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Runtime\Support\DockerRuntime; use AgentCat\Tests\Runtime\Support\Images; @@ -106,6 +107,66 @@ public function testAStartedContainerEndsOnItsOwnAfterItsLifetime(): void $this->assertGreaterThan(1.5, $lived, sprintf('the container ended %.2f s after its start, before its lifetime', $lived)); } + /** Containers write only into the test's output directory, which the cleanup makes removable. */ + #[MaximumDuration(60000)] + public function testOnlyTheOutputDirectoryCanBeMounted(): void + { + $out = $this->out(); + file_put_contents($out . '/pool.conf', ''); + $home = getenv('HOME'); + $this->assertIsString($home); + $elsewhere = $home . '/kernel-' . bin2hex(random_bytes(4)); + $this->assertTrue(mkdir($elsewhere)); + + try { + $refused = Thrown::by(\InvalidArgumentException::class, fn(): array => DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$elsewhere => '/kernel'])); + } finally { + rmdir($elsewhere); + } + $command = DockerRuntime::command(Images::CLI, ['true'], $this->env(), [$out => '/out', $out . '/pool.conf' => '/etc/pool.conf']); + + $this->assertStringContainsString('output directory', $refused->getMessage()); + $this->assertContains('type=bind,source=' . realpath($out) . ',target=/out', $command); + $this->assertContains('type=bind,source=' . realpath($out) . '/pool.conf,target=/etc/pool.conf', $command); + } + + /** PHP must not be PID 1 in a PHP CLI image (header conflict 11): its signal drain's re-raise would not end it. */ + #[MaximumDuration(60000)] + public function testPhpCliImagesGetAnInitWhateverTheCallerSays(): void + { + $out = $this->out(); + + DockerRuntime::start(Images::CLI, ['php', '-r', 'file_put_contents("/out/pid", (string) getmypid()); sleep(30);'], $this->env(), [$out => '/out'], init: false); + + $this->assertContains('--init', DockerRuntime::command(Images::CLI, ['true'], $this->env(), [], init: false)); + $this->assertContains('--init', DockerRuntime::command(Images::SWOOLE, ['true'], $this->env(), [], init: false)); + $this->assertNotContains('--init', DockerRuntime::command(Images::FPM, ['true'], $this->env(), [], init: false)); + $deadline = microtime(true) + 20.0; + while (!is_file($out . '/pid') && microtime(true) < $deadline) { + usleep(50_000); + } + $this->assertFileExists($out . '/pid'); + $this->assertNotSame('1', file_get_contents($out . '/pid'), 'PHP runs as PID 1'); + } + + /** + * A runtime that drops the container's environment (FPM's default `clear_env`) makes the SDK use its default hosts: + * in every container they resolve to 127.0.0.1, where no AgentCat listens. + */ + #[MaximumDuration(60000)] + public function testAgentCatsDefaultHostsResolveToTheLocalHostInEveryContainer(): void + { + $hosts = [parse_url(Constants::DEFAULT_API_BASE_URL, PHP_URL_HOST), parse_url(Constants::DEFAULT_DIAGNOSTICS_ENDPOINT, PHP_URL_HOST)]; + $code = sprintf('echo gethostbyname(%s), " ", gethostbyname(%s);', var_export($hosts[0], true), var_export($hosts[1], true)); + + $run = DockerRuntime::run(Images::CLI, ['env', '-i', '/usr/local/bin/php', '-r', $code], $this->env(), []); // an empty environment + + $this->assertSame(0, $run['exitCode'], $run['stderr']); + $this->assertSame('127.0.0.1 127.0.0.1', $run['stdout']); + $env = ['AGENTCAT_API_URL' => 'http://127.0.0.1:@example.com/'] + $this->env(); // user information, host example.com + $this->assertStringContainsString('AGENTCAT_API_URL', Thrown::by(\InvalidArgumentException::class, static fn(): array => DockerRuntime::command(Images::CLI, ['true'], $env, []))->getMessage()); + } + /** * Runs the smoke test in a new PHPUnit process. * diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index be25b3e..f15f2a6 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -4,6 +4,7 @@ namespace AgentCat\Tests\Runtime\Support; +use AgentCat\Internal\Constants; use Symfony\Component\Process\Process; /** @@ -12,8 +13,9 @@ * * Every container uses host networking and reaches only 127.0.0.1: its environment must point `AGENTCAT_API_URL` and * `AGENTCAT_DIAGNOSTICS_ENDPOINT` there (`RuntimeTestCase::env()`), so a fixture that forgets its collector fails - * closed. The repository is mounted read-only at its own path and is the working directory; every other mount is an - * existing path under the test HOME (`--mount` never creates a host path). Every container is named + * closed, and AgentCat's default hosts resolve to 127.0.0.1 for a runtime that drops that environment. The repository + * is mounted read-only at its own path and is the working directory; every other mount is the test's output directory + * or a path in it (`--mount` never creates a host path). PHP CLI images always get `--init`. Every container is named * `agentcat-rt--` and runs with `--rm`; `removeAll()` removes the ones that outlive their docker client, * `removeStale()` those of a test process that died, and a started container ends on its own after its lifetime. * Every docker command runs with a timeout. @@ -41,6 +43,12 @@ final class DockerRuntime private const string NAME_PREFIX = 'agentcat-rt-'; + /** The images whose main process is PHP CLI: PHP must not be PID 1 there (header conflict 11). */ + private const array INIT_IMAGES = [Images::CLI, Images::SWOOLE]; + + /** The test's output directory is `/runtime-` (`RuntimeTestCase::out()`). */ + public const string OUTPUT_DIRECTORY_PREFIX = 'runtime-'; + /** * Run by `sh -c` in front of a started container's entrypoint (`$1` is the lifetime): after the lifetime it sends * SIGTERM to PID 1 (with `--init`, tini passes it on), and 10 s later SIGKILL to every other process. @@ -99,7 +107,7 @@ public static function repo(): string * * @param non-empty-list $command the command, after the image's entrypoint * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own - * @param array $mounts host path (under the test HOME) => container path + * @param array $mounts host path (in the output directory) => container path * @param float $timeout how long the port may take to accept a connection * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) * @param float $lifetime how long the container may run at most @@ -118,7 +126,7 @@ public static function start( $timeout = self::seconds($timeout); $lifetime = (string) (int) ceil(self::seconds($lifetime)); $runtime = new self(self::newName()); - $options = self::options($runtime->name, $env, $mounts, $init, $remove); + $options = self::options($image, $runtime->name, $env, $mounts, $init, $remove); $run = self::docker(['run', '--detach', ...$options, '--entrypoint', 'sh', $image, '-c', self::LIFETIME_SCRIPT, 'sh', $lifetime, ...self::entrypoint($image), ...$command]); if (!$run->isSuccessful()) { throw new \RuntimeException(sprintf('docker run %s failed: %s', $image, $run->getErrorOutput())); @@ -135,13 +143,13 @@ public static function start( * * @param list $command * @param array $env - * @param array $mounts host path (under the test HOME) => container path + * @param array $mounts host path (in the output directory) => container path * * @return array{exitCode: int, stdout: string, stderr: string} */ public static function run(string $image, array $command, array $env, array $mounts): array { - $run = self::docker(['run', ...self::options(self::newName(), $env, $mounts, true, true), $image, ...$command], self::RUN_SECONDS); + $run = self::docker(['run', ...self::options($image, self::newName(), $env, $mounts, true, true), $image, ...$command], self::RUN_SECONDS); return ['exitCode' => $run->getExitCode() ?? -1, 'stdout' => $run->getOutput(), 'stderr' => $run->getErrorOutput()]; } @@ -152,13 +160,13 @@ public static function run(string $image, array $command, array $env, array $mou * * @param list $command * @param array $env - * @param array $mounts host path (under the test HOME) => container path + * @param array $mounts host path (in the output directory) => container path * * @return list */ public static function command(string $image, array $command, array $env, array $mounts, bool $init = true): array { - return ['docker', 'run', '--interactive', ...self::options(self::newName(), $env, $mounts, $init, true), $image, ...$command]; + return ['docker', 'run', '--interactive', ...self::options($image, self::newName(), $env, $mounts, $init, true), $image, ...$command]; } /** @@ -196,7 +204,7 @@ public static function removeStale(): void public static function makeRemovable(string $dir): void { $name = self::newName(); - $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::underTestHome($dir), '/out'), Images::CLI, 'chmod', '-R', 'a+rwX', '/out']); + $run = self::docker(['run', '--rm', '--pull', 'never', '--name', $name, '--network', 'none', '--mount', self::bind(self::inOutputDirectory($dir), '/out'), Images::CLI, 'chmod', '-R', 'a+rwX', '/out']); if (!$run->isSuccessful()) { throw new \RuntimeException(sprintf('chmod of %s in %s failed: %s', $dir, Images::CLI, $run->getErrorOutput())); } @@ -214,8 +222,9 @@ public function logs(): string } /** - * `docker stop -t`: SIGTERM, the runtime's own graceful stop, and SIGKILL after `$timeout` seconds. With `--rm` - * the container is gone afterwards: read `logs()` first. + * `docker stop -t`: the image's stop signal (`Images`: SIGQUIT for FPM, SIGWINCH for Apache, SIGTERM for the + * others; with `--init`, tini passes it on), the runtime's own graceful stop, and SIGKILL after `$timeout` seconds. + * With `--rm` the container is gone afterwards: read `logs()` first. */ public function stopGracefully(float $timeout): void { @@ -349,24 +358,28 @@ private function logsOrWhy(): string * * @return list */ - private static function options(string $name, array $env, array $mounts, bool $init, bool $remove): array + private static function options(string $image, string $name, array $env, array $mounts, bool $init, bool $remove): array { foreach (['AGENTCAT_API_URL', 'AGENTCAT_DIAGNOSTICS_ENDPOINT'] as $required) { - if (!str_starts_with($env[$required] ?? '', self::FAIL_CLOSED_PREFIX)) { - throw new \InvalidArgumentException(sprintf('A container needs %s on 127.0.0.1, the collector of the test or a closed port: pass RuntimeTestCase::env()', $required)); + if (!self::local($env[$required] ?? '')) { + throw new \InvalidArgumentException(sprintf('A container needs %s on http://127.0.0.1:, the collector of the test or a closed port: pass RuntimeTestCase::env()', $required)); } } $repo = self::repo(); $options = ['--pull', 'never', '--name', $name, '--network', 'host', '--mount', self::bind($repo, $repo, readonly: true), '--workdir', $repo]; + foreach ([Constants::DEFAULT_API_BASE_URL, Constants::DEFAULT_DIAGNOSTICS_ENDPOINT] as $default) { + $options[] = '--add-host'; + $options[] = parse_url($default, PHP_URL_HOST) . ':127.0.0.1'; + } if ($remove) { $options[] = '--rm'; } - if ($init) { + if ($init || in_array($image, self::INIT_IMAGES, true)) { $options[] = '--init'; } foreach ($mounts as $host => $container) { $options[] = '--mount'; - $options[] = self::bind(self::underTestHome($host), $container); + $options[] = self::bind(self::inOutputDirectory($host), $container); } foreach ($env as $variable => $value) { $options[] = '--env'; @@ -388,14 +401,26 @@ private static function bind(string $host, string $container, bool $readonly = f return sprintf('type=bind,source=%s,target=%s%s', $host, $container, $readonly ? ',readonly' : ''); } - /** The real path of `$host`, which must exist under the test HOME: the only host paths a container may write. */ - private static function underTestHome(string $host): string + /** An `http://127.0.0.1:` URL, with no user information that would name another host. */ + private static function local(string $url): bool + { + $parts = parse_url($url); + + return is_array($parts) && str_starts_with($url, self::FAIL_CLOSED_PREFIX) && ($parts['scheme'] ?? null) === 'http' + && ($parts['host'] ?? null) === '127.0.0.1' && isset($parts['port']) && !isset($parts['user']) && !isset($parts['pass']); + } + + /** + * The real path of `$host`, which must be the test's output directory (`/runtime-`) or a path in + * it: the only host place a container may write, and the one the cleanup makes removable. + */ + private static function inOutputDirectory(string $host): string { $home = getenv('HOME'); $realHome = is_string($home) ? realpath($home) : false; $real = realpath($host); - if ($realHome === false || $real === false || !str_starts_with($real, $realHome . '/')) { - throw new \InvalidArgumentException(sprintf('A mount source must exist under the test HOME (%s): %s', var_export($home, true), $host)); + if ($realHome === false || $real === false || !str_starts_with($real, $realHome . '/' . self::OUTPUT_DIRECTORY_PREFIX)) { + throw new \InvalidArgumentException(sprintf('A mount source must be the test\'s output directory (RuntimeTestCase::out()) or a path in it: %s', $host)); } return $real; diff --git a/tests/Runtime/Support/Images.php b/tests/Runtime/Support/Images.php index 8dc7e94..e088181 100644 --- a/tests/Runtime/Support/Images.php +++ b/tests/Runtime/Support/Images.php @@ -10,11 +10,16 @@ */ final class Images { + /** Its stop signal is SIGQUIT: a graceful stop. */ public const string FPM = 'php:8.3.35-fpm-bookworm'; + /** Its stop signal is SIGWINCH: a graceful stop. */ public const string APACHE = 'php:8.3.35-apache-bookworm'; - /** Built locally: `docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker` (the official image plus ext-pcntl). */ + /** + * Built locally: `docker build --tag agentcat-runtime-cli:8.3.35 tests/Runtime/docker` (the official image plus + * ext-pcntl). Its stop signal is SIGTERM, as for `FRANKENPHP`, `SWOOLE` and `UNIT`. + */ public const string CLI = 'agentcat-runtime-cli:8.3.35'; public const string FRANKENPHP = 'dunglas/frankenphp:1.13.1-php8.3.35-bookworm'; diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index 1c464f5..47b8fcb 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -134,7 +134,7 @@ final protected function out(): string } $home = getenv('HOME'); $this->assertIsString($home); - $out = $home . '/runtime-' . bin2hex(random_bytes(6)); + $out = $home . '/' . DockerRuntime::OUTPUT_DIRECTORY_PREFIX . bin2hex(random_bytes(6)); foreach (['', ...self::OUT_DIRECTORIES] as $directory) { $path = $directory === '' ? $out : $out . '/' . $directory; $this->assertTrue(mkdir($path, 0o777) && chmod($path, 0o777), 'Cannot create ' . $path); // chmod: the umask From 2c2dc386b729815a96268a0b350d7e959eda7695 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:06:59 +0000 Subject: [PATCH 35/55] test: cover FastCGI 4-byte lengths, parameters in several records and a body above one record against PHP-FPM --- tests/Runtime/FastCgiClientTest.php | 73 +++++++++++++++++++++++++ tests/Runtime/Fixtures/fastcgi-echo.php | 14 +++++ tests/Runtime/Support/FastCgiClient.php | 3 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/Runtime/FastCgiClientTest.php create mode 100644 tests/Runtime/Fixtures/fastcgi-echo.php diff --git a/tests/Runtime/FastCgiClientTest.php b/tests/Runtime/FastCgiClientTest.php new file mode 100644 index 0000000..2c60385 --- /dev/null +++ b/tests/Runtime/FastCgiClientTest.php @@ -0,0 +1,73 @@ +fpm(); + $headers = [ + 'Content-Type' => 'text/plain', + 'X-Long' => str_repeat('l', 200), // a 4-byte value length + 'X-Fill-One' => str_repeat('a', 30000), // with the next two: 90 kB of parameters, three FCGI_PARAMS records + 'X-Fill-Two' => str_repeat('b', 30000), + 'X-Fill-Three' => str_repeat('c', 30000), + ]; + $body = str_repeat('z', 70000); // two FCGI_STDIN records + $sentAt = microtime(true); + + $answer = (new FastCgiClient('127.0.0.1', $port))->request(self::repo() . '/tests/Runtime/Fixtures/fastcgi-echo.php', 'POST', '/echo?x=1', $headers, $body); + + $this->assertSame(200, $answer['status'], $answer['body']); + $this->assertSame('application/json', $answer['headers']['content-type'] ?? null); + $echo = Json::decode($answer['body']); + $this->assertInstanceOf(\stdClass::class, $echo); + $this->assertSame(['POST', '/echo?x=1', 'x=1', 'text/plain'], [$echo->method, $echo->uri, $echo->query, $echo->contentType]); + $this->assertEquals((object) [ + 'HTTP_X_LONG' => $headers['X-Long'], + 'HTTP_X_FILL_ONE' => $headers['X-Fill-One'], + 'HTTP_X_FILL_TWO' => $headers['X-Fill-Two'], + 'HTTP_X_FILL_THREE' => $headers['X-Fill-Three'], + ], $echo->headers); + $this->assertSame($body, $echo->body); + $this->assertGreaterThan($sentAt, $answer['endedAt']); + $this->assertLessThanOrEqual(microtime(true), $answer['endedAt']); + } + + /** A pair never spans two records (FPM parses each record on its own), so one longer than a record is refused. */ + #[MaximumDuration(60000)] + public function testAParameterLongerThanOneRecordIsRefusedBeforeTheClientConnects(): void + { + $client = new FastCgiClient('127.0.0.1', $this->freePort()); // nothing listens there + + $refused = Thrown::by(\InvalidArgumentException::class, static fn(): array => $client->request('/echo.php', 'GET', '/', ['X-Huge' => str_repeat('h', 70000)], '')); + + $this->assertStringContainsString('HTTP_X_HUGE does not fit in one record', $refused->getMessage()); + } + + /** PHP-FPM on 127.0.0.1, with a pool on a free port; returns the port. */ + private function fpm(): int + { + $out = $this->out(); + $port = $this->freePort(); + file_put_contents($out . '/pool.conf', "[www]\nlisten = 127.0.0.1:{$port}\npm = static\npm.max_children = 1\n"); + DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env(), [$out . '/pool.conf' => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf'], port: $port); + + return $port; + } +} diff --git a/tests/Runtime/Fixtures/fastcgi-echo.php b/tests/Runtime/Fixtures/fastcgi-echo.php new file mode 100644 index 0000000..ac88065 --- /dev/null +++ b/tests/Runtime/Fixtures/fastcgi-echo.php @@ -0,0 +1,14 @@ + $_SERVER['REQUEST_METHOD'] ?? null, + 'uri' => $_SERVER['REQUEST_URI'] ?? null, + 'query' => $_SERVER['QUERY_STRING'] ?? null, + 'contentType' => $_SERVER['CONTENT_TYPE'] ?? null, + 'headers' => array_filter($_SERVER, static fn(int|string $name): bool => is_string($name) && str_starts_with($name, 'HTTP_X_'), ARRAY_FILTER_USE_KEY), + 'body' => file_get_contents('php://input'), +], JSON_THROW_ON_ERROR); diff --git a/tests/Runtime/Support/FastCgiClient.php b/tests/Runtime/Support/FastCgiClient.php index 5bf1b60..1721d76 100644 --- a/tests/Runtime/Support/FastCgiClient.php +++ b/tests/Runtime/Support/FastCgiClient.php @@ -50,6 +50,7 @@ public function request(string $script, string $method, string $uri, array $head if (!is_finite($timeout) || $timeout <= 0.0 || $timeout > self::MAX_SECONDS) { throw new \InvalidArgumentException(sprintf('A FastCGI timeout must be above 0 and at most %d s: %s', (int) self::MAX_SECONDS, var_export($timeout, true))); } + $records = $this->records($script, $method, $uri, $headers, $body); // a parameter too long for a record throws here $deadline = microtime(true) + $timeout; set_error_handler(static function (int $severity, string $message): never { throw new \RuntimeException('FastCGI: ' . $message); @@ -62,7 +63,7 @@ public function request(string $script, string $method, string $uri, array $head } try { - $this->write($socket, $this->records($script, $method, $uri, $headers, $body), $deadline); + $this->write($socket, $records, $deadline); [$stdout, $endedAt] = $this->readAnswer($socket, $deadline); } finally { fclose($socket); From 688749ca4eddd2bad9d4f2f5fc53066f9f37e84d Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:09:32 +0000 Subject: [PATCH 36/55] test: document which runtime images always get an init --- tests/Runtime/Support/DockerRuntime.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index f15f2a6..fbd3fc0 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -108,9 +108,10 @@ public static function repo(): string * @param non-empty-list $command the command, after the image's entrypoint * @param array $env the container's environment: `RuntimeTestCase::env()` plus the test's own * @param array $mounts host path (in the output directory) => container path + * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it * @param float $timeout how long the port may take to accept a connection * @param bool $remove false keeps a stopped container for `logs()` until `stop()` (ruling P16) - * @param float $lifetime how long the container may run at most + * @param float $lifetime how long the container may run at most: longer than any runtime test */ public static function start( string $image, @@ -161,6 +162,7 @@ public static function run(string $image, array $command, array $env, array $mou * @param list $command * @param array $env * @param array $mounts host path (in the output directory) => container path + * @param bool $init `--init`; `Images::CLI` and `Images::SWOOLE` always get it * * @return list */ From 17d107ee69b5e3cc7b84a7dca387129fa4ceea64 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:41:15 +0000 Subject: [PATCH 37/55] fix: post diagnostics once per request, at shutdown, in per-request runtimes Under PHP-FPM, mod_php and php -S, a drain longer than 2 s (a slow hook or a slow AgentCat API) let the 2 s window post the buffered lines, and the shutdown posted the rest: two posts for one request (D47 asks for one). Only a process that outlives its requests (the command line, FrankenPHP worker mode) keeps the TypeScript window now. --- src/Internal/Diagnostics/Diagnostics.php | 26 ++++++--- tests/E2E/HttpEndToEndTest.php | 3 +- .../Internal/Diagnostics/DiagnosticsTest.php | 58 ++++++++++++++++++- 3 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/Internal/Diagnostics/Diagnostics.php b/src/Internal/Diagnostics/Diagnostics.php index ce14251..8c5313e 100644 --- a/src/Internal/Diagnostics/Diagnostics.php +++ b/src/Internal/Diagnostics/Diagnostics.php @@ -5,6 +5,7 @@ namespace AgentCat\Internal\Diagnostics; use AgentCat\Internal\Delivery\EventQueue; +use AgentCat\Internal\Delivery\FinishRequest; use AgentCat\Internal\Delivery\QueueCompanion; use AgentCat\Internal\Logging\Log; use AgentCat\Internal\SdkVersion; @@ -16,11 +17,13 @@ /** * TypeScript diagnostics: every SDK log line (D07), as OTLP/JSON logs to AgentCat's collector. The first `init()` of * the process decides. Lines are buffered (1 000 at most, with at most 1 MiB of bodies; the oldest are dropped) and - * posted as a background request (`flush()` does not wait for it; 250 ms connect and 1 s total timeouts, D49) at the - * first queue pass (`pump()`, `flush()`, `shutdown()`) once the oldest of them is 2 s old, and once more when the queue - * shuts down, after the events (D47: under PHP-FPM one post per request, after the response). Lines logged in the last - * shutdown pass, after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log line would - * feed the buffer it reports on. + * posted as a background request (`flush()` does not wait for it; 250 ms connect and 1 s total timeouts, D49) when the + * queue shuts down, after the events. A process that outlives its requests (`FinishRequest::outlivesRequest()`: the + * command line, FrankenPHP worker mode) also posts them at the first queue pass (`pump()`, `flush()`, `shutdown()`) + * once the oldest of them is 2 s old, as TypeScript does; a per-request runtime (PHP-FPM, mod_php, `php -S`) posts only + * at shutdown, once per request, after the response, however long the request and its drain take (D47). Lines logged + * in the last shutdown pass, after `finish()`, are dropped unless a later `shutdown()` runs. A failure is silent: a log + * line would feed the buffer it reports on. * * @internal */ @@ -62,6 +65,7 @@ final class Diagnostics implements QueueCompanion * @param list $recordAttributes * @param \Closure(): float $clock monotonic seconds * @param \Closure(): int $wallMillis wall-clock milliseconds + * @param bool $window whether queue passes post after 2 s */ private function __construct( private readonly EventQueue $queue, @@ -69,6 +73,7 @@ private function __construct( private readonly array $recordAttributes, private readonly \Closure $clock, private readonly \Closure $wallMillis, + private readonly bool $window, ) {} /** @@ -79,8 +84,9 @@ private function __construct( * @param (\Closure(): bool)|null $inTestEnvironment default `TestMode::inTestEnvironment()` (P5) * @param (\Closure(): float)|null $clock tests: monotonic seconds * @param (\Closure(): int)|null $wallMillis tests: wall-clock milliseconds + * @param string|null $sapi tests: the SAPI, which decides the 2 s window; default `PHP_SAPI` */ - public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null): void + public static function init(string $projectId, bool $disabled, EventQueue $queue, ?\Closure $inTestEnvironment = null, ?\Closure $clock = null, ?\Closure $wallMillis = null, ?string $sapi = null): void { try { if (self::$initialized) { @@ -102,6 +108,7 @@ public static function init(string $projectId, bool $disabled, EventQueue $queue DiagnosticsAttributes::record(), $clock ?? static fn(): float => hrtime(true) / 1e9, $wallMillis ?? static fn(): int => (int) (microtime(true) * 1000), // whole milliseconds, like Date.now() + FinishRequest::outlivesRequest($sapi ?? PHP_SAPI, $_SERVER), ); self::$active = $diagnostics; $queue->attach($diagnostics); @@ -125,12 +132,15 @@ public static function reset(): void Log::setDiagnosticsSink(null); } - /** Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes). */ + /** + * Posts the buffer once its oldest line is 2 s old (the TypeScript timer, checked on queue passes), in a process + * that outlives its requests only: a per-request runtime posts once, at shutdown (D47). + */ #[\Override] public function tick(): void { $firstAt = $this->firstAt; - if ($firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) { + if ($this->window && $firstAt !== null && ($this->clock)() - $firstAt >= self::BATCH_FLUSH_SECONDS) { $this->flush(); } } diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index b45757b..36f7b65 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -293,7 +293,7 @@ public function testOddHeadersReachTheEventAsStrings(): void /** * D47 (open question Q3): under `php -S`, like PHP-FPM, each request posts its own diagnostics once, at its end, * after its response. Each POST is tied to its request by the session in its `Successfully sent event` line: each - * call mints its own session. A slow runner can add a POST without that line, from the 2 s window. + * call mints its own session. */ #[Group('ac-06-62')] #[MaximumDuration(15000)] @@ -315,6 +315,7 @@ public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void $this->assertCount(2, $answeredAt, 'each call minted its own session'); $sent = $this->diagnosticsWithASentEvent($diagnostics, 2); $this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request'); + $this->assertCount(2, $this->collector()->requests($diagnostics), 'and no other: the 2 s window is for long-running processes only'); foreach ($answeredAt as $sessionId => $receivedAt) { $own = array_values(array_filter($sent, fn(\stdClass $request): bool => str_contains($this->body($request), ' | session ' . $sessionId . ' | '))); $this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId); diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index e56610e..759ce30 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -237,6 +237,60 @@ public function testTheTwoSecondWindowIsCheckedOnQueuePasses(): void $this->assertSame(2, $this->posts()); } + /** @return iterable */ + public static function longRunningSapis(): iterable + { + yield 'command line' => ['cli']; + yield 'phpdbg' => ['phpdbg']; + } + + /** D47: a process that outlives its requests (a stdio server, a worker) keeps the TypeScript 2 s window. */ + #[DataProvider('longRunningSapis')] + public function testALongRunningProcessPostsOnceItsOldestLineIsTwoSecondsOld(string $sapi): void + { + $this->init('proj_ABC', sapi: $sapi); + Log::write('first'); + $this->now += 2.0; + + $this->queue->pump(); + + $this->assertSame(1, $this->posts()); + } + + /** @return iterable */ + public static function perRequestSapis(): iterable + { + yield 'PHP-FPM' => ['fpm-fcgi']; + yield 'mod_php' => ['apache2handler']; + yield 'php -S' => ['cli-server']; + yield 'FrankenPHP classic mode' => ['frankenphp']; + } + + /** + * D47: a per-request runtime posts its lines once, when the queue shuts down after the response, also when the + * request or its drain (a slow hook, a slow AgentCat API) lasts longer than the 2 s window of long-running processes. + */ + #[Group('ac-06-62')] + #[DataProvider('perRequestSapis')] + public function testAPerRequestRuntimePostsItsLinesOnceAtShutdown(string $sapi): void + { + $this->init('proj_ABC', sapi: $sapi); + Log::write('setup'); + $this->now += 3.0; + $this->queue->pump(); + $this->assertTrue($this->queue->flush(1.0)); + $this->assertSame(0, $this->posts(), 'no queue pass posts before the shutdown'); + Log::write('sent'); + + $this->queue->shutdown(); + + $this->assertSame(1, $this->posts()); + $records = $this->records(0); + $this->assertCount(2, $records, 'every line of the request in one post'); + $this->assertStringEndsWith('] setup', $this->text($records[0])); + $this->assertStringEndsWith('] sent', $this->text($records[1])); + } + /** * I1: a collector that never answers (black-holed otel.agentcat.com). The flush pass posts the batch, as its window * is over, and returns true at once: AgentCat's own request is neither counted nor waited for. @@ -725,9 +779,9 @@ public function testResetForgetsTheDecisionAndRemovesTheSink(): void $this->assertFalse($this->queue->hasWork()); } - private function init(string $projectId, bool $disabled = false, bool $testEnvironment = false): void + private function init(string $projectId, bool $disabled = false, bool $testEnvironment = false, ?string $sapi = null): void { - Diagnostics::init($projectId, $disabled, $this->queue, static fn(): bool => $testEnvironment, fn(): float => $this->now, static fn(): int => self::WALL_MILLIS); + Diagnostics::init($projectId, $disabled, $this->queue, static fn(): bool => $testEnvironment, fn(): float => $this->now, static fn(): int => self::WALL_MILLIS, $sapi); } private function queueOn(HttpChannel $channel): EventQueue From 004cb2eaabb8284769ee96bd74ef91ebe15b21d0 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Mon, 19 Oct 2026 23:41:20 +0000 Subject: [PATCH 38/55] test: prove delivery after the response under PHP-FPM --- phpstan.no-bundle.neon | 1 + tests/Runtime/Fixtures/fpm-session.php | 13 + tests/Runtime/Fixtures/laravel-front.php | 7 + tests/Runtime/Fixtures/symfony-front.php | 19 ++ tests/Runtime/FpmTest.php | 309 +++++++++++++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 tests/Runtime/Fixtures/fpm-session.php create mode 100644 tests/Runtime/Fixtures/laravel-front.php create mode 100644 tests/Runtime/Fixtures/symfony-front.php create mode 100644 tests/Runtime/FpmTest.php diff --git a/phpstan.no-bundle.neon b/phpstan.no-bundle.neon index 613ee13..fda9eab 100644 --- a/phpstan.no-bundle.neon +++ b/phpstan.no-bundle.neon @@ -8,3 +8,4 @@ parameters: - tests/Integration/Symfony - tests/Contract/Symfony - tests/Integration/Conformance/SymfonyConformanceTest.php + - tests/Runtime/Fixtures/symfony-front.php diff --git a/tests/Runtime/Fixtures/fpm-session.php b/tests/Runtime/Fixtures/fpm-session.php new file mode 100644 index 0000000..9b9e942 --- /dev/null +++ b/tests/Runtime/Fixtures/fpm-session.php @@ -0,0 +1,13 @@ + 'true', 'project_id' => 'proj_' . str_repeat('e', 27)]); +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); +$kernel->terminate($request, $response); diff --git a/tests/Runtime/FpmTest.php b/tests/Runtime/FpmTest.php new file mode 100644 index 0000000..86a1391 --- /dev/null +++ b/tests/Runtime/FpmTest.php @@ -0,0 +1,309 @@ +scenario([['status' => 202, 'delayMs' => 2000]]); + $fpm = $this->fpm(); + $started = microtime(true); + + $answer = $fpm->request(self::repo() . self::HTTP_SERVER, 'POST', '/mcp', $this->headers('echo', [ + 'X-E2E-Collector' => $scenario, + 'X-E2E-Identify-Sleep' => '1', // the identify hook sleeps 3 s, then writes the marker + ]), $this->modernCall('echo', ['msg' => 'hi', 'session_id' => 'start'])); + + $this->assertSame(200, $answer['status'], $answer['body']); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $this->assertLessThan(self::ANSWER_SECONDS, $answer['endedAt'] - $started, 'FPM held the request for the hooks or the delivery'); + $request = $this->requestAt($scenario, 0); + $this->assertSame('e2e-user', JsonAssert::at($this->event($request), 'identify_actor_given_id')); + $this->assertGreaterThan($answer['endedAt'] + self::AFTER_THE_HOOK_SECONDS, $this->start($request), 'the delivery started after the hook, which ran after the answer'); + $wokeAt = file_get_contents($this->out() . '/identify'); // written in the container when the hook woke up + $this->assertIsString($wokeAt); + $this->assertIsNumeric($wokeAt); + $this->assertGreaterThan($answer['endedAt'] + self::AFTER_THE_HOOK_SECONDS, (float) $wokeAt, 'identify woke up after the answer'); + } + + /** + * Review Focus 5: AgentCat writes and closes the native session before its drain, so a second request of the same + * session gets the lock while the first one still waits 3 s for the AgentCat API. Without that, PHP would write the + * session after the shutdown functions, after the drain. + */ + #[MaximumDuration(30000)] + public function testTheSessionLockIsReleasedBeforeTheDrain(): void + { + $scenario = self::$collector->scenario([], ['status' => 202, 'delayMs' => 3000]); + $fpm = $this->fpm(); + $script = self::repo() . '/tests/Runtime/Fixtures/fpm-session.php'; + $sentAt = microtime(true); + + $first = $fpm->request($script, 'POST', '/mcp', $this->headers('echo', ['X-E2E-Collector' => $scenario]), $this->modernCall('echo', ['msg' => 'one', 'session_id' => 'start'])); + + $this->assertSame(200, $first['status'], $first['body']); + $this->assertLessThan(self::ANSWER_SECONDS, $first['endedAt'] - $sentAt, 'FPM held the first request for the delivery'); + $cookie = $first['headers']['set-cookie'] ?? ''; + $this->assertStringStartsWith('PHPSESSID=', $cookie); + $this->assertSame('1', $first['headers']['x-e2e-session-count'] ?? null); + $started = microtime(true); + + $pair = strstr($cookie, ';', true); // PHPSESSID=, without the attributes + $second = $fpm->request($script, 'POST', '/mcp', $this->headers('echo', ['X-E2E-Collector' => $scenario, 'Cookie' => $pair === false ? $cookie : $pair]), $this->modernCall('echo', ['msg' => 'two', 'session_id' => 'start'])); + + $this->assertSame(200, $second['status'], $second['body']); + $this->assertSame('echo: two', JsonAssert::at($this->answerResult($second), 'content', 1, 'text')); + $this->assertSame('2', $second['headers']['x-e2e-session-count'] ?? null, 'the second request read the session that the first one wrote'); + $this->assertLessThan(self::ANSWER_SECONDS, $second['endedAt'] - $started, 'the second request waited for the first one\'s session lock'); + $this->requestAt($scenario, 1); // the second request's event + $this->assertGreaterThan($second['endedAt'] + 1.0, $this->end($this->requestAt($scenario, 0)), 'the first request\'s drain still waited for the collector when the second one had its answer'); + } + + /** + * D47: under PHP-FPM a request posts its diagnostics once, at its end, after the answer. The 3 s hook keeps the drain + * longer than the 2 s window of long-running servers. The posts are counted after the event deliveries of this + * request and of one more request, which the pool's one worker serves only after the first request's script ended, + * and whose post the test waits for too: each post carries all the lines of one request. + */ + #[Group('ac-06-62')] + #[MaximumDuration(30000)] + public function testDiagnosticsArePostedOncePerRequestAfterTheResponse(): void + { + $events = self::$collector->scenario(); + $diagnostics = self::$collector->scenario([], ['status' => 200, 'delayMs' => 500]); + $fpm = $this->fpm(workers: 1); // the one more request runs only once the first one's script, its drain included, ended + $headers = ['X-E2E-Collector' => $events, 'X-E2E-Diagnostics' => $diagnostics]; + $started = microtime(true); + + $answer = $fpm->request(self::repo() . self::HTTP_SERVER, 'POST', '/mcp', $this->headers('echo', [...$headers, 'X-E2E-Identify-Sleep' => '1']), $this->modernCall('echo', ['msg' => 'one', 'session_id' => 'start'])); + + $this->assertSame(200, $answer['status'], $answer['body']); + $this->assertLessThan(self::ANSWER_SECONDS, $answer['endedAt'] - $started, 'FPM held the request for the hook, the delivery or the diagnostics'); + $first = $this->eventId($this->requestAt($events, 0)); + $more = $fpm->request(self::repo() . self::HTTP_SERVER, 'POST', '/mcp', $this->headers('echo', $headers), $this->modernCall('echo', ['msg' => 'two', 'session_id' => 'start'])); + $this->assertSame(200, $more['status'], $more['body']); + $second = $this->eventId($this->requestAt($events, 1)); + $posts = $this->waitForPostWith($diagnostics, 'Successfully sent event ' . $second); + + $this->assertCount(2, $posts, 'one diagnostics POST per request'); + foreach ($posts as $post) { + $this->assertSame('/v1/logs', $post->path ?? null); + $this->assertSame(1, substr_count($this->body($post), 'AgentCat setup started'), 'each POST carries all the lines of one request'); + } + $own = array_values(array_filter($posts, fn(\stdClass $post): bool => str_contains($this->body($post), 'Successfully sent event ' . $first))); + $this->assertCount(1, $own, 'one POST carries the delivery line of the first request'); + $this->assertGreaterThan($answer['endedAt'] + self::AFTER_THE_HOOK_SECONDS, $this->start($own[0]), 'the first request posted its diagnostics after the hook, which ran after the answer'); + } + + /** + * Plan E1 header conflict 6: a streamed laravel/mcp answer (two progress notifications 50 ms apart, then the result) + * reaches the client as server-sent events, and its event comes when the stream ends, with a `duration` that covers + * the stream. + */ + #[Group('ac-02-54')] + #[MaximumDuration(40000)] + public function testALaravelStreamIsRecordedAfterTheStreamEnds(): void + { + $scenario = self::$collector->scenario(); + $fpm = $this->fpm(); + + $answer = $fpm->request(self::repo() . '/tests/Runtime/Fixtures/laravel-front.php', 'POST', '/mcp/e2e-stream', $this->headers('stream', ['X-E2E-Collector' => $scenario]), $this->modernCall('stream', ['session_id' => 'start'])); + + $this->assertSame(200, $answer['status'], $answer['body']); + $this->assertStringStartsWith('text/event-stream', $answer['headers']['content-type'] ?? ''); + $this->assertSame(3, substr_count($answer['body'], 'data: '), 'two notifications, then the result'); + $this->assertSame('done', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $event = $this->event($this->requestAt($scenario, 0)); + $this->assertSame('stream', JsonAssert::at($event, 'resource_name')); + $this->assertGreaterThanOrEqual(100, JsonAssert::at($event, 'duration'), 'the duration covers both 50 ms waits of the stream'); + } + + /** + * Header conflict 7 (D58): under PHP-FPM `Response::send()` finishes the request, `kernel.terminate` makes one + * non-blocking pass, and AgentCat's shutdown function delivers, so the client has its answer before the collector + * answers 3 s later. An untimed first request compiles the container. + */ + #[MaximumDuration(40000)] + public function testSymfonyAnswersBeforeTheShutdownDelivery(): void + { + $scenario = self::$collector->scenario([['status' => 202], ['status' => 202, 'delayMs' => 3000]]); + $fpm = $this->fpm($scenario); + $script = self::repo() . '/tests/Runtime/Fixtures/symfony-front.php'; + $warmUp = $fpm->request($script, 'POST', '/mcp/conformance', $this->headers('echo', []), $this->modernCall('echo', ['msg' => 'warm', 'session_id' => 'start']), 30.0); + $this->assertSame(200, $warmUp['status'], $warmUp['body']); + $this->requestAt($scenario, 0); // the warm-up's event takes the undelayed step + $started = microtime(true); + + $answer = $fpm->request($script, 'POST', '/mcp/conformance', $this->headers('echo', []), $this->modernCall('echo', ['msg' => 'hi', 'session_id' => 'start'])); + + $this->assertSame(200, $answer['status'], $answer['body']); + $this->assertSame('echo: hi', JsonAssert::at($this->answerResult($answer), 'content', 1, 'text')); + $this->assertLessThan(self::ANSWER_SECONDS, $answer['endedAt'] - $started, 'FPM held the request for the delivery'); + $delivery = $this->requestAt($scenario, 1); + $event = $this->event($delivery); + $this->assertSame(['conformance-server', 'echo'], [JsonAssert::at($event, 'server_name'), JsonAssert::at($event, 'resource_name')]); + $this->assertGreaterThan($answer['endedAt'] + 1.0, $this->end($delivery), 'the collector answered the delivery after the client had its answer'); + } + + /** + * PHP-FPM on 127.0.0.1 with `$workers` workers (two by default: one can drain while the other answers), which keep + * the container's environment (`RuntimeTestCase::env()` with `$collector` as the AgentCat API) and get `HOME=/out`, + * as FPM gives its workers `/var/www` otherwise. + */ + private function fpm(?string $collector = null, int $workers = 2): FastCgiClient + { + $out = $this->out(); + $port = $this->freePort(); + $pool = $out . '/pool.conf'; + file_put_contents($pool, implode("\n", [ + '[www]', + 'listen = 127.0.0.1:' . $port, + 'clear_env = no', + 'env[HOME] = /out', + 'pm = static', + 'pm.max_children = ' . $workers, + 'catch_workers_output = yes', + 'decorate_workers_output = no', + '', + ])); + DockerRuntime::start(Images::FPM, ['php-fpm', '-F'], $this->env($collector), [$pool => '/usr/local/etc/php-fpm.d/zzz-agentcat.conf', $out => '/out'], port: $port); + + return new FastCgiClient('127.0.0.1', $port); + } + + /** + * The headers of a 2026-07-28 `tools/call` of `$tool`, as a client sends them, plus `$extra`. + * + * @param array $extra + * + * @return array + */ + private function headers(string $tool, array $extra): array + { + return ['Accept' => 'application/json, text/event-stream', ...HttpHarness::modernHeaders('tools/call', $tool), ...$extra]; + } + + /** + * A 2026-07-28 `tools/call` (id 1) from `fpm-client`: no handshake, the client in `_meta`. + * + * @param array $arguments + */ + private function modernCall(string $tool, array $arguments): string + { + $params = HttpHarness::modernParams(['name' => $tool, 'arguments' => (object) $arguments], ['name' => 'fpm-client', 'version' => '1.0.0']); + + return json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/call', 'params' => $params], JSON_THROW_ON_ERROR); + } + + /** @param array{status: int, headers: array, body: string, endedAt: float} $answer */ + private function answerResult(array $answer): \stdClass + { + return HttpHarness::result(HttpHarness::decode($answer['body'], $answer['headers']['content-type'] ?? '', 1)); + } + + /** Waits for request `$index` (from 0, in arrival order) of `$scenario`, and fails the test after the deadline. */ + private function requestAt(string $scenario, int $index): \stdClass + { + $request = $this->waitForRequests($scenario, $index + 1, self::COLLECTOR_DEADLINE_SECONDS)[$index] ?? null; + $this->assertInstanceOf(\stdClass::class, $request); + + return $request; + } + + /** The event that a request to the collector carried. */ + private function event(\stdClass $request): \stdClass + { + $event = Json::decode($this->body($request)); + $this->assertInstanceOf(\stdClass::class, $event); + + return $event; + } + + private function eventId(\stdClass $request): string + { + $id = JsonAssert::at($this->event($request), 'id'); + $this->assertIsString($id); + + return $id; + } + + /** + * Waits until a request of `$scenario` carries `$line`, and fails the test after the deadline. + * + * @return list<\stdClass> every request of the scenario so far + */ + private function waitForPostWith(string $scenario, string $line): array + { + $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; + do { + $posts = self::$collector->requests($scenario); + foreach ($posts as $post) { + if (str_contains($this->body($post), $line)) { + return $posts; + } + } + usleep(20_000); + } while (microtime(true) < $deadline); + self::fail(sprintf('No request of %s carried "%s" within %.1f s: %d requests', $scenario, $line, self::COLLECTOR_DEADLINE_SECONDS, count($posts))); + } + + private function body(\stdClass $request): string + { + $body = $request->body ?? null; + $this->assertIsString($body); + + return $body; + } + + /** When the request reached the collector. */ + private function start(\stdClass $request): float + { + $start = $request->start ?? null; + $this->assertIsFloat($start); + + return $start; + } + + /** When the collector answered the request (after its delay). */ + private function end(\stdClass $request): float + { + $end = $request->end ?? null; + $this->assertIsFloat($end); + + return $end; + } +} From 88e1958a9b1a726a7344425df00465b48841696f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 00:07:46 +0000 Subject: [PATCH 39/55] test: check that AgentCatBundle extends Bundle, which Symfony 8.1 does not deprecate --- tests/Unit/Symfony/RecipeTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Symfony/RecipeTest.php b/tests/Unit/Symfony/RecipeTest.php index 9d85cf8..5c3ae77 100644 --- a/tests/Unit/Symfony/RecipeTest.php +++ b/tests/Unit/Symfony/RecipeTest.php @@ -12,7 +12,7 @@ use Symfony\Component\Config\Definition\Processor; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\ConfigurationExtensionInterface; -use Symfony\Component\HttpKernel\Bundle\BundleInterface; +use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\Yaml\Yaml; final class RecipeTest extends TestCase @@ -24,9 +24,9 @@ public function testTheManifestRegistersTheBundleCopiesTheConfigAndAddsTheProjec $manifest = Json::decode((string) file_get_contents(self::RECIPE . '/manifest.json')); $this->assertEquals((object) [AgentCatBundle::class => ['all']], JsonAssert::at($manifest, 'bundles')); - $implements = class_implements(AgentCatBundle::class); - $this->assertIsArray($implements); - $this->assertContains(BundleInterface::class, $implements); + $parents = class_parents(AgentCatBundle::class); // Bundle: Symfony 8.1 deprecates HttpKernel's BundleInterface + $this->assertIsArray($parents); + $this->assertContains(Bundle::class, $parents); $this->assertEquals((object) ['config/' => '%CONFIG_DIR%/'], JsonAssert::at($manifest, 'copy-from-recipe')); $this->assertEquals((object) ['AGENTCAT_PROJECT_ID' => ''], JsonAssert::at($manifest, 'env')); } From bd79c8c9a8a7d4457ee5eda9a7c9eefc60bebfe1 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 00:10:24 +0000 Subject: [PATCH 40/55] test: cover FrankenPHP worker mode, which keeps the 2 s diagnostics window through $_SERVER --- .../Internal/Diagnostics/DiagnosticsTest.php | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php index 759ce30..7ab998c 100644 --- a/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php +++ b/tests/Unit/Internal/Diagnostics/DiagnosticsTest.php @@ -33,10 +33,13 @@ final class DiagnosticsTest extends TestCase private const int WALL_MILLIS = 1_792_163_096_821; - /** Read by diagnostics (D50), and the unprefixed names an application can have for itself. */ + /** + * Read by diagnostics (D50), the unprefixed names an application can have for itself, and FrankenPHP's worker flag, + * which decides the 2 s window (D47). + */ private const array VARIABLES = [ 'AGENTCAT_DISABLE_DIAGNOSTICS', 'AGENTCAT_DIAGNOSTICS_ENDPOINT', 'AGENTCAT_DIAGNOSTICS_TOKEN', - 'DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'APP_ENV', + 'DISABLE_DIAGNOSTICS', 'DIAGNOSTICS_ENDPOINT', 'DIAGNOSTICS_TOKEN', 'APP_ENV', 'FRANKENPHP_WORKER', ]; private Environment $environment; @@ -237,17 +240,25 @@ public function testTheTwoSecondWindowIsCheckedOnQueuePasses(): void $this->assertSame(2, $this->posts()); } - /** @return iterable */ + /** @return iterable}> */ public static function longRunningSapis(): iterable { - yield 'command line' => ['cli']; - yield 'phpdbg' => ['phpdbg']; + yield 'command line' => ['cli', []]; + yield 'phpdbg' => ['phpdbg', []]; + yield 'FrankenPHP worker mode' => ['frankenphp', ['FRANKENPHP_WORKER' => '1']]; // the worker's $_SERVER only } - /** D47: a process that outlives its requests (a stdio server, a worker) keeps the TypeScript 2 s window. */ + /** + * D47: a process that outlives its requests (a stdio server, a worker) keeps the TypeScript 2 s window. + * + * @param array $server more `$_SERVER` entries of the process + */ #[DataProvider('longRunningSapis')] - public function testALongRunningProcessPostsOnceItsOldestLineIsTwoSecondsOld(string $sapi): void + public function testALongRunningProcessPostsOnceItsOldestLineIsTwoSecondsOld(string $sapi, array $server): void { + foreach ($server as $name => $value) { + $_SERVER[$name] = $value; // setUp() cleared VARIABLES, and tearDown() restores them + } $this->init('proj_ABC', sapi: $sapi); Log::write('first'); $this->now += 2.0; From cb65738d7330c868b777be408fb2eed724fa78cd Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 00:14:45 +0000 Subject: [PATCH 41/55] test: share one collector poll, the sent-event filter and the body accessor MockCollector::waitFor() is the one poll loop over a scenario, with a condition; RuntimeTestCase::waitUntil() asserts it, and waitForRequests() and FpmTest use it. MockCollector::withASentEvent() and body() replace the copies in FpmTest, HttpEndToEndTest and StdioEndToEndTest. --- tests/E2E/HttpEndToEndTest.php | 26 ++--------- tests/E2E/StdioEndToEndTest.php | 11 +---- tests/Integration/Support/MockCollector.php | 51 +++++++++++++++++++++ tests/Runtime/FpmTest.php | 37 ++------------- tests/Runtime/Support/RuntimeTestCase.php | 25 ++++++---- 5 files changed, 76 insertions(+), 74 deletions(-) diff --git a/tests/E2E/HttpEndToEndTest.php b/tests/E2E/HttpEndToEndTest.php index 36f7b65..0a3f9ef 100644 --- a/tests/E2E/HttpEndToEndTest.php +++ b/tests/E2E/HttpEndToEndTest.php @@ -317,10 +317,10 @@ public function testEachRequestSendsItsOwnDiagnosticsAfterTheResponse(): void $this->assertCount(2, $sent, 'one diagnostics POST with the per-event line per tracked request'); $this->assertCount(2, $this->collector()->requests($diagnostics), 'and no other: the 2 s window is for long-running processes only'); foreach ($answeredAt as $sessionId => $receivedAt) { - $own = array_values(array_filter($sent, fn(\stdClass $request): bool => str_contains($this->body($request), ' | session ' . $sessionId . ' | '))); + $own = array_values(array_filter($sent, static fn(\stdClass $request): bool => str_contains(MockCollector::body($request), ' | session ' . $sessionId . ' | '))); $this->assertCount(1, $own, 'one diagnostics POST carries the event line of ' . $sessionId); $this->assertSame('/v1/logs', $own[0]->path); - $this->assertSame(1, substr_count($this->body($own[0]), 'Successfully sent event '), 'one line each'); + $this->assertSame(1, substr_count(MockCollector::body($own[0]), 'Successfully sent event '), 'one line each'); $start = $own[0]->start; $this->assertIsFloat($start); $this->assertGreaterThan($receivedAt, $start, 'the diagnostics POST of ' . $sessionId . ' ran after its response'); @@ -390,27 +390,7 @@ private function event(float $deadlineSeconds = self::COLLECTOR_DEADLINE_SECONDS */ private function diagnosticsWithASentEvent(string $scenario, int $count): array { - $deadline = microtime(true) + self::DIAGNOSTICS_DEADLINE_SECONDS; - do { - $sent = array_values(array_filter( - $this->collector()->requests($scenario), - fn(\stdClass $request): bool => str_contains($this->body($request), 'Successfully sent event '), - )); - if (count($sent) >= $count) { - break; - } - usleep(20_000); - } while (microtime(true) < $deadline); - - return $sent; - } - - private function body(\stdClass $request): string - { - $body = $request->body ?? null; - $this->assertIsString($body); - - return $body; + return MockCollector::withASentEvent($this->collector()->waitFor($scenario, static fn(array $requests): bool => count(MockCollector::withASentEvent($requests)) >= $count, self::DIAGNOSTICS_DEADLINE_SECONDS)); } /** How often the server's agentcat.log says that a request rebuilt the registries. */ diff --git a/tests/E2E/StdioEndToEndTest.php b/tests/E2E/StdioEndToEndTest.php index 5695146..9065ca4 100644 --- a/tests/E2E/StdioEndToEndTest.php +++ b/tests/E2E/StdioEndToEndTest.php @@ -266,7 +266,7 @@ public function testStdinEofSendsTheDiagnosticsOnce(): void $requests = $this->collector()->requests($diagnostics); $this->assertCount(1, $requests, sprintf('one diagnostics POST; the server lived %.3f s', $lifetime)); $this->assertSame('/v1/logs', $requests[0]->path); - $body = $this->body($requests[0]); + $body = MockCollector::body($requests[0]); $this->assertStringContainsString('"stringValue":"php"', $body); $this->assertStringContainsString('AgentCat setup complete | project proj_', $body); $this->assertSame(1, substr_count($body, 'Successfully sent event '), 'the per-event line of the EOF drain'); @@ -423,15 +423,6 @@ private function eventWithMessage(string $msg, float $deadlineSeconds): ?\stdCla return null; } - /** The body of a request the collector recorded. */ - private function body(\stdClass $request): string - { - $body = $request->body ?? null; - $this->assertIsString($body); - - return $body; - } - /** The server's agentcat.log. */ private function log(): string { diff --git a/tests/Integration/Support/MockCollector.php b/tests/Integration/Support/MockCollector.php index df356fe..3d59caa 100644 --- a/tests/Integration/Support/MockCollector.php +++ b/tests/Integration/Support/MockCollector.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Integration\Support; use AgentCat\Internal\Json\Json; +use PHPUnit\Framework\Assert; use Symfony\Component\Process\InputStream; use Symfony\Component\Process\Process; @@ -24,6 +25,12 @@ final class MockCollector /** A new port is tried when the server exits at once (another process took the free port first). */ private const int START_ATTEMPTS = 3; + /** How often `waitFor()` reads the requests again. */ + private const int POLL_MICROSECONDS = 20_000; + + /** The SDK's line for an event that the AgentCat API accepted, as diagnostics posts carry it. */ + private const string SENT_EVENT = 'Successfully sent event '; + private bool $stopped = false; private function __construct( @@ -119,6 +126,50 @@ public function requests(string $baseUrl): array return $requests; } + /** + * Reads the requests of a scenario again until `$done` accepts them or `$timeout` seconds pass, and returns the last + * read. The caller asserts what it waited for. + * + * @param \Closure(list<\stdClass>): bool $done + * + * @return list<\stdClass> in arrival order, as `requests()` returns them + * + * @phpstan-impure the collector writes the log while the test runs + */ + public function waitFor(string $baseUrl, \Closure $done, float $timeout): array + { + $deadline = microtime(true) + $timeout; + $requests = $this->requests($baseUrl); + while (!$done($requests) && microtime(true) < $deadline) { + usleep(self::POLL_MICROSECONDS); + $requests = $this->requests($baseUrl); + } + + return $requests; + } + + /** + * The diagnostics posts among `$requests` that carry a `Successfully sent event` line: of any event, or of the event + * `$eventId` (`evt_…`). + * + * @param list<\stdClass> $requests + * + * @return list<\stdClass> + */ + public static function withASentEvent(array $requests, string $eventId = ''): array + { + return array_values(array_filter($requests, static fn(\stdClass $request): bool => str_contains(self::body($request), self::SENT_EVENT . $eventId))); + } + + /** The body of a recorded request; the test fails when the record has none. */ + public static function body(\stdClass $request): string + { + $body = $request->body ?? null; + Assert::assertIsString($body); + + return $body; + } + /** * The largest number of requests the server handled at the same time. * diff --git a/tests/Runtime/FpmTest.php b/tests/Runtime/FpmTest.php index 86a1391..5c7e91a 100644 --- a/tests/Runtime/FpmTest.php +++ b/tests/Runtime/FpmTest.php @@ -5,6 +5,7 @@ namespace AgentCat\Tests\Runtime; use AgentCat\Internal\Json\Json; +use AgentCat\Tests\Integration\Support\MockCollector; use AgentCat\Tests\Runtime\Support\DockerRuntime; use AgentCat\Tests\Runtime\Support\FastCgiClient; use AgentCat\Tests\Runtime\Support\Images; @@ -117,14 +118,14 @@ public function testDiagnosticsArePostedOncePerRequestAfterTheResponse(): void $more = $fpm->request(self::repo() . self::HTTP_SERVER, 'POST', '/mcp', $this->headers('echo', $headers), $this->modernCall('echo', ['msg' => 'two', 'session_id' => 'start'])); $this->assertSame(200, $more['status'], $more['body']); $second = $this->eventId($this->requestAt($events, 1)); - $posts = $this->waitForPostWith($diagnostics, 'Successfully sent event ' . $second); + $posts = $this->waitUntil($diagnostics, static fn(array $posts): bool => MockCollector::withASentEvent($posts, $second) !== [], self::COLLECTOR_DEADLINE_SECONDS, 'the one more request posted its diagnostics'); $this->assertCount(2, $posts, 'one diagnostics POST per request'); foreach ($posts as $post) { $this->assertSame('/v1/logs', $post->path ?? null); - $this->assertSame(1, substr_count($this->body($post), 'AgentCat setup started'), 'each POST carries all the lines of one request'); + $this->assertSame(1, substr_count(MockCollector::body($post), 'AgentCat setup started'), 'each POST carries all the lines of one request'); } - $own = array_values(array_filter($posts, fn(\stdClass $post): bool => str_contains($this->body($post), 'Successfully sent event ' . $first))); + $own = MockCollector::withASentEvent($posts, $first); $this->assertCount(1, $own, 'one POST carries the delivery line of the first request'); $this->assertGreaterThan($answer['endedAt'] + self::AFTER_THE_HOOK_SECONDS, $this->start($own[0]), 'the first request posted its diagnostics after the hook, which ran after the answer'); } @@ -247,7 +248,7 @@ private function requestAt(string $scenario, int $index): \stdClass /** The event that a request to the collector carried. */ private function event(\stdClass $request): \stdClass { - $event = Json::decode($this->body($request)); + $event = Json::decode(MockCollector::body($request)); $this->assertInstanceOf(\stdClass::class, $event); return $event; @@ -261,34 +262,6 @@ private function eventId(\stdClass $request): string return $id; } - /** - * Waits until a request of `$scenario` carries `$line`, and fails the test after the deadline. - * - * @return list<\stdClass> every request of the scenario so far - */ - private function waitForPostWith(string $scenario, string $line): array - { - $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; - do { - $posts = self::$collector->requests($scenario); - foreach ($posts as $post) { - if (str_contains($this->body($post), $line)) { - return $posts; - } - } - usleep(20_000); - } while (microtime(true) < $deadline); - self::fail(sprintf('No request of %s carried "%s" within %.1f s: %d requests', $scenario, $line, self::COLLECTOR_DEADLINE_SECONDS, count($posts))); - } - - private function body(\stdClass $request): string - { - $body = $request->body ?? null; - $this->assertIsString($body); - - return $body; - } - /** When the request reached the collector. */ private function start(\stdClass $request): float { diff --git a/tests/Runtime/Support/RuntimeTestCase.php b/tests/Runtime/Support/RuntimeTestCase.php index 47b8fcb..e9c2d46 100644 --- a/tests/Runtime/Support/RuntimeTestCase.php +++ b/tests/Runtime/Support/RuntimeTestCase.php @@ -41,8 +41,6 @@ abstract class RuntimeTestCase extends TestCase 'app/storage/logs', ]; - private const int POLL_MICROSECONDS = 20_000; - protected static MockCollector $collector; /** Whether this process removed the containers of dead runs yet. */ @@ -185,13 +183,22 @@ final protected function env(?string $collector = null): array */ final protected function waitForRequests(string $scenario, int $count, float $timeout): array { - $deadline = microtime(true) + $timeout; - $requests = self::$collector->requests($scenario); - while (count($requests) < $count && microtime(true) < $deadline) { - usleep(self::POLL_MICROSECONDS); - $requests = self::$collector->requests($scenario); - } - $this->assertGreaterThanOrEqual($count, count($requests), sprintf('%d of %d requests reached the collector within %.1f s', count($requests), $count, $timeout)); + return $this->waitUntil($scenario, static fn(array $requests): bool => count($requests) >= $count, $timeout, sprintf('%d requests reached the collector', $count)); + } + + /** + * Waits until `$done` accepts the requests of the collector's scenario, and fails the test after `$timeout` seconds + * with `$what` and the number of requests. + * + * @param \Closure(list<\stdClass>): bool $done + * @param string $what what `$done` waits for, for the failure message + * + * @return list<\stdClass> every request of the scenario so far, in arrival order + */ + final protected function waitUntil(string $scenario, \Closure $done, float $timeout, string $what): array + { + $requests = self::$collector->waitFor($scenario, $done, $timeout); + $this->assertTrue($done($requests), sprintf('Not within %.1f s: %s (%d requests)', $timeout, $what, count($requests))); return $requests; } From 0a13c050d322b16ac5bd4b821a44e6ab9c9c27f7 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 01:37:59 +0000 Subject: [PATCH 42/55] test: prove FrankenPHP delivery in classic and worker mode Classic mode answers before the shutdown delivery and gives the drain its own time limit, which under ZTS counts wall time. In worker mode the raw frankenphp_handle_request() loop flushes after each request, waits at most 1 s with a slow API (D58), and its stop delivers the rest. A server tracked at worker boot or in the first request keeps the 2 s diagnostics window. PHP logs its errors in these containers (log_errors 1): the image has no php.ini, so a time-limit fatal after the answer went only to the closed response. --- composer-dependency-analyser.php | 4 +- tests/Runtime/Fixtures/frankenphp/classic.php | 17 + tests/Runtime/Fixtures/frankenphp/slow.php | 12 + tests/Runtime/Fixtures/frankenphp/worker.php | 49 +++ tests/Runtime/FrankenPhpTest.php | 299 ++++++++++++++++++ 5 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 tests/Runtime/Fixtures/frankenphp/classic.php create mode 100644 tests/Runtime/Fixtures/frankenphp/slow.php create mode 100644 tests/Runtime/Fixtures/frankenphp/worker.php create mode 100644 tests/Runtime/FrankenPhpTest.php diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 57fbcdb..f84d1dd 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -18,8 +18,8 @@ ->ignoreErrorsOnPackageAndPath('psr/container', __DIR__ . '/src/McpSdk', [ErrorType::SHADOW_DEPENDENCY]) // Psr\EventDispatcher\EventDispatcherInterface arrives with mcp/sdk; the test double stands in for a customer's dispatcher. ->ignoreErrorsOnPackageAndPath('psr/event-dispatcher', __DIR__ . '/tests/Support/McpSdk/RecordingDispatcher.php', [ErrorType::SHADOW_DEPENDENCY]) - // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixture builds its request from the globals with it, like a customer. - ->ignoreErrorsOnPackageAndPath('php-http/discovery', __DIR__ . '/tests/E2E/Fixtures', [ErrorType::SHADOW_DEPENDENCY]) + // Http\Discovery\Psr17Factory arrives with mcp/sdk; the HTTP fixtures build their request from the globals with it, like a customer. + ->ignoreErrorsOnPackageAndPaths('php-http/discovery', [__DIR__ . '/tests/E2E/Fixtures', __DIR__ . '/tests/Runtime/Fixtures'], [ErrorType::SHADOW_DEPENDENCY]) // laravel/mcp and Laravel are optional (01 AC-90): dev dependencies used only by the Laravel adapter and bridge. ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/LaravelMcp', [ErrorType::DEV_DEPENDENCY_IN_PROD]) ->ignoreErrorsOnPackageAndPath('laravel/mcp', __DIR__ . '/src/Laravel', [ErrorType::DEV_DEPENDENCY_IN_PROD]) diff --git a/tests/Runtime/Fixtures/frankenphp/classic.php b/tests/Runtime/Fixtures/frankenphp/classic.php new file mode 100644 index 0000000..765da10 --- /dev/null +++ b/tests/Runtime/Fixtures/frankenphp/classic.php @@ -0,0 +1,17 @@ + function_exists($name)) ?? 'null')); +header('X-AgentCat-Wait: ' . RequestEndWait::seconds(console: false)); + +require dirname(__DIR__, 3) . '/E2E/Fixtures/buffered-server.php'; diff --git a/tests/Runtime/Fixtures/frankenphp/slow.php b/tests/Runtime/Fixtures/frankenphp/slow.php new file mode 100644 index 0000000..967f9b0 --- /dev/null +++ b/tests/Runtime/Fixtures/frankenphp/slow.php @@ -0,0 +1,12 @@ +setServerInfo('worker-server', '1.0.0') + ->addTool(static fn(string $msg): string => 'echo: ' . $msg, 'echo', description: 'Echo', inputSchema: ['type' => 'object', 'properties' => ['msg' => ['type' => 'string']], 'required' => ['msg']]); + AgentCat::track($builder, 'proj_' . str_repeat('w', 27), new Options(apiBaseUrl: $collector)); + + return $builder; +}; +$bootServer = getenv('AGENTCAT_E2E_BOOT_SERVER') === '1' ? $tracked(null)->build() : null; + +$handler = static function () use ($tracked, $bootServer): void { + $finish = FinishRequest::functionFor(PHP_SAPI, $_SERVER, static fn(string $name): bool => function_exists($name)); + $collector = is_string($_SERVER['HTTP_X_E2E_COLLECTOR'] ?? null) ? $_SERVER['HTTP_X_E2E_COLLECTOR'] : null; + $server = $bootServer ?? $tracked($collector)->build(); + $factory = new Psr17Factory(); + $response = $server->run(new StreamableHttpTransport($factory->createServerRequestFromGlobals(), $factory, $factory)); + http_response_code($response->getStatusCode()); + header('X-AgentCat-Finish: ' . ($finish ?? 'null')); + header('X-AgentCat-Wait: ' . RequestEndWait::seconds(console: false)); + header('X-E2E-Server: ' . ($bootServer instanceof Server ? 'boot' : 'request')); + foreach ($response->getHeaders() as $name => $values) { + header($name . ': ' . implode(', ', $values)); + } + echo $response->getBody(); +}; + +while (frankenphp_handle_request($handler)) { + AgentCat::flush(RequestEndWait::WORKER_SECONDS); // D58: the documented loop waits at most 1 s per request +} diff --git a/tests/Runtime/FrankenPhpTest.php b/tests/Runtime/FrankenPhpTest.php new file mode 100644 index 0000000..641c807 --- /dev/null +++ b/tests/Runtime/FrankenPhpTest.php @@ -0,0 +1,299 @@ +scenario([['status' => 202, 'delayMs' => 2000]]); + $client = new McpHttpClient($this->frankenphp(worker: false) . '/classic.php'); + $started = microtime(true); + + $answer = $client->post($this->call(1, 'hi'), $this->headers($scenario)); + + $this->assertLessThan(self::ANSWER_SECONDS, $answer['receivedAt'] - $started, 'FrankenPHP held the answer for the delivery'); + $this->assertEchoed('hi', $answer, 1); + $this->assertSame('frankenphp_finish_request', $answer['headers']['x-agentcat-finish'] ?? null); + $this->assertSame('0', $answer['headers']['x-agentcat-wait'] ?? null, 'a per-request SAPI leaves the delivery to the shutdown function'); + $deliveries = $this->waitForRequests($scenario, 1, self::COLLECTOR_DEADLINE_SECONDS); + $this->assertCount(1, $deliveries); + $this->assertGreaterThan($answer['receivedAt'] + 1.0, $this->time($deliveries[0], 'end'), 'the collector answered the delivery after the client had its answer'); + } + + /** + * Plan E1 header conflict 14: under ZTS, `max_execution_time` counts wall time, so a request that used most of its + * 2 s would be killed during the drain. The drain sets its own limit: it waits for the collector past the 2 s, + * logs the delivery and then posts its diagnostics, the last step of the shutdown. The collector records a request + * after its delay also when the drain dies, so the diagnostics POST is the proof. + */ + #[MaximumDuration(40000)] + public function testTheDrainHasItsOwnTimeLimitUnderZts(): void + { + $events = self::$collector->scenario([['status' => 202, 'delayMs' => 2000]]); + $diagnostics = self::$collector->scenario(); + $client = new McpHttpClient($this->frankenphp(worker: false, env: $this->diagnosticsTo($diagnostics)) . '/slow.php'); + $started = microtime(true); + + // slow.php works 1 s of wall time of its 2 s limit, then answers; the delivery waits 2 s more (ruling P16: about + // 1 s of slack before the limit, and the drain ends about 1 s after it). + $answer = $client->post($this->call(1, 'hi'), $this->headers($events)); + + $this->assertEchoed('hi', $answer, 1); + $deliveries = $this->waitForRequests($events, 1, self::COLLECTOR_DEADLINE_SECONDS); + $this->assertCount(1, $deliveries); + $this->assertGreaterThan($started + self::TIME_LIMIT_SECONDS, $this->time($deliveries[0], 'end'), 'the collector answered after the 2 s limit'); + $this->waitUntil($diagnostics, static fn(array $posts): bool => MockCollector::withASentEvent($posts) !== [], self::COLLECTOR_DEADLINE_SECONDS, 'the drain logged the delivery and posted its diagnostics'); + $this->assertStringNotContainsString('Maximum execution time', $this->runtime()->logs()); + } + + #[MaximumDuration(40000)] + public function testWorkerModeIsDetectedAndTheLoopsFlushDeliversEachRequest(): void + { + $scenario = self::$collector->scenario(); + $client = new McpHttpClient($this->frankenphp(worker: true) . '/worker.php'); + + foreach (['one', 'two'] as $i => $msg) { + $answer = $client->post($this->call($i + 1, $msg), $this->headers($scenario)); + $this->assertEchoed($msg, $answer, $i + 1); + $this->assertSame('null', $answer['headers']['x-agentcat-finish'] ?? null, 'no finish function in worker mode'); + $this->assertSame('1', $answer['headers']['x-agentcat-wait'] ?? null, 'D58: a worker waits at most 1 s after each request'); + } + + $this->assertCount(2, $this->waitForRequests($scenario, 2, self::COLLECTOR_DEADLINE_SECONDS)); // the worker is still running + } + + #[MaximumDuration(60000)] + public function testWithASlowApiTheWorkerLoopWaitsAtMostOneSecondAndItsStopDelivers(): void + { + $scenario = self::$collector->scenario([], ['status' => 202, 'delayMs' => 4000]); + // one worker thread: a request waits for the previous one's flush; no --rm: the logs are read after the stop (P16) + $client = new McpHttpClient($this->frankenphp(worker: true, remove: false) . '/worker.php'); + $headers = $this->headers($scenario); + $this->assertEchoed('m1', $client->post($this->call(1, 'm1'), $headers), 1); + $started = microtime(true); + + $answer = $client->post($this->call(2, 'm2'), $headers); + + $this->assertLessThan(self::WORKER_WAIT_SECONDS, microtime(true) - $started, 'the loop waited longer than the D58 cap'); + $this->assertEchoed('m2', $answer, 2); + $runtime = $this->runtime(); + $runtime->stopGracefully(15.0); // frankenphp_handle_request() returns false; the script's shutdown function delivers + $logs = $runtime->logs(); + $runtime->stop(); + $this->assertStringNotContainsString('Fatal error', $logs); + $this->assertSame(2, substr_count($this->sdkLog(), self::SENT_EVENT), 'the worker\'s stop waited for both deliveries'); + $this->assertCount(2, $this->waitForRequests($scenario, 2, self::COLLECTOR_DEADLINE_SECONDS)); + } + + /** @return iterable, string}> the worker's environment, and its `X-E2E-Server` */ + public static function workerServers(): iterable + { + yield 'a server tracked at worker boot' => [['AGENTCAT_E2E_BOOT_SERVER' => '1'], 'boot']; + yield 'a server tracked in each request' => [[], 'request']; + } + + /** + * Plan E2 Task 8 carry: the first `track()` decides the diagnostics window, at worker boot (outside any request: + * FrankenPHP sets `FRANKENPHP_WORKER` in `$_SERVER` and the environment) or in the first request (only the + * environment has it). The worker is detected either way, so its queue passes post the lines once they are 2 s + * old, while the worker still runs. A worker detected as classic would post nothing until it stops. + * + * @param array $env + */ + #[DataProvider('workerServers')] + #[MaximumDuration(40000)] + public function testAWorkerPostsItsDiagnosticsWhileItRuns(array $env, string $server): void + { + $events = self::$collector->scenario(); + $diagnostics = self::$collector->scenario(); + $started = microtime(true); + $client = new McpHttpClient($this->frankenphp(worker: true, collector: $events, env: [...$env, ...$this->diagnosticsTo($diagnostics)]) . '/worker.php'); + $headers = HttpHarness::modernHeaders('tools/call', 'echo'); // the AgentCat API is the container's AGENTCAT_API_URL + + // Each request runs queue passes (the pump at the handler's entry, then the loop's flush); the first one after + // the first lines are 2 s old posts them. + $calls = 0; + $deadline = microtime(true) + self::COLLECTOR_DEADLINE_SECONDS; + do { + ++$calls; + $answer = $client->post($this->call($calls, 'm' . $calls), $headers); + $this->assertEchoed('m' . $calls, $answer, $calls); + $this->assertSame($server, $answer['headers']['x-e2e-server'] ?? null); + $posts = self::$collector->waitFor($diagnostics, static fn(array $posts): bool => $posts !== [], self::REQUEST_INTERVAL_SECONDS); + } while ($posts === [] && microtime(true) < $deadline); + + $this->assertNotSame([], $posts, sprintf('No diagnostics POST within %.1f s of requests to the running worker', self::COLLECTOR_DEADLINE_SECONDS)); + $this->assertStringContainsString('AgentCat setup started', MockCollector::body($posts[0]), 'the first POST carries the first track()\'s lines'); + $this->assertGreaterThan($started + self::DIAGNOSTICS_WINDOW_SECONDS, $this->time($posts[0], 'start'), 'the 2 s window, not an earlier post'); + ++$calls; + $this->assertEchoed('m' . $calls, $client->post($this->call($calls, 'm' . $calls), $headers), $calls); // the worker still runs + $this->assertCount($calls, $this->waitForRequests($events, $calls, self::COLLECTOR_DEADLINE_SECONDS)); + } + + /** + * FrankenPHP on 127.0.0.1, serving `Fixtures/frankenphp/` in classic mode, or with `worker.php` as a worker of one + * thread, from a Caddyfile in `out()`. PHP logs its errors (FrankenPHP's image has no php.ini, so `log_errors` is + * off and a fatal error after the answer would leave no trace). The container gets `RuntimeTestCase::env()` with + * `$collector` as the AgentCat API, plus `$env`; `HOME` is `/out`, where AgentCat writes its log. + * + * @param array $env + * @param bool $remove false keeps the stopped container for `logs()` until `stop()` + * + * @return string the base URL + */ + private function frankenphp(bool $worker, ?string $collector = null, array $env = [], bool $remove = true): string + { + $out = $this->out(); + $port = $this->freePort(); + $fixtures = self::repo() . self::FIXTURES; + $caddyfile = $out . '/Caddyfile'; + file_put_contents($caddyfile, implode("\n", [ + '{', + "\tauto_https off", + "\tadmin off", + "\tfrankenphp {", + "\t\tphp_ini max_execution_time " . (int) self::TIME_LIMIT_SECONDS, + "\t\tphp_ini log_errors 1", // the image has no php.ini: without it, PHP errors go only to the response + ...($worker ? ["\t\tworker " . $fixtures . '/worker.php 1'] : []), + "\t}", + '}', + '', + 'http://127.0.0.1:' . $port . ' {', + "\troot * " . $fixtures, + "\tphp_server", + '}', + '', + ])); + $this->runtime = DockerRuntime::start( + Images::FRANKENPHP, + ['frankenphp', 'run', '--config', self::CADDYFILE], + [...$this->env($collector), ...$env], + [$caddyfile => self::CADDYFILE, $out => '/out'], + port: $port, + remove: $remove, + ); + + return 'http://127.0.0.1:' . $port; + } + + private function runtime(): DockerRuntime + { + $this->assertInstanceOf(DockerRuntime::class, $this->runtime, 'frankenphp() starts the container'); + + return $this->runtime; + } + + /** + * Diagnostics on, sent to `$scenario`, for every request of the container. + * + * @return array + */ + private function diagnosticsTo(string $scenario): array + { + return ['AGENTCAT_DISABLE_DIAGNOSTICS' => 'false', 'AGENTCAT_DIAGNOSTICS_ENDPOINT' => $scenario]; + } + + /** + * The headers of a 2026-07-28 `tools/call` of `echo`, and the AgentCat API of the server built per request. + * + * @return array + */ + private function headers(string $scenario): array + { + return [...HttpHarness::modernHeaders('tools/call', 'echo'), 'X-E2E-Collector' => $scenario]; + } + + /** + * A 2026-07-28 `tools/call` of `echo`: no handshake, the client in `_meta`. + * + * @return array + */ + private function call(int $id, string $msg): array + { + $params = HttpHarness::modernParams(['name' => 'echo', 'arguments' => (object) ['msg' => $msg, 'session_id' => 'start']], ['name' => 'fp', 'version' => '1']); + + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/call', 'params' => $params]; + } + + /** @param array{status: int, headers: array, body: string, receivedAt: float} $answer */ + private function assertEchoed(string $msg, array $answer, int $id): void + { + $this->assertSame(200, $answer['status'], $answer['body']); + $result = HttpHarness::result(HttpHarness::decode($answer['body'], $answer['headers']['content-type'] ?? '', $id)); + $this->assertSame('echo: ' . $msg, JsonAssert::at($result, 'content', 1, 'text')); + } + + /** When the collector received (`start`) or answered (`end`) a request. */ + private function time(\stdClass $request, string $field): float + { + $time = $request->{$field} ?? null; + $this->assertIsFloat($time); + + return $time; + } + + /** AgentCat's own log in the container (`$HOME/agentcat.log`, HOME=/out). */ + private function sdkLog(): string + { + $log = file_get_contents($this->out() . '/agentcat.log'); + $this->assertIsString($log); + + return $log; + } +} From f84a075d953aac36c159619b6958efe9d3e3d66f Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 02:02:02 +0000 Subject: [PATCH 43/55] test: bind FrankenPHP to 127.0.0.1 and prove the finish call on an answer without Content-Length The site address alone is only a host matcher, so Caddy listened on every interface of the host network. The Caddyfile now binds 127.0.0.1, and each test checks that 127.0.0.2 is refused. A classic answer without Content-Length reaches the client before the delivery only through the shutdown function's frankenphp_finish_request() call. Both classic tests send an untimed warm-up request first. --- tests/Runtime/Fixtures/frankenphp/unsized.php | 13 ++++ tests/Runtime/FrankenPhpTest.php | 62 ++++++++++++++++--- tests/Runtime/Support/DockerRuntime.php | 10 ++- 3 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 tests/Runtime/Fixtures/frankenphp/unsized.php diff --git a/tests/Runtime/Fixtures/frankenphp/unsized.php b/tests/Runtime/Fixtures/frankenphp/unsized.php new file mode 100644 index 0000000..b456907 --- /dev/null +++ b/tests/Runtime/Fixtures/frankenphp/unsized.php @@ -0,0 +1,13 @@ +scenario([['status' => 202, 'delayMs' => 2000]]); - $client = new McpHttpClient($this->frankenphp(worker: false) . '/classic.php'); + $client = $this->warmClassicClient('/classic.php'); $started = microtime(true); - $answer = $client->post($this->call(1, 'hi'), $this->headers($scenario)); + $answer = $client->post($this->call(2, 'hi'), $this->headers($scenario)); $this->assertLessThan(self::ANSWER_SECONDS, $answer['receivedAt'] - $started, 'FrankenPHP held the answer for the delivery'); - $this->assertEchoed('hi', $answer, 1); + $this->assertEchoed('hi', $answer, 2); $this->assertSame('frankenphp_finish_request', $answer['headers']['x-agentcat-finish'] ?? null); $this->assertSame('0', $answer['headers']['x-agentcat-wait'] ?? null, 'a per-request SAPI leaves the delivery to the shutdown function'); - $deliveries = $this->waitForRequests($scenario, 1, self::COLLECTOR_DEADLINE_SECONDS); - $this->assertCount(1, $deliveries); - $this->assertGreaterThan($answer['receivedAt'] + 1.0, $this->time($deliveries[0], 'end'), 'the collector answered the delivery after the client had its answer'); + $this->assertDeliveredAfter($answer, $scenario); + } + + /** + * Without a Content-Length, the client sees the end of the answer only when FrankenPHP ends the response. Closing + * and flushing the output buffers (D57) sends the bytes but not the end, so only the shutdown function's call of + * `frankenphp_finish_request()` gives the client its answer before the delivery. + */ + #[MaximumDuration(40000)] + public function testClassicModeEndsAnAnswerWithoutContentLengthBeforeTheDelivery(): void + { + $scenario = self::$collector->scenario([['status' => 202, 'delayMs' => 2000]]); + $client = $this->warmClassicClient('/unsized.php'); + $started = microtime(true); + + $answer = $client->post($this->call(2, 'hi'), $this->headers($scenario)); + + $this->assertLessThan(self::ANSWER_SECONDS, $answer['receivedAt'] - $started, 'the shutdown function did not end the answer with frankenphp_finish_request() before the delivery'); + $this->assertSame('removed', $answer['headers']['x-e2e-content-length'] ?? null, 'PHP sent the answer without its Content-Length'); + $this->assertEchoed('hi', $answer, 2); + $this->assertDeliveredAfter($answer, $scenario); } /** @@ -187,8 +205,8 @@ public function testAWorkerPostsItsDiagnosticsWhileItRuns(array $env, string $se } /** - * FrankenPHP on 127.0.0.1, serving `Fixtures/frankenphp/` in classic mode, or with `worker.php` as a worker of one - * thread, from a Caddyfile in `out()`. PHP logs its errors (FrankenPHP's image has no php.ini, so `log_errors` is + * FrankenPHP on 127.0.0.1 only (`bind`, checked: 127.0.0.2 is refused), serving `Fixtures/frankenphp/` in classic + * mode, or with `worker.php` as a worker of one thread, from a Caddyfile in `out()`. PHP logs its errors (FrankenPHP's image has no php.ini, so `log_errors` is * off and a fatal error after the answer would leave no trace). The container gets `RuntimeTestCase::env()` with * `$collector` as the AgentCat API, plus `$env`; `HOME` is `/out`, where AgentCat writes its log. * @@ -215,6 +233,7 @@ private function frankenphp(bool $worker, ?string $collector = null, array $env '}', '', 'http://127.0.0.1:' . $port . ' {', + "\tbind 127.0.0.1", // the site address alone names a host to match, and Caddy would listen on every interface "\troot * " . $fixtures, "\tphp_server", '}', @@ -228,10 +247,37 @@ private function frankenphp(bool $worker, ?string $collector = null, array $env port: $port, remove: $remove, ); + $this->assertFalse(DockerRuntime::accepts($port, '127.0.0.2'), 'FrankenPHP listens beyond 127.0.0.1'); return 'http://127.0.0.1:' . $port; } + /** + * FrankenPHP in classic mode, after one untimed request to `$script` with its own scenario, whose delivery the + * collector has received: the timed request then meets a warm OPcache and no drain of the warm-up (ruling P16). + */ + private function warmClassicClient(string $script): McpHttpClient + { + $warmUp = self::$collector->scenario(); + $client = new McpHttpClient($this->frankenphp(worker: false) . $script); + $this->assertEchoed('warm', $client->post($this->call(1, 'warm'), $this->headers($warmUp)), 1); + $this->waitForRequests($warmUp, 1, self::COLLECTOR_DEADLINE_SECONDS); + + return $client; + } + + /** + * The scenario received one delivery, and answered it more than 1 s after the client had its answer. + * + * @param array{status: int, headers: array, body: string, receivedAt: float} $answer + */ + private function assertDeliveredAfter(array $answer, string $scenario): void + { + $deliveries = $this->waitForRequests($scenario, 1, self::COLLECTOR_DEADLINE_SECONDS); + $this->assertCount(1, $deliveries); + $this->assertGreaterThan($answer['receivedAt'] + 1.0, $this->time($deliveries[0], 'end'), 'the collector answered the delivery after the client had its answer'); + } + private function runtime(): DockerRuntime { $this->assertInstanceOf(DockerRuntime::class, $this->runtime, 'frankenphp() starts the container'); diff --git a/tests/Runtime/Support/DockerRuntime.php b/tests/Runtime/Support/DockerRuntime.php index fbd3fc0..1e045b3 100644 --- a/tests/Runtime/Support/DockerRuntime.php +++ b/tests/Runtime/Support/DockerRuntime.php @@ -305,7 +305,7 @@ private function waitForPort(int $port, float $timeout): void { $deadline = microtime(true) + $timeout; $nextRunningCheck = microtime(true) + self::RUNNING_CHECK_SECONDS; - while (!$this->accepts($port)) { + while (!self::accepts($port)) { if (microtime(true) >= $nextRunningCheck) { if (!$this->running()) { throw new \RuntimeException(sprintf('Container %s ended before port %d accepted a connection: %s', $this->name, $port, $this->logsOrWhy())); @@ -319,12 +319,16 @@ private function waitForPort(int $port, float $timeout): void } } - private function accepts(int $port): bool + /** + * Whether `$host:$port` accepts a TCP connection now (one attempt). A runtime that listens on 127.0.0.1 only + * refuses another loopback address, such as 127.0.0.2. + */ + public static function accepts(int $port, string $host = '127.0.0.1'): bool { set_error_handler(static fn(): bool => true); // connection refused while the runtime boots try { - $socket = stream_socket_client('tcp://127.0.0.1:' . $port, $errorCode, $errorMessage, self::CONNECT_SECONDS); + $socket = stream_socket_client('tcp://' . $host . ':' . $port, $errorCode, $errorMessage, self::CONNECT_SECONDS); } finally { restore_error_handler(); } From 6ac71707f0b4abf3872164d994b25a03b1cf29a5 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 03:01:22 +0000 Subject: [PATCH 44/55] fix: end the answer with fastcgi_finish_request() under NGINX Unit Unit's PHP module reports the SAPI of php -S (cli-server), so the SDK treated it as a SAPI without a finish-request function. Unit has fastcgi_finish_request(), and without the call an answer without a Content-Length reached the client only when the delivery ended. Unit names itself in SERVER_SOFTWARE (Unit/); php -S does not, so php -S keeps the output-buffer path (D57). --- src/Internal/Delivery/FinishRequest.php | 19 +++++++++++++++++-- .../Internal/Delivery/FinishRequestTest.php | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Internal/Delivery/FinishRequest.php b/src/Internal/Delivery/FinishRequest.php index 3031a46..e861bfb 100644 --- a/src/Internal/Delivery/FinishRequest.php +++ b/src/Internal/Delivery/FinishRequest.php @@ -14,8 +14,9 @@ final class FinishRequest { /** * PHP-FPM `fastcgi_finish_request`, LiteSpeed `litespeed_finish_request`, FrankenPHP (classic mode) - * `frankenphp_finish_request` or its `fastcgi_finish_request` alias; null elsewhere. FrankenPHP worker mode - * (`isFrankenPhpWorker()`) gets null: its shutdown functions do not run per request. + * `frankenphp_finish_request` or its `fastcgi_finish_request` alias, NGINX Unit (`isUnit()`) + * `fastcgi_finish_request`; null elsewhere. FrankenPHP worker mode (`isFrankenPhpWorker()`) gets null: its shutdown + * functions do not run per request. * * @param array $server `$_SERVER` * @param \Closure(string): bool $functionExists @@ -27,6 +28,7 @@ public static function functionFor(string $sapi, array $server, \Closure $functi $sapi === 'fpm-fcgi' => 'fastcgi_finish_request', $sapi === 'litespeed' => 'litespeed_finish_request', $sapi === 'frankenphp' && !self::isFrankenPhpWorker($server, $getenv) => $functionExists('frankenphp_finish_request') ? 'frankenphp_finish_request' : 'fastcgi_finish_request', + $sapi === 'cli-server' && self::isUnit($server) => 'fastcgi_finish_request', default => null, }; @@ -44,6 +46,19 @@ public static function isFrankenPhpWorker(array $server, ?\Closure $getenv = nul return isset($server['FRANKENPHP_WORKER']) || ($getenv ?? getenv(...))('FRANKENPHP_WORKER') !== false; } + /** + * NGINX Unit: its PHP module reports the SAPI of `php -S` (`cli-server`), and Unit names itself in + * `SERVER_SOFTWARE` (`Unit/`), where `php -S` names PHP's development server. + * + * @param array $server `$_SERVER` + */ + private static function isUnit(array $server): bool + { + $software = $server['SERVER_SOFTWARE'] ?? null; + + return is_string($software) && str_starts_with($software, 'Unit/'); + } + /** * The process outlives the request: the command line (Octane on Swoole or RoadRunner, a Symfony Runtime worker, a * stdio server) or FrankenPHP worker mode. diff --git a/tests/Unit/Internal/Delivery/FinishRequestTest.php b/tests/Unit/Internal/Delivery/FinishRequestTest.php index e97abdb..4b54e60 100644 --- a/tests/Unit/Internal/Delivery/FinishRequestTest.php +++ b/tests/Unit/Internal/Delivery/FinishRequestTest.php @@ -35,7 +35,12 @@ public static function runtimes(): iterable yield 'FrankenPHP worker mode' => ['frankenphp', ['FRANKENPHP_WORKER' => '1'], ['frankenphp_finish_request', 'fastcgi_finish_request'], null]; yield 'CLI' => ['cli', [], ['fastcgi_finish_request', 'litespeed_finish_request', 'frankenphp_finish_request'], null]; yield 'built-in web server' => ['cli-server', [], ['fastcgi_finish_request'], null]; + yield 'built-in web server with a fastcgi_finish_request() of its own' => ['cli-server', ['SERVER_SOFTWARE' => 'PHP 8.3.26 Development Server'], ['fastcgi_finish_request'], null]; + yield 'NGINX Unit' => ['cli-server', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], ['fastcgi_finish_request'], 'fastcgi_finish_request']; + yield 'NGINX Unit without the function' => ['cli-server', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], [], null]; + yield 'a server software that only contains Unit/' => ['cli-server', ['SERVER_SOFTWARE' => 'NotUnit/1.0'], ['fastcgi_finish_request'], null]; yield 'Apache mod_php' => ['apache2handler', [], ['fastcgi_finish_request'], null]; + yield 'Apache mod_php with Unit\'s server software' => ['apache2handler', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], ['fastcgi_finish_request'], null]; } /** @@ -94,6 +99,7 @@ public static function processes(): iterable yield 'PHP-FPM with a stray worker variable' => ['fpm-fcgi', ['FRANKENPHP_WORKER' => '1'], false]; yield 'Apache mod_php' => ['apache2handler', [], false]; yield 'built-in web server' => ['cli-server', [], false]; + yield 'NGINX Unit' => ['cli-server', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], false]; } /** @param array $server */ @@ -109,6 +115,8 @@ public static function requestEnds(): iterable yield 'Apache mod_php' => ['apache2handler', [], [], true]; yield 'php-cgi' => ['cgi-fcgi', [], [], true]; yield 'built-in web server' => ['cli-server', [], ['fastcgi_finish_request'], true]; + yield 'NGINX Unit' => ['cli-server', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], ['fastcgi_finish_request'], false]; + yield 'NGINX Unit without the function' => ['cli-server', ['SERVER_SOFTWARE' => 'Unit/1.34.2'], [], true]; yield 'PHP-FPM' => ['fpm-fcgi', [], ['fastcgi_finish_request'], false]; yield 'PHP-FPM with the function disabled' => ['fpm-fcgi', [], [], true]; yield 'LiteSpeed' => ['litespeed', [], ['litespeed_finish_request'], false]; @@ -142,6 +150,15 @@ public function testResponseEndsWithTheScriptReadsWorkerModeFromTheEnvironment() $this->assertTrue(FinishRequest::outlivesRequest('frankenphp', [], $this->environment('1'))); } + /** Only a string names the server software: `$_SERVER` is an array that any code can write to. */ + public function testUnitIsNamedByAStringServerSoftware(): void + { + $exists = static fn(string $name): bool => true; + + $this->assertNull(FinishRequest::functionFor('cli-server', ['SERVER_SOFTWARE' => ['Unit/1.34.2']], $exists)); + $this->assertNull(FinishRequest::functionFor('cli-server', ['SERVER_SOFTWARE' => null], $exists)); + } + public function testDetectFindsNothingUnderTheCliTestRunner(): void { $this->assertSame('cli', PHP_SAPI); From c7ef09c1f74f985cc6731a78b96852a2fba79e56 Mon Sep 17 00:00:00 2001 From: Naseem Alnaji Date: Tue, 20 Oct 2026 03:05:55 +0000 Subject: [PATCH 45/55] test: prove CLI, Swoole, mod_php and NGINX Unit delivery in Docker CliTest: an mcp/sdk and a laravel/mcp stdio server in the CLI image (docker run --init) deliver the queued event at SIGTERM and end with 143; stdout carries only JSON-RPC. The first event installs no SIGTERM handler under ext-swoole, and one in the CLI image, both with pcntl. ApacheTest: mod_php with output_buffering=4096 answers an mcp/sdk server with its own Content-Length (D57), a symfony/mcp-bundle server (D62) and a laravel/mcp server (D59) before the delivery. UnitTest: NGINX Unit reports cli-server; the answer reaches the client before the delivery with and without a Content-Length. RuntimeTestCase now holds the echo call, its headers and checks, the collector times, the diagnostics switch and the /out file reader that the FPM, FrankenPHP, Apache and Unit tests share. --- tests/E2E/Support/StdioMcpClient.php | 24 +++-- tests/Integration/Support/MockCollector.php | 4 +- tests/Runtime/ApacheTest.php | 114 ++++++++++++++++++++ tests/Runtime/CliTest.php | 102 ++++++++++++++++++ tests/Runtime/Fixtures/swoole-guard.php | 20 ++++ tests/Runtime/Fixtures/unit.php | 10 ++ tests/Runtime/FpmTest.php | 26 +---- tests/Runtime/FrankenPhpTest.php | 84 +++------------ tests/Runtime/Support/RuntimeTestCase.php | 67 ++++++++++++ tests/Runtime/UnitTest.php | 107 ++++++++++++++++++ 10 files changed, 456 insertions(+), 102 deletions(-) create mode 100644 tests/Runtime/ApacheTest.php create mode 100644 tests/Runtime/CliTest.php create mode 100644 tests/Runtime/Fixtures/swoole-guard.php create mode 100644 tests/Runtime/Fixtures/unit.php create mode 100644 tests/Runtime/UnitTest.php diff --git a/tests/E2E/Support/StdioMcpClient.php b/tests/E2E/Support/StdioMcpClient.php index a6a3227..d89d799 100644 --- a/tests/E2E/Support/StdioMcpClient.php +++ b/tests/E2E/Support/StdioMcpClient.php @@ -10,11 +10,11 @@ use Symfony\Component\Process\Process; /** - * A raw JSON-RPC client for an MCP stdio server: `php