Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4317a4d
feat: add publishCustomEvent with TypeScript-parity server and sessio…
naji247 Oct 17, 2026
059b759
fix: log instead of throwing when publishCustomEvent cannot find a se…
naji247 Oct 17, 2026
6885277
test: pin the closure marker and the NAN substitution of a custom event
naji247 Oct 17, 2026
b52356a
feat: add AgentCat::fake() for customer test suites
naji247 Oct 17, 2026
953e26f
fix: count one PHPUnit assertion when a fake assertion passes
naji247 Oct 17, 2026
cc03b5c
feat: drain the event queue on SIGTERM and SIGINT in CLI servers
naji247 Oct 17, 2026
28b39a6
fix: forward siginfo, hand the signal back before the drain, and keep…
naji247 Oct 17, 2026
47ed2b5
test: prove tracking end to end over HTTP with a real mcp/sdk server
naji247 Oct 17, 2026
a71edbb
test: give the HTTP end-to-end test room on slow runners and keep its…
naji247 Oct 17, 2026
a27b9cd
test: prove tracking end to end over stdio, including EOF and SIGTERM…
naji247 Oct 17, 2026
e6b554e
fix: send no duration for a float outside the int range
naji247 Oct 17, 2026
bcaa4a1
fix: dispatch nothing in a shutdown after the shared deadline
naji247 Oct 17, 2026
9b4bebb
docs: say that the signal drain sees only an ignore set in PHP
naji247 Oct 17, 2026
38f362b
test: drop the ac-01-69 tag from the fake's test-mode tests
naji247 Oct 17, 2026
a4c381a
fix: keep AgentCat's log in the fake while a fake is the shared client
naji247 Oct 17, 2026
daf426f
fix: log instead of throwing for a server tracked while AgentCat was off
naji247 Oct 17, 2026
88d0b2c
test: pin the fake log's client registry and its string messages
naji247 Oct 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ jobs:
- 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/phpunit --testsuite Contract,Integration
- run: vendor/bin/phpunit --testsuite Contract,Integration,E2E

all-green:
name: All green
Expand Down
10 changes: 7 additions & 3 deletions composer-dependency-analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;

return (new Configuration())
// ext-posix is optional: FileLogger guards its use with function_exists().
->ignoreErrorsOnExtensions(['ext-posix'], [ErrorType::SHADOW_DEPENDENCY])
// ext-pcntl and ext-posix are optional: FileLogger and SignalDrain guard their use with function_exists().
->ignoreErrorsOnExtensions(['ext-pcntl', 'ext-posix'], [ErrorType::SHADOW_DEPENDENCY])
// Test fixture functions, loaded with require_once by ExceptionCaptureTest (not autoloadable).
->ignoreUnknownFunctionsRegex('~^AgentCat\\\\Tests\\\\Fixtures\\\\Errors\\\\~')
// mcp/sdk is optional (01 AC-90): a dev dependency and a suggestion, used only by the adapter.
->ignoreErrorsOnPackageAndPath('mcp/sdk', __DIR__ . '/src/McpSdk', [ErrorType::DEV_DEPENDENCY_IN_PROD])
// AgentCatFake asserts with PHPUnit only when the class is loaded (class_exists()); phpunit stays a dev dependency.
->ignoreErrorsOnPackageAndPath('phpunit/phpunit', __DIR__ . '/src/Testing', [ErrorType::DEV_DEPENDENCY_IN_PROD])
// Psr\Container\ContainerInterface arrives with mcp/sdk; the adapter only passes it through.
->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]);
->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]);
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
"mcp/sdk": "<0.8.1"
},
"suggest": {
"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",
"mcp/sdk": "Track mcp/sdk servers with AgentCat::track($builder, $projectId)"
},
"minimum-stability": "stable",
Expand Down
3 changes: 3 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
<testsuite name="Contract">
<directory>tests/Contract</directory>
</testsuite>
<testsuite name="E2E">
<directory>tests/E2E</directory>
</testsuite>
<testsuite name="Architecture">
<directory>tests/Architecture</directory>
<!-- PHPat rules run inside PHPStan, not PHPUnit -->
Expand Down
65 changes: 64 additions & 1 deletion src/AgentCat.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

namespace AgentCat;

use AgentCat\Internal\Logging\Log;
use AgentCat\Testing\AgentCatFake;
use AgentCat\Testing\Internal\FakeLog;

/**
* The AgentCat entry point: `AgentCat::track($builder, 'proj_…')` before `$builder->build()`.
*
Expand Down Expand Up @@ -42,6 +46,37 @@ public static function track(object $server, ?string $projectId = null, ?Options
return self::client()->track($server, $projectId, $options);
}

/**
* Publishes a custom event, for a tracked server or for a session.
*
* With a server tracked by any client (the Builder, or the server built from it), the event goes to the project
* the server was tracked with, whatever `$projectId` says, and carries the server name and version and the SDK
* fields. The server's `redactEvent` option applies to it; `redactSensitiveInformation` does not. When the server
* was tracked with `enableTracing: false`, the event is dropped. Without `$event->sessionId`, the event has no
* session.
*
* With a session ID string, the event goes to `$projectId` for that session, used as it is; `$event->sessionId`
* wins over it. No server or SDK fields and no hooks apply.
*
* The event is queued, not sent: AgentCat delivers it like any other event. Apart from the two errors below, it
* never throws: a failure is logged. When a `track()` in this process could not track a server, or ran while
* AgentCat was off, a server that is not tracked logs a warning instead of the second error. In test environments
* it validates `$projectId` and then does nothing, like `track()` (see `AGENTCAT_ENABLED` there).
*
* In a file without `declare(strict_types=1)`, PHP turns an int, float or bool first argument into a session ID
* string.
*
* @param object|string $serverOrSessionId A tracked server, or the session ID of the event.
* @param string|null $projectId Your AgentCat project ID (`proj_…`); required, even for a tracked server.
* @param CustomEvent|null $event The event data; null sends an event with no data.
*
* @throws \InvalidArgumentException when `$projectId` is null or empty, or when the server is not tracked
*/
public static function publishCustomEvent(object|string $serverOrSessionId, ?string $projectId, ?CustomEvent $event = null): void
{
self::client()->publishCustomEvent($serverOrSessionId, $projectId, $event);
}

/**
* Sends the queued events now and waits for them. Rarely needed: AgentCat delivers on its own, between requests
* and when the process ends.
Expand All @@ -65,6 +100,28 @@ public static function shutdown(): void
self::client()->shutdown();
}

/**
* Starts recording for your test suite: installs a client that records every event and sends nothing, and
* returns the fake to assert on. Restore the real client with `AgentCat::setClient(null)` in `tearDown()`.
*
* Call it first, before the application builds its servers. In a test environment, a server tracked before
* `fake()` is not tracked at all: no tool call of it is recorded. With `AGENTCAT_ENABLED=true`, a server tracked
* before `fake()` keeps the client that tracked it.
*
* While the fake is the shared client, AgentCat logs to the fake's `logs()` only, and adopts no Builder's PSR-3
* logger. `setClient()` brings back the log routing from before the first `fake()`.
*
* The fake records whatever `AGENTCAT_ENABLED` says, so a CI that sets `AGENTCAT_ENABLED=false` still works. Test
* environments without `fake()` stay silent: `track()` does nothing there.
*/
public static function fake(): AgentCatFake
{
$fake = new AgentCatFake();
self::setClient($fake->client());

return $fake;
}

/** The shared client; the first call creates it. */
public static function client(): Client
{
Expand All @@ -73,10 +130,16 @@ public static function client(): Client

/**
* Replaces the shared client, for example with one from your dependency-injection container. Null goes back to
* a new default client.
* a new default client. Replacing the client of a fake brings back the log routing from before the first
* `fake()`.
*/
public static function setClient(?Client $client): void
{
self::$client = $client;
Log::endDiversion();
$fakeLog = $client instanceof Client ? FakeLog::of($client) : null;
if ($fakeLog instanceof FakeLog) {
Log::divert($fakeLog); // a fake's client: AgentCat logs to the fake only
}
}
}
98 changes: 98 additions & 0 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
namespace AgentCat;

use AgentCat\Internal\ClientSeams;
use AgentCat\Internal\Constants;
use AgentCat\Internal\Delivery\EventQueue;
use AgentCat\Internal\Engine\CallInterceptor;
use AgentCat\Internal\Engine\Publisher;
use AgentCat\Internal\Engine\TrackedServer;
use AgentCat\Internal\Engine\TrackedServers;
use AgentCat\Internal\Logging\Log;
use AgentCat\Internal\Pipeline\QueuedEvent;
use AgentCat\Internal\Support\Env;
use AgentCat\Internal\Support\ErrorText;
use AgentCat\Internal\Support\TestMode;
use AgentCat\Internal\Support\Values;
use AgentCat\Internal\Tags\TagValidator;
use AgentCat\McpSdk\McpSdkAdapter;
use Psr\Log\LoggerInterface;

