Skip to content

Symfony bundle and runtime delivery tests in Docker - #7

Merged
naji247 merged 55 commits into
feat/plan-a-foundationfrom
feat/plan-e2-symfony-runtime
Oct 22, 2026
Merged

naji247 merged 55 commits into
feat/plan-a-foundationfrom
feat/plan-e2-symfony-runtime

Conversation

@naji247

@naji247 naji247 commented Oct 20, 2026

Copy link
Copy Markdown
Member

Seventh part of the PHP AgentCat SDK: the Symfony bundle and runtime delivery proofs in Docker. Stacked on #6; review #1 to #6 first.

What is in it

  • Symfony with no code: AgentCat\Symfony\AgentCatBundle tracks every symfony/mcp-bundle server (0.13 and 0.14, Symfony 7.3 and 8.x) when the container builds it. A compiler pass runs AgentCat's tools/call, tools/list and initialize handlers before the customer's tagged mcp.request_handler services, so a customer handler that answers tools/call itself is still recorded and still answers. If a future bundle release changes the shape of that call, the pass leaves it untouched, and a contract test fails by name.
  • The agentcat configuration (bin/console config:dump-reference agentcat): enabled, project_id, exclude_servers and projects by mcp.servers name, logger (a PSR-3 service ID), api_url, the TypeScript options in snake_case, hooks as service IDs of invokable services, exporters, and the diagnostics keys. Every value reads an %env(default::…)% by default. A wrong value logs one line at runtime and never breaks the container compile or a request.
  • A Flex recipe under recipe/agentcat/sdk/1.0: it registers the bundle, copies config/packages/agentcat.yaml and adds AGENTCAT_PROJECT_ID= to .env. The bundle also works in an application that has mcp/sdk without symfony/mcp-bundle: it compiles and tracks nothing.
  • Tests: AgentCat is off while a customer's test suite runs. AgentCat::fake() records the events of a kernel booted after it, with each call's HTTP request, so a test of an identify hook that reads a header records the same actor as production.
  • Delivery after the response: a kernel.terminate listener waits at most 1 s in a long-running worker and makes one non-blocking pass elsewhere. Each event reads the headers of its own request from the RequestStack, so a worker that serves many requests never mixes them up.
  • Answer length: on SAPIs where the answer ends only with the script (mod_php, php-cgi, php -S), both bridges give a tracked JSON answer its Content-Length, so the client does not wait for delivery. HEAD requests, 1xx, 204 and 304 answers, SSE streams and answers that already have a length are left alone.
  • The bundle's own log lines go to %kernel.logs_dir%/agentcat.log by default; logger routes them to a service.
  • If the agentcat configuration cannot be read (for example an unset %env()% without default::), each server stays untracked with one log line, and AgentCat::publishCustomEvent() logs instead of throwing into the tool. The Laravel bridge gets the same fix.

SDK fixes found by the runtime tests

  • PHP-FPM: diagnostics posted twice when the drain ran longer than 2 s. A per-request runtime now posts once, at shutdown, after fastcgi_finish_request(); long-running runtimes keep the 2 s window.
  • NGINX Unit reports the SAPI of php -S (cli-server), so the SDK did not call its fastcgi_finish_request(), and an answer without Content-Length reached the client only after delivery (2.0 s with a slow API). The SDK now finds Unit by SERVER_SOFTWARE (Unit/…); php -S keeps the output-buffer path.
  • A self-feeding hook (one that publishes events from inside its own hook) no longer keeps one delivery pass running: each pass is bounded to the events queued at its start.
  • A signal drain that empties the queue while a hook runs no longer logs a false delivery failure or skips the transport.

Runtime delivery tests (Docker, runtime CI job)

Each test runs a real server in a pinned image against a local collector that answers slowly, and asserts that the client gets its answer before the delivery ends:

  • PHP-FPM through a FastCGI client: the answer before the hooks and the delivery, the native session lock released before the drain, one diagnostics post per request, a Laravel stream recorded after it ends, Symfony.
  • FrankenPHP: classic mode (with and without Content-Length), the drain's own time limit under ZTS, worker mode with the loop's AgentCat::flush(1.0), a slow API in worker mode, and diagnostics while a worker runs.
  • The CLI: SIGTERM delivers the queue for mcp/sdk and php artisan mcp:start on a stock PHP build, and stdout stays JSON-RPC. Under Swoole the signal drain does not install.
  • Apache mod_php with output buffering: a plain mcp/sdk server, Symfony and Laravel answer before the delivery.
  • NGINX Unit: an answer without Content-Length before the delivery.

Every test server listens on 127.0.0.1 only. The suite also fails closed for the event API: a test without its own collector sends to a closed local port, never to the AgentCat API.

Testing

3140 tests pass (composer check; two expected skips). The covered mutation score is 98 % on the full set (5500 mutants) and 99 % on the lines changed since #6 (337 mutants). The mcp-sdk-main job passes with 771 tests against mcp/sdk main at 82e19d1, without symfony/mcp-bundle (which requires mcp/sdk ^0.8.1); the bundle's no-mcp-bundle test runs there. PHP 8.5 with the highest (PHPUnit 13.4.3, Symfony 8.1, symfony/mcp-bundle 0.14.1) and the lowest (PHPUnit 12.5.40, Symfony 7.3, symfony/mcp-bundle 0.13.0) dependency sets passed locally in Docker: both PHPStan configs and 3136 tests each. The runtime suite passes with 37 tests in Docker.

…ecord into it

