diff --git a/composer.json b/composer.json index e779ca8..6c028dc 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,7 @@ "php tests/canonical-run-lifecycle-smoke.php", "php tests/channels-smoke.php", "php tests/chat-run-control-smoke.php", + "php tests/external-chat-run-bridge-fixture-smoke.php", "php tests/task-execution-smoke.php", "php tests/frontend-chat-rest-smoke.php", "php tests/agents-chat-jsonrpc-route-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index 4382e60..3bcf5c3 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -72,11 +72,12 @@ Agents API owns the generic run-control ability contracts and default run-contro | --- | --- | --- | | `agents/get-chat-run` | Return status for a known chat run. | `wp_agent_chat_run_status_handler` | | `agents/cancel-chat-run` | Request best-effort cancellation for a known chat run. | `wp_agent_chat_run_cancel_handler` | +| `agents/list-chat-run-events` | Return lifecycle/event pages for a known chat run. | `wp_agent_chat_run_events_handler` | | `agents/queue-chat-message` | Accept a next user message while a session has an active run. | `wp_agent_chat_message_queue_handler` | Clients can expose status, Stop, and Queue controls whenever these canonical abilities are available and the caller has permission for the selected agent. The default handlers preserve safe behavior for synchronous runtimes; runtime-specific handlers are enhancements, not prerequisites. -Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, and `failed`. The canonical run payload is: +Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, `runtime_tool_pending`, `approval_required`, `budget_exceeded`, `stalled`, and `interrupted`. The canonical run payload is: ```php array( @@ -85,10 +86,98 @@ array( 'status' => 'running', 'started_at' => '2026-01-01T00:00:00Z', 'updated_at' => '2026-01-01T00:00:01Z', - 'metadata' => array(), + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'external-provider-id', + 'run_id' => 'provider-run-id', + 'event_cursor' => 'provider-event-cursor', + ), + ), +) +``` + +The status, events, and cancel hooks are the generic external durable-run adapter contract. A host can back them with any durable run provider by returning the canonical payloads above; Agents API normalizes status values, validates required identifiers, applies the existing permission callbacks, and keeps provider-specific state inside metadata. The canonical metadata object is `metadata['orchestration']` with `provider` for the adapter/provider id, `run_id` for the durable provider run id, and `event_cursor` for the provider cursor associated with the latest status or event page. These keys are intentionally generic and do not imply a specific runner, queue, or product. + +Event handlers return the same run fields plus an `events` list, `cursor`, and `has_more`. Each event uses `id`, `type`, `created_at`, optional `message`, and opaque `metadata`. The returned `cursor` is the client polling cursor for the next `agents/list-chat-run-events` call; adapters can mirror a provider-native cursor in `metadata['orchestration']['event_cursor']` when the provider cursor differs from the public cursor. + +### External durable-run bridge + +An external orchestration bridge should call its own in-process adapter or service client for its provider-native durable run status, then return the canonical Agents API run-control shape. Agents API does not shell out to an orchestrator and does not require any orchestrator package dependency; the bridge is just a handler behind `wp_agent_chat_run_status_handler` and `wp_agent_chat_run_events_handler`. + +Mapping guidance: + +| External status field | Agents API field | +| --- | --- | +| `run.state` | `status`, mapped into the bounded Agents API vocabulary: `queued`, `running`, `cancelling`, `cancelled`, `completed`, `failed`, `stalled`, or `interrupted`. | +| `run.id` | `metadata.orchestration.run_id`; keep the client-addressable chat `run_id` unchanged at the top level. | +| `totals` | `metadata.orchestration.totals`. | +| `latest_event_cursor` | `cursor` on event pages and `metadata.orchestration.event_cursor` on status/event payloads. | +| `normalized_events` | `events`, with each item using canonical `id`, `type`, `created_at`, `message`, and `metadata`. | +| `artifact_refs` | `metadata.orchestration.artifact_refs`. | + +Example provider-native input shape: + +```php +array( + 'schema' => 'example-orchestrator/run-status/v1', + 'run' => array( + 'id' => 'external-run-1', + 'state' => 'succeeded', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + ), + 'totals' => array( + 'events' => 2, + 'artifacts' => 1, + 'errors' => 0, + ), + 'latest_event_cursor' => 'provider-cursor-2', + 'normalized_events' => array( + array( + 'id' => 'provider-event-2', + 'cursor' => 'provider-cursor-2', + 'type' => 'artifact', + 'created_at' => '2026-06-25T12:03:00Z', + 'message' => 'Recorded transcript artifact.', + ), + ), + 'artifact_refs' => array( + array( + 'id' => 'artifact-transcript', + 'type' => 'transcript', + 'label' => 'Transcript', + ), + ), ) ``` +Canonical `agents/get-chat-run` output: + +```php +array( + 'run_id' => 'run-chat-1', + 'session_id' => 'session-external-1', + 'status' => 'completed', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', + 'run_id' => 'external-run-1', + 'state' => 'succeeded', + 'event_cursor' => 'provider-cursor-2', + 'totals' => array( 'events' => 2, 'artifacts' => 1, 'errors' => 0 ), + 'artifact_refs' => array( + array( 'id' => 'artifact-transcript', 'type' => 'transcript', 'label' => 'Transcript' ), + ), + ), + ), +) +``` + +Canonical `agents/list-chat-run-events` output uses the same run fields, returns provider events mapped into the canonical event shape, sets `cursor` to the latest provider cursor, and repeats that cursor in `metadata.orchestration.event_cursor` when useful. Cancellation stays handler-owned: register `wp_agent_chat_run_cancel_handler` only when the bridge can request cancellation through its durable provider; otherwise Agents API keeps the default local cancellation behavior. + Cancellation is best-effort. A runtime that can abort provider work immediately may do so; a runtime that cannot should mark the run `cancelling` and let its conversation loop stop at the next interrupt check. `WP_Agent_Chat_Run_Control::cancellation_interrupt_message()` builds the message shape expected by `WP_Agent_Conversation_Loop` `interrupt_source` callbacks. Read abilities return observer-safe run envelopes for non-operators. Stored run state may contain runtime diagnostics, package/workflow selectors, raw refs, provenance, output, or caller metadata for audit/debugging, but `agents/get-chat-run`, `agents/list-chat-run-events`, `agents/get-task-run`, `agents/get-runtime-package-run`, and `agents/list-runtime-package-run-events` redact high-risk keys unless the caller passes the explicit unredacted read gate. Managers keep full access by default; hosts can extend that path with `agents_chat_run_unredacted_read_permission`, `agents_task_unredacted_read_permission`, or `agents_runtime_package_run_unredacted_read_permission` while leaving broad read access observer-safe. diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 49f4b77..3c2b504 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -453,7 +453,10 @@ function agents_chat_run_output_schema(): array { ), 'started_at' => array( 'type' => 'string' ), 'updated_at' => array( 'type' => 'string' ), - 'metadata' => array( 'type' => 'object' ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Opaque run metadata. External durable-run adapters should use orchestration.provider, orchestration.run_id, and orchestration.event_cursor for provider identity, provider run identity, and latest event cursor.', + ), ), ); } @@ -486,8 +489,15 @@ function agents_chat_run_events_output_schema(): array { ), ), ), - 'cursor' => array( 'type' => 'string' ), + 'cursor' => array( + 'type' => 'string', + 'description' => 'Opaque cursor for the next events page. External durable-run adapters should also mirror the latest durable cursor in metadata.orchestration.event_cursor when useful for status polling.', + ), 'has_more' => array( 'type' => 'boolean' ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Opaque event-page metadata using the same orchestration.provider, orchestration.run_id, and orchestration.event_cursor convention as run status payloads.', + ), ), ); } diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index b2868ff..1b873c2 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -216,7 +216,15 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'status' => 'running', 'started_at' => '2026-01-01T00:00:00Z', 'updated_at' => '2026-01-01T00:00:01Z', - 'metadata' => array( 'provider' => 'test', 'token' => 'secret-token' ), + 'metadata' => array( + 'provider' => 'test', + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-run-1', + 'event_cursor' => 'external-cursor-1', + ), + 'token' => 'secret-token', + ), ), 10, 2 @@ -225,6 +233,9 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { $status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ); agents_api_smoke_assert_equals( 'running', $status['status'] ?? null, 'get-run normalizes status payload', $failures, $passes ); agents_api_smoke_assert_equals( 'test', $status['metadata']['provider'] ?? null, 'get-run preserves metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'fake-durable-runner', $status['metadata']['orchestration']['provider'] ?? null, 'get-run preserves external orchestration provider metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'get-run preserves external orchestration run id metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-cursor-1', $status['metadata']['orchestration']['event_cursor'] ?? null, 'get-run preserves external orchestration event cursor metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'secret-token', $status['metadata']['token'] ?? null, 'manager get-run preserves operator metadata', $failures, $passes ); $GLOBALS['__agents_api_smoke_caps']['manage_options'] = false; $observer_status = AgentsAPI\AI\Channels\agents_get_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ); @@ -283,6 +294,13 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'run_id' => $input['run_id'], 'session_id' => $input['session_id'], 'status' => 'running', + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-events-run-1', + 'event_cursor' => 'external-event-cursor-1', + ), + ), 'events' => array( array( 'id' => 'evt_1', @@ -290,9 +308,14 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 'message' => 'Calling client/tool...', 'created_at' => '2026-01-01T00:00:00Z', 'metadata' => array( - 'turn' => 1, - 'tool_name' => 'client/tool', - 'tool_call_id' => 'call-1', + 'turn' => 1, + 'tool_name' => 'client/tool', + 'tool_call_id' => 'call-1', + 'orchestration' => array( + 'provider' => 'fake-durable-runner', + 'run_id' => 'external-events-run-1', + 'event_cursor' => 'external-event-cursor-1', + ), ), ), ), @@ -314,6 +337,10 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { agents_api_smoke_assert_equals( 'session-events-1', $event_page['session_id'] ?? null, 'run events handler preserves session id', $failures, $passes ); agents_api_smoke_assert_equals( 'running', $event_page['status'] ?? null, 'run events handler normalizes status', $failures, $passes ); agents_api_smoke_assert_equals( 'evt_1', $event_page['cursor'] ?? null, 'run events handler returns cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 'fake-durable-runner', $event_page['metadata']['orchestration']['provider'] ?? null, 'run events handler preserves external orchestration provider metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-events-run-1', $event_page['metadata']['orchestration']['run_id'] ?? null, 'run events handler preserves external orchestration run id metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-event-cursor-1', $event_page['metadata']['orchestration']['event_cursor'] ?? null, 'run events handler preserves external orchestration event cursor metadata', $failures, $passes ); agents_api_smoke_assert_equals( 'client/tool', $event_page['events'][0]['metadata']['tool_name'] ?? null, 'run events handler returns safe metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-event-cursor-1', $event_page['events'][0]['metadata']['orchestration']['event_cursor'] ?? null, 'run events handler returns external event cursor metadata', $failures, $passes ); agents_api_smoke_finish( 'chat run-control', $failures, $passes ); diff --git a/tests/external-chat-run-bridge-fixture-smoke.php b/tests/external-chat-run-bridge-fixture-smoke.php new file mode 100644 index 0000000..28d7103 --- /dev/null +++ b/tests/external-chat-run-bridge-fixture-smoke.php @@ -0,0 +1,303 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +if ( ! function_exists( 'current_user_can' ) ) { + function current_user_can( string $capability ): bool { + return ! empty( $GLOBALS['__agents_api_smoke_caps'][ $capability ] ); + } +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id(): int { + return (int) ( $GLOBALS['__agents_api_smoke_user_id'] ?? 0 ); + } +} + +if ( ! function_exists( 'wp_has_ability_category' ) ) { + function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); + } +} + +if ( ! function_exists( 'wp_register_ability_category' ) ) { + function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; + } +} + +if ( ! function_exists( 'wp_has_ability' ) ) { + function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); + } +} + +if ( ! function_exists( 'wp_register_ability' ) ) { + function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; + } +} + +if ( ! function_exists( 'get_option' ) ) { + function get_option( string $option, $default = false ) { + return $GLOBALS['__agents_api_smoke_options'][ $option ] ?? $default; + } +} + +if ( ! function_exists( 'update_option' ) ) { + function update_option( string $option, $value, $autoload = null ): bool { + unset( $autoload ); + $GLOBALS['__agents_api_smoke_options'][ $option ] = $value; + return true; + } +} + +agents_api_smoke_require_module(); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +$GLOBALS['__agents_api_smoke_caps'] = array( + 'read' => true, + 'manage_options' => true, +); +$GLOBALS['__agents_api_smoke_user_id'] = 123; + +/** + * Fake in-process external orchestration bridge fixture. It accepts a + * provider-native durable status shape without invoking the provider itself. + */ +final class Agents_API_External_Chat_Run_Bridge_Fixture { + /** @param array $external_status Provider-native status payload. */ + public function __construct( private array $external_status ) {} + + /** @param array $input Ability input. */ + public function get_run( array $input ): array { + $run = $this->external_run(); + + return array( + 'run_id' => (string) ( $input['run_id'] ?? '' ), + 'session_id' => (string) ( $input['session_id'] ?? '' ), + 'status' => self::map_state( (string) ( $run['state'] ?? '' ) ), + 'started_at' => (string) ( $run['started_at'] ?? '' ), + 'updated_at' => (string) ( $run['updated_at'] ?? '' ), + 'metadata' => $this->metadata(), + ); + } + + /** @param array $input Ability input. */ + public function list_events( array $input ): array { + $events = array(); + foreach ( is_array( $this->external_status['normalized_events'] ?? null ) ? $this->external_status['normalized_events'] : array() as $event ) { + if ( is_array( $event ) ) { + $events[] = $this->map_event( $event ); + } + } + + $run = $this->get_run( $input ); + + return array_merge( + $run, + array( + 'events' => $events, + 'cursor' => (string) ( $this->external_status['latest_event_cursor'] ?? '' ), + 'has_more' => (bool) ( $this->external_status['has_more_events'] ?? false ), + ) + ); + } + + /** @return array */ + private function external_run(): array { + return is_array( $this->external_status['run'] ?? null ) ? $this->external_status['run'] : array(); + } + + /** @return array */ + private function metadata(): array { + $run = $this->external_run(); + + return array( + 'orchestration' => array( + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', + 'run_id' => (string) ( $run['id'] ?? '' ), + 'state' => (string) ( $run['state'] ?? '' ), + 'event_cursor' => (string) ( $this->external_status['latest_event_cursor'] ?? '' ), + 'totals' => is_array( $this->external_status['totals'] ?? null ) ? $this->external_status['totals'] : array(), + 'artifact_refs' => is_array( $this->external_status['artifact_refs'] ?? null ) ? array_values( $this->external_status['artifact_refs'] ) : array(), + ), + ); + } + + /** @param array $event external normalized event. */ + private function map_event( array $event ): array { + $cursor = (string) ( $event['cursor'] ?? $this->external_status['latest_event_cursor'] ?? '' ); + + return array( + 'id' => (string) ( $event['id'] ?? $cursor ), + 'type' => (string) ( $event['type'] ?? 'run_event' ), + 'created_at' => (string) ( $event['created_at'] ?? '' ), + 'message' => (string) ( $event['message'] ?? '' ), + 'metadata' => array( + 'orchestration' => array( + 'provider' => 'example-orchestrator', + 'schema' => 'example-orchestrator/run-status/v1', + 'run_id' => (string) ( $this->external_run()['id'] ?? '' ), + 'event_cursor' => $cursor, + ), + 'raw_type' => (string) ( $event['raw_type'] ?? '' ), + ), + ); + } + + private static function map_state( string $state ): string { + return match ( strtolower( trim( $state ) ) ) { + 'queued', 'pending' => 'queued', + 'running', 'active' => 'running', + 'cancelling' => 'cancelling', + 'cancelled' => 'cancelled', + 'succeeded', 'completed', 'success' => 'completed', + 'failed', 'error' => 'failed', + 'stalled' => 'stalled', + 'interrupted' => 'interrupted', + default => 'running', + }; + } +} + +$external_status = array( + 'schema' => 'example-orchestrator/run-status/v1', + 'run' => array( + 'id' => 'external-run-1', + 'state' => 'succeeded', + 'started_at' => '2026-06-25T12:00:00Z', + 'updated_at' => '2026-06-25T12:03:00Z', + ), + 'totals' => array( + 'events' => 2, + 'artifacts' => 2, + 'errors' => 0, + ), + 'latest_event_cursor' => 'provider-cursor-2', + 'normalized_events' => array( + array( + 'id' => 'provider-event-1', + 'cursor' => 'provider-cursor-1', + 'type' => 'log', + 'raw_type' => 'stdout', + 'created_at' => '2026-06-25T12:01:00Z', + 'message' => 'Started durable work.', + ), + array( + 'id' => 'provider-event-2', + 'cursor' => 'provider-cursor-2', + 'type' => 'artifact', + 'raw_type' => 'artifact.recorded', + 'created_at' => '2026-06-25T12:03:00Z', + 'message' => 'Recorded artifact.', + ), + ), + 'artifact_refs' => array( + array( + 'id' => 'artifact-transcript', + 'type' => 'transcript', + 'label' => 'Transcript', + ), + array( + 'id' => 'artifact-bundle', + 'type' => 'bundle', + 'label' => 'Run bundle', + ), + ), +); + +$fixture = new Agents_API_External_Chat_Run_Bridge_Fixture( $external_status ); + +add_filter( + 'wp_agent_chat_run_status_handler', + static fn() => array( $fixture, 'get_run' ), + 10, + 2 +); + +add_filter( + 'wp_agent_chat_run_events_handler', + static fn() => array( $fixture, 'list_events' ), + 10, + 2 +); + +$status = AgentsAPI\AI\Channels\agents_get_chat_run( + array( + 'session_id' => 'session-external-1', + 'run_id' => 'run-chat-1', + ) +); + +agents_api_smoke_assert_equals( 'run-chat-1', $status['run_id'] ?? null, 'external orchestration bridge preserves Agents API run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'session-external-1', $status['session_id'] ?? null, 'external orchestration bridge preserves Agents API session id', $failures, $passes ); +agents_api_smoke_assert_equals( 'completed', $status['status'] ?? null, 'external orchestrator succeeded state maps to Agents API completed status', $failures, $passes ); +agents_api_smoke_assert_equals( 'example-orchestrator', $status['metadata']['orchestration']['provider'] ?? null, 'external orchestration bridge marks orchestration provider', $failures, $passes ); +agents_api_smoke_assert_equals( 'example-orchestrator/run-status/v1', $status['metadata']['orchestration']['schema'] ?? null, 'external orchestration bridge records source schema', $failures, $passes ); +agents_api_smoke_assert_equals( 'external-run-1', $status['metadata']['orchestration']['run_id'] ?? null, 'external orchestration bridge maps provider run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'succeeded', $status['metadata']['orchestration']['state'] ?? null, 'external orchestration bridge preserves provider state in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $status['metadata']['orchestration']['event_cursor'] ?? null, 'external orchestration bridge maps latest_event_cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 2, $status['metadata']['orchestration']['totals']['events'] ?? null, 'external orchestration bridge maps totals metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-bundle', $status['metadata']['orchestration']['artifact_refs'][1]['id'] ?? null, 'external orchestration bridge maps artifact refs metadata', $failures, $passes ); + +$events = AgentsAPI\AI\Channels\agents_list_chat_run_events( + array( + 'session_id' => 'session-external-1', + 'run_id' => 'run-chat-1', + 'cursor' => 'provider-cursor-0', + ) +); + +agents_api_smoke_assert_equals( 'completed', $events['status'] ?? null, 'external orchestrator event page maps run status', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $events['cursor'] ?? null, 'external orchestrator event page maps latest cursor', $failures, $passes ); +agents_api_smoke_assert_equals( false, $events['has_more'] ?? null, 'external orchestrator event page maps has_more default', $failures, $passes ); +agents_api_smoke_assert_equals( 2, count( $events['events'] ?? array() ), 'external orchestrator event page maps normalized events', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact', $events['events'][1]['type'] ?? null, 'external orchestrator event page preserves normalized event type', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact.recorded', $events['events'][1]['metadata']['raw_type'] ?? null, 'external orchestrator event page keeps raw event type in metadata', $failures, $passes ); +agents_api_smoke_assert_equals( 'provider-cursor-2', $events['events'][1]['metadata']['orchestration']['event_cursor'] ?? null, 'external orchestrator event metadata maps event cursor', $failures, $passes ); +agents_api_smoke_assert_equals( 'artifact-transcript', $events['metadata']['orchestration']['artifact_refs'][0]['id'] ?? null, 'external orchestrator event page maps artifact refs metadata', $failures, $passes ); + +$cancelled = AgentsAPI\AI\Channels\agents_cancel_chat_run( + array( + 'session_id' => 'session-external-1', + 'run_id' => 'run-chat-1', + ) +); + +agents_api_smoke_assert_equals( true, $cancelled instanceof WP_Error, 'external orchestration bridge fixture does not claim cancellation without a cancel handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_not_found', $cancelled instanceof WP_Error ? $cancelled->get_error_code() : null, 'external orchestrator cancellation remains handler-owned', $failures, $passes ); + +agents_api_smoke_finish( 'external orchestrator chat run bridge fixture', $failures, $passes );