Expand Down Expand Up @@ -72,6 +76,8 @@ public function track(object $server, ?string $projectId = null, ?Options $optio
{
try {
if (!($this->enabled)()) {
TrackedServers::markSkipped(); // a fake() after this finds no tracked server: it must not throw then

return $server; // D44 AGENTCAT_ENABLED, else D31: nothing in test environments unless fake()
}
$options ??= new Options();
Expand Down Expand Up @@ -118,12 +124,104 @@ public function track(object $server, ?string $projectId = null, ?Options $optio

return $server;
} catch (\Throwable $e) {
// Every failure ends here, after the shape or compatibility line where there is one.
TrackedServers::markIncomplete();
Log::write('Warning: Failed to track server - ' . ErrorText::of($e));

return $server;
}
}

/**
* Publishes a custom event, for a tracked server or for a session.
*
* With a server tracked by any client (the Builder, or the server built from it), the event goes to the project
* the server was tracked with, whatever `$projectId` says, and carries the server name and version and the SDK
* fields. The server's `redactEvent` option applies to it; `redactSensitiveInformation` does not. When the server
* was tracked with `enableTracing: false`, the event is dropped. Without `$event->sessionId`, the event has no
* session.
*
* With a session ID string, the event goes to `$projectId` for that session, used as it is; `$event->sessionId`
* wins over it. No server or SDK fields and no hooks apply.
*
* The event is queued, not sent: AgentCat delivers it like any other event. Apart from the two errors below, it
* never throws: a failure is logged. When a `track()` in this process could not track a server, or ran while
* AgentCat was off, a server that is not tracked logs a warning instead of the second error. In test environments
* it validates `$projectId` and then does nothing, like `track()` (see `AGENTCAT_ENABLED` there).
*
* In a file without `declare(strict_types=1)`, PHP turns an int, float or bool first argument into a session ID
* string.
*
* @param object|string $serverOrSessionId A tracked server, or the session ID of the event.
* @param string|null $projectId Your AgentCat project ID (`proj_…`); required, even for a tracked server.
* @param CustomEvent|null $event The event data; null sends an event with no data.
*
* @throws \InvalidArgumentException when `$projectId` is null or empty, or when the server is not tracked
*/
public function publishCustomEvent(object|string $serverOrSessionId, ?string $projectId, ?CustomEvent $event = null): void
{
if ($projectId === null || $projectId === '') {
throw new \InvalidArgumentException('projectId is required for publishCustomEvent');
}
if (!($this->enabled)()) {
return; // D44 AGENTCAT_ENABLED, else D31: track() tracked nothing here, so no server is looked up
}
$event ??= new CustomEvent();
$sessionId = $event->sessionId ?? '';
$server = null;
if (is_string($serverOrSessionId)) {
$sessionId = $sessionId !== '' ? $sessionId : $serverOrSessionId; // verbatim: no derivation, '' is sessionless
} else {
$server = TrackedServers::get($serverOrSessionId) ?? McpSdkAdapter::trackedServer($serverOrSessionId);
if (!$server instanceof TrackedServer) {
if (TrackedServers::incomplete()) {
// AgentCat's own failure, logged before: it must not throw into the customer's code
Log::write('Warning: Failed to publish custom event - the server is not tracked (see the earlier AgentCat log line)');

return;
}
if (TrackedServers::skipped()) {
// a test that tracked the server before fake(): its code must not get the exception
Log::write('Warning: Failed to publish custom event - the server is not tracked (track() ran while AgentCat was off; call AgentCat::fake() before track())');

return;
}

throw new \InvalidArgumentException('Server is not tracked. Please call agentcat.track() first or provide a session ID string.');
}
if ($sessionId === '') {
Log::write('publishCustomEvent: no sessionId provided; event will be published without a session. Pass eventData.sessionId to attribute it to a task.');
}
}

try {
$custom = new Event(
sessionId: $sessionId,
projectId: $projectId,
eventType: Constants::CUSTOM_EVENT_TYPE,
duration: $event->duration,
resourceName: $event->resourceName,
parameters: $event->parameters,
response: $event->response,
userIntent: $event->message,
isError: $event->isError,
error: $event->error, // sent as it is: no exception capture
tags: TagValidator::validate($event->tags), // null for null or no valid entry
);
if (Values::isObjectLike($event->properties) && Values::entries($event->properties) !== []) {
$custom->properties = $event->properties;
}
if ($server instanceof TrackedServer) {
$server->publisher()->publishCustom($server, $custom); // the queue of the client that tracked the server
} else {
$this->publisher->queue()->add(new QueuedEvent($custom));
}
Log::write(sprintf("Published custom event %s with type 'agentcat:custom'", $sessionId !== '' ? 'for session ' . $sessionId : 'without a session'));
} catch (\Throwable $e) {
Log::write('Warning: Failed to publish custom event - ' . ErrorText::of($e));
}
}

/**
* Sends the queued events now and waits for them. Rarely needed: AgentCat delivers on its own, between requests
* and when the process ends.
Expand Down
39 changes: 39 additions & 0 deletions src/CustomEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace AgentCat;

/**
* The data of a custom event, for `publishCustomEvent()`. Use named arguments: the argument names are public API.
* Every field is optional.
*/
final readonly class CustomEvent
{
/**
* @param string|null $sessionId The session to attribute the event to, for example the `session_id` of a task. It
* wins over a session ID passed as the first argument of `publishCustomEvent()`.
* @param string|null $resourceName What the event is about, for example `checkout`.
* @param mixed $parameters Any JSON-encodable input data.
* @param mixed $response Any JSON-encodable output data.
* @param string|null $message Why it happened; sent as the event's user intent.
* @param int|float|null $duration Duration in milliseconds.
* @param bool|null $isError Whether the event is an error.
* @param mixed $error JSON-encodable error details, sent as they are.
* @param array<array-key, mixed>|null $tags Tags: string keys and string values. Invalid entries are left out and
* logged, like the tags of the `eventTags` option.
* @param mixed $properties JSON-encodable properties; sent only when they have at least one entry.
*/
public function __construct(
public ?string $sessionId = null,
public ?string $resourceName = null,
public mixed $parameters = null,
public mixed $response = null,
public ?string $message = null,
public int|float|null $duration = null,
public ?bool $isError = null,
public mixed $error = null,
public ?array $tags = null,
public mixed $properties = null,
) {}
}
Loading
Loading