ToolCalls no longer resets the laravel/mcp CustomCallTool; LaravelTestCase resets it next to ToolCalls, so the shared recorder names no library.
…ct 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.
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.
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.
…e 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.
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.
…s 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.
…e first Laravel application of a PHP 8.5 process takes about 0.1 s
…ontent-Length where responses end with the script (D62)
…ernel's terminate phase, as KernelBrowser does
…nd send AgentCat's default hosts to 127.0.0.1 in runtime containers
…untimes

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.
…cessor

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.
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.
…swer 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.
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/<version>); php -S does not,
so php -S keeps the output-buffer path (D57).
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.
php -S names itself "PHP/8.3.6 (Development Server)" in SERVER_SOFTWARE
(recorded from a real run). With that value and a fastcgi_finish_request()
of its own, php -S still sends its answer when the script ends, so the
answer-length listeners and the buffer close (D57, D59, D62) stay on.
…logs

RuntimeTestCase now holds the warm-up request, the delivered-after check,
the wait for diagnostics posts with a sent event and the PHP log check;
the FrankenPHP, Apache and Unit tests call them instead of copies.

Apache serves only the two fixture directories and forbids the rest of
tests/, which the test checks. Its log check and Unit's new one match
PHP warnings, notices, deprecations and fatal errors; Apache logs
mod_php's info level, where PHP puts deprecations.
…mfony bundle

Under a fake, the bundle tracked with the fake's client, which reads the
SAPI globals: on the CLI they hold no request, so recorded events had no
requestInfo and hooks got no headers. An identify service that reads a
header named the actor differently under the fake than in production.

The tracker now gives a fake's client the RequestStack through an
internal Client::withHttpContext(), a copy that keeps the queue, the
enabled gate and the telemetry setting, so it still records into the
fake (D41). The Laravel bridge reads the request from laravel/mcp's
transport and records the same under a fake; a test now pins that.
…idge left untracked

When the Symfony bundle could not read its configuration (a bare
%env()% whose variable is unset), or the Laravel bridge failed before
Client::track(), the tracker logged the failure but did not mark the
process. publishCustomEvent($server, ...) for that server then threw
"Server is not tracked" into the customer's tool (D32).

Both trackers now call TrackedServers::markIncomplete() in their catch,
as Client::track() does on its own failures, so the custom event logs
the "see the earlier AgentCat log line" warning instead.
The commented projects example read '%env(AGENTCAT_BILLING_PROJECT_ID)%'.
Unlike Laravel's env(), Symfony's bare %env()% throws when the variable is
not set, so a customer who uncommented the example without setting the
variable in every environment made the whole agentcat configuration
unreadable: every server answered untracked.

The example now reads '%env(default::AGENTCAT_BILLING_PROJECT_ID)%'; an
unset variable gives a null entry, which is project_id. RecipeTest
checks that every variable of the recipe has a default and that the
uncommented examples are valid for the bundle.
…as today

TrackBuildersPass removed every addRequestHandlers call of a server
builder and handed its first argument to the configurator. A call of
another shape, for example a named $handlers argument in a later
symfony/mcp-bundle release, handed [] on, and the customer's tagged
request handlers disappeared without a line.

The pass now moves the call only when its single argument is a
TaggedIteratorArgument. Any other call stays where it is, untouched: its
handlers then come before AgentCat's, which still tracks the builder
(header conflict 3's fallback). A test with a compiled container shows
the customer's handler answering in each shape.
The Flex recipe registers AgentCatBundle in every Symfony application
that requires agentcat/sdk, plain mcp/sdk ones too, but every bundle test
booted symfony/mcp-bundle and skipped without it.

WithoutMcpBundleTest boots FrameworkBundle and AgentCatBundle only: the
container compiles with no server to track, a request is answered, the
event its controller publishes goes out at terminate(), and the D62
listener, forced on, leaves the answer as it is. SymfonyTestCase lets a
class run without symfony/mcp-bundle, so the test also runs in the
mcp/sdk main leg, which removes the bundle.
TrackBuildersPass moves the call only when the tagged request handlers
are its single argument. The contract test checked the first argument
only, so a bundle release that added a second argument would keep the
contract leg green while AgentCat fell back, without a word, to
tracking the builder after the customer's handlers. It now asserts that
the call has exactly one argument.
… port

tests/bootstrap.php pointed only the diagnostics endpoint at a closed
port. An event of a test that named no collector of its own, through a
queue with the real transport, went to https://api.agentcat.com.

The bootstrap now sets MCPCAT_API_URL, the last name the SDK reads, to a
closed port on 127.0.0.1 (EventApiGuard), so a test's own option,
AGENTCAT_API_URL or MCPCAT_API_URL still comes first, and child
processes inherit it. Every setUp that clears MCPCAT_API_URL sets the
guard again; the three tests of the default URL remove it and their
tearDown puts it back. New tests check the guard after the setUp of the
Symfony and Laravel bases and of ClientDiagnosticsTest, and send one
event through EventQueue::global() that fails at the closed port.
symfony/config 7.3 types DefinitionConfigurator::rootNode() as any node
and a prototype's end() as any parent, so PHPStan on the lowest
dependency set could not follow the tree. The root is now narrowed from
a native type, and the projects node keeps its definition in a variable.
The tree is unchanged.
Base automatically changed from feat/plan-e1-laravel to feat/plan-a-foundation October 22, 2026 22:58
@naji247
naji247 merged commit 763c657 into feat/plan-a-foundation Oct 22, 2026
12 of 13 checks passed
@naji247
naji247 deleted the feat/plan-e2-symfony-runtime branch October 22, 2026 22:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants