From 8cdce84647356a288697987f4bbed0d05eef9854 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 28 May 2026 20:41:03 -0400 Subject: [PATCH] feat: add canonical chat run control --- README.md | 3 + agents-api.php | 2 + composer.json | 1 + docs/channels-workflows-operations.md | 29 ++- src/Channels/register-agents-chat-ability.php | 18 ++ ...ster-agents-chat-run-control-abilities.php | 210 ++++++++++++++++++ .../class-wp-agent-chat-run-control.php | 133 +++++++++++ tests/chat-run-control-smoke.php | 150 +++++++++++++ 8 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 src/Channels/register-agents-chat-run-control-abilities.php create mode 100644 src/Runtime/class-wp-agent-chat-run-control.php create mode 100644 tests/chat-run-control-smoke.php diff --git a/README.md b/README.md index 4580cf8..43fdd25 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Agents API sits between tool/action discovery and product-specific automation. I - Multi-turn orchestration contracts. - Opt-in mediated tool result truncation for oversized transcript payloads. - Opt-in between-turn interrupt sources for cancel, redirect, or additional instruction messages. +- Canonical chat run-control contracts for run IDs, run status, best-effort cancellation, and queued messages. - Canonical ability discovery and dispatch meta-abilities for large tool surfaces. - Agent package and package-artifact contracts. - Shared `wp_guideline` / `wp_guideline_type` storage substrate polyfill when Core/Gutenberg do not provide it. @@ -177,6 +178,7 @@ wp_register_agent( - `AgentsAPI\AI\WP_Agent_Tool_Result_Truncator` - `AgentsAPI\AI\WP_Agent_Byte_Limit_Tool_Result_Truncator` - `AgentsAPI\AI\WP_Agent_Conversation_Result` +- `AgentsAPI\AI\WP_Agent_Chat_Run_Control` - `AgentsAPI\AI\WP_Agent_Conversation_Loop` - `WP_Agent_Consent_Policy` - `WP_Agent_Default_Consent_Policy` @@ -203,6 +205,7 @@ wp_register_agent( - `AgentsAPI\AI\Tools\WP_Agent_Tool_Execution_Core` - `AgentsAPI\AI\Tools\WP_Agent_Tool_Result` - `agents/ability-search` / `agents/ability-call` +- `agents/chat` / `agents/get-chat-run` / `agents/cancel-chat-run` / `agents/queue-chat-message` - `AgentsAPI\AI\Approvals\WP_Agent_Approval_Decision` - `AgentsAPI\AI\Approvals\WP_Agent_Pending_Action` - `AgentsAPI\AI\Approvals\WP_Agent_Pending_Action_Status` diff --git a/agents-api.php b/agents-api.php index b73ebc1..f1604a8 100644 --- a/agents-api.php +++ b/agents-api.php @@ -128,6 +128,7 @@ require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-tool-result-truncator.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-byte-limit-tool-result-truncator.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-result.php'; +require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-chat-run-control.php'; require_once AGENTS_API_PATH . 'src/Runtime/class-wp-agent-conversation-loop.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-call.php'; require_once AGENTS_API_PATH . 'src/Tools/class-wp-agent-tool-parameters.php'; @@ -170,6 +171,7 @@ require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-bridge.php'; require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-channel.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-ability.php'; +require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-run-control-abilities.php'; require_once AGENTS_API_PATH . 'src/Channels/register-frontend-chat-rest-route.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-dispatch-message-ability.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-bindings.php'; diff --git a/composer.json b/composer.json index c5f5f11..ef5cc10 100644 --- a/composer.json +++ b/composer.json @@ -72,6 +72,7 @@ "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", + "php tests/chat-run-control-smoke.php", "php tests/frontend-chat-rest-smoke.php", "php tests/agents-dispatch-message-ability-smoke.php", "php tests/webhook-safety-smoke.php", diff --git a/docs/channels-workflows-operations.md b/docs/channels-workflows-operations.md index e62369e..1240e94 100644 --- a/docs/channels-workflows-operations.md +++ b/docs/channels-workflows-operations.md @@ -62,7 +62,34 @@ array( ) ``` -The channel can consume either a single `reply` or assistant `messages` from the ability result. It stores a returned `session_id` before sending replies so delivery failures do not lose session continuity. +The channel can consume either a single `reply` or assistant `messages` from the ability result. It stores a returned `session_id` before sending replies so delivery failures do not lose session continuity. `agents/chat` also exposes a canonical `run_id`; runtimes receive a generated `run_id` in the input when callers omit one, and responses include that same ID unless the runtime returns its own. + +## Chat run control + +Agents API owns the generic run-control ability contracts while runtimes own concrete storage, workers, provider aborts, and queue draining. + +| Ability | Purpose | Runtime hook | +| --- | --- | --- | +| `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/queue-chat-message` | Accept a next user message while a session has an active run. | `wp_agent_chat_message_queue_handler` | + +Run status vocabulary is bounded to `queued`, `running`, `cancelling`, `cancelled`, `completed`, and `failed`. The canonical run payload is: + +```php +array( + 'run_id' => 'run_opaque', + 'session_id' => 'session_opaque', + 'status' => 'running', + 'started_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:01Z', + 'metadata' => array(), +) +``` + +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. + +Queued messages return the same run payload plus `queued_message_id` and `position`. Async runtimes can drain queued messages through their worker, cron, or Action Scheduler integration. Synchronous runtimes can expose queued state and require polling or an explicit continue operation in the consuming product; the substrate does not force a background runner. ## Session, webhook, and idempotency helpers diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index e00f164..90f8c1d 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -39,6 +39,8 @@ namespace AgentsAPI\AI\Channels; +use AgentsAPI\AI\WP_Agent_Chat_Run_Control; + defined( 'ABSPATH' ) || exit; /** @@ -104,6 +106,10 @@ static function (): void { * @return array|\WP_Error Canonical output, or WP_Error if no runtime is registered. */ function agents_chat_dispatch( array $input ) { + if ( ! isset( $input['run_id'] ) || '' === trim( (string) $input['run_id'] ) ) { + $input['run_id'] = WP_Agent_Chat_Run_Control::generate_run_id(); + } + /** * Filter the chat runtime handler. * @@ -156,6 +162,10 @@ function agents_chat_dispatch( array $input ) { ); } + if ( ! isset( $result['run_id'] ) || '' === trim( (string) $result['run_id'] ) ) { + $result['run_id'] = $input['run_id']; + } + return $result; } @@ -207,6 +217,10 @@ function agents_chat_input_schema(): array { 'type' => array( 'string', 'null' ), 'description' => 'Existing session ID to continue, or null to start a new session.', ), + 'run_id' => array( + 'type' => array( 'string', 'null' ), + 'description' => 'Optional client-supplied idempotency/run key. If omitted, the dispatcher provides an opaque run ID to the runtime and response.', + ), 'session_owner' => agents_chat_session_owner_schema(), 'attachments' => array( 'type' => 'array', @@ -323,6 +337,10 @@ function agents_chat_output_schema(): array { 'type' => 'string', 'description' => 'Primary assistant text. Must be set even when the runtime supplies multi-message output via `messages`.', ), + 'run_id' => array( + 'type' => 'string', + 'description' => 'Opaque ID for this accepted chat turn. Use with agents/get-chat-run, agents/cancel-chat-run, and agents/queue-chat-message when the runtime supports run control.', + ), 'messages' => array( 'type' => 'array', 'description' => 'Optional multi-message expansion (e.g. assistant emitted multiple turns or split a long answer). When present, each entry is `{ role, content }`. The single-string `reply` is still required for clients that don\'t parse `messages`.', diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php new file mode 100644 index 0000000..6334025 --- /dev/null +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -0,0 +1,210 @@ + array( + 'label' => 'Get Chat Run', + 'description' => 'Read the canonical status for an addressable chat run.', + 'input_schema' => agents_chat_run_id_input_schema(), + 'output_schema' => agents_chat_run_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_get_chat_run', + 'annotations' => array( 'idempotent' => true ), + ), + AGENTS_CANCEL_CHAT_RUN_ABILITY => array( + 'label' => 'Cancel Chat Run', + 'description' => 'Request best-effort cancellation for an addressable chat run.', + 'input_schema' => agents_chat_run_id_input_schema(), + 'output_schema' => agents_cancel_chat_run_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_cancel_chat_run', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => true, + ), + ), + AGENTS_QUEUE_CHAT_MESSAGE_ABILITY => array( + 'label' => 'Queue Chat Message', + 'description' => 'Queue a user message for a conversation while another chat run is active.', + 'input_schema' => agents_queue_chat_message_input_schema(), + 'output_schema' => agents_queue_chat_message_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_queue_chat_message', + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), + ), + ); + + foreach ( $abilities as $ability => $args ) { + if ( wp_has_ability( $ability ) ) { + continue; + } + + wp_register_ability( + $ability, + array( + 'label' => $args['label'], + 'description' => $args['description'], + 'category' => 'agents-api', + 'input_schema' => $args['input_schema'], + 'output_schema' => $args['output_schema'], + 'execute_callback' => $args['execute_callback'], + 'permission_callback' => __NAMESPACE__ . '\\agents_chat_run_control_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => $args['annotations'], + ), + ) + ); + } + } +); + +/** @return array|\WP_Error */ +function agents_get_chat_run( array $input ) { + $handler = apply_filters( 'wp_agent_chat_run_status_handler', null, $input ); + if ( ! is_callable( $handler ) ) { + return agents_chat_run_control_no_handler( 'agents_chat_run_status_unsupported', 'No chat run status handler is registered.' ); + } + + return agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_status' ); +} + +/** @return array|\WP_Error */ +function agents_cancel_chat_run( array $input ) { + $handler = apply_filters( 'wp_agent_chat_run_cancel_handler', null, $input ); + if ( ! is_callable( $handler ) ) { + return agents_chat_run_control_no_handler( 'agents_chat_run_cancel_unsupported', 'No chat run cancellation handler is registered.' ); + } + + $result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_run_invalid_cancel_result' ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $result['cancelled'] = (bool) ( $result['cancelled'] ?? in_array( + $result['status'], + array( + WP_Agent_Chat_Run_Control::STATUS_CANCELLING, + WP_Agent_Chat_Run_Control::STATUS_CANCELLED, + ), + true + ) ); + + return $result; +} + +/** @return array|\WP_Error */ +function agents_queue_chat_message( array $input ) { + $handler = apply_filters( 'wp_agent_chat_message_queue_handler', null, $input ); + if ( ! is_callable( $handler ) ) { + return agents_chat_run_control_no_handler( 'agents_chat_message_queue_unsupported', 'No chat message queue handler is registered.' ); + } + + $result = agents_chat_run_control_normalize_result( call_user_func( $handler, $input ), 'agents_chat_message_queue_invalid_result' ); + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( empty( $result['queued_message_id'] ) ) { + return new \WP_Error( 'agents_chat_message_queue_invalid_result', 'Queued message results must include queued_message_id.' ); + } + + $result['queued_message_id'] = (string) $result['queued_message_id']; + $result['position'] = max( 0, (int) ( $result['position'] ?? 0 ) ); + + return $result; +} + +function agents_chat_run_control_permission( array $input ): bool { + $allowed = function_exists( 'current_user_can' ) ? current_user_can( 'read' ) : false; + return (bool) apply_filters( 'agents_chat_run_control_permission', $allowed, $input ); +} + +/** @return array|\WP_Error */ +function agents_chat_run_control_normalize_result( $result, string $error_code ) { + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! is_array( $result ) ) { + return new \WP_Error( $error_code, 'Chat run-control handlers must return an array or WP_Error.' ); + } + + try { + return WP_Agent_Chat_Run_Control::normalize_run( $result ); + } catch ( \InvalidArgumentException $error ) { + return new \WP_Error( $error_code, $error->getMessage() ); + } +} + +function agents_chat_run_control_no_handler( string $code, string $message ): \WP_Error { + return new \WP_Error( $code, $message ); +} + +function agents_chat_run_id_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'session_id', 'run_id' ), + 'properties' => array( + 'session_id' => array( 'type' => 'string' ), + 'run_id' => array( 'type' => 'string' ), + 'session_owner' => agents_chat_session_owner_schema(), + ), + ); +} + +function agents_chat_run_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'run_id', 'session_id', 'status' ), + 'properties' => array( + 'run_id' => array( 'type' => 'string' ), + 'session_id' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => WP_Agent_Chat_Run_Control::statuses(), + ), + 'started_at' => array( 'type' => 'string' ), + 'updated_at' => array( 'type' => 'string' ), + 'metadata' => array( 'type' => 'object' ), + ), + ); +} + +function agents_cancel_chat_run_output_schema(): array { + $schema = agents_chat_run_output_schema(); + $schema['required'][] = 'cancelled'; + $schema['properties']['cancelled'] = array( 'type' => 'boolean' ); + return $schema; +} + +function agents_queue_chat_message_input_schema(): array { + $schema = agents_chat_input_schema(); + $schema['required'][] = 'session_id'; + return $schema; +} + +function agents_queue_chat_message_output_schema(): array { + $schema = agents_chat_run_output_schema(); + $schema['required'][] = 'queued_message_id'; + $schema['properties']['queued_message_id'] = array( 'type' => 'string' ); + $schema['properties']['position'] = array( 'type' => 'integer' ); + return $schema; +} diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php new file mode 100644 index 0000000..a219739 --- /dev/null +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -0,0 +1,133 @@ + $run Raw run status. + * @return array + */ + public static function normalize_run( array $run ): array { + $run_id = trim( (string) ( $run['run_id'] ?? '' ) ); + $session_id = trim( (string) ( $run['session_id'] ?? '' ) ); + $status = self::normalize_status( $run['status'] ?? self::STATUS_RUNNING ); + + if ( '' === $run_id ) { + throw new \InvalidArgumentException( 'run_id must be a non-empty string' ); + } + + if ( '' === $session_id ) { + throw new \InvalidArgumentException( 'session_id must be a non-empty string' ); + } + + $normalized = array( + 'run_id' => $run_id, + 'session_id' => $session_id, + 'status' => $status, + 'started_at' => isset( $run['started_at'] ) ? (string) $run['started_at'] : '', + 'updated_at' => isset( $run['updated_at'] ) ? (string) $run['updated_at'] : '', + 'metadata' => isset( $run['metadata'] ) && is_array( $run['metadata'] ) ? $run['metadata'] : array(), + ); + + if ( isset( $run['queued_message_id'] ) ) { + $normalized['queued_message_id'] = (string) $run['queued_message_id']; + } + + if ( isset( $run['position'] ) ) { + $normalized['position'] = max( 0, (int) $run['position'] ); + } + + if ( isset( $run['cancelled'] ) ) { + $normalized['cancelled'] = (bool) $run['cancelled']; + } + + return $normalized; + } + + /** + * Normalize status values while keeping the public vocabulary bounded. + */ + public static function normalize_status( $status ): string { + $status = is_string( $status ) ? strtolower( trim( $status ) ) : ''; + return in_array( $status, self::statuses(), true ) ? $status : self::STATUS_RUNNING; + } + + /** + * Build the interrupt message shape consumed by WP_Agent_Conversation_Loop. + * + * Runtimes that cannot abort an in-flight provider request immediately can + * persist this message for their loop-level `interrupt_source` to return. + * + * @param string $run_id Run to cancel. + * @param string $session_id Session containing the run. + * @param array $metadata Additional runtime metadata. + * @return array + */ + public static function cancellation_interrupt_message( + string $run_id, + string $session_id = '', + array $metadata = array() + ): array { + return WP_Agent_Message::text( + 'user', + 'Cancel this run.', + array_merge( + $metadata, + array( + 'type' => 'chat_run_interrupt', + 'interrupt_action' => 'cancel', + 'run_id' => $run_id, + 'session_id' => $session_id, + ) + ) + ); + } +} diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php new file mode 100644 index 0000000..b0d3cd9 --- /dev/null +++ b/tests/chat-run-control-smoke.php @@ -0,0 +1,150 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } +} + +function current_user_can( string $capability ): bool { + unset( $capability ); + return true; +} + +function wp_has_ability_category( string $category ): bool { + return isset( $GLOBALS['__agents_api_smoke_categories'][ $category ] ); +} + +function wp_register_ability_category( string $category, array $args ): void { + $GLOBALS['__agents_api_smoke_categories'][ $category ] = $args; +} + +function wp_has_ability( string $ability ): bool { + return isset( $GLOBALS['__agents_api_smoke_abilities'][ $ability ] ); +} + +function wp_register_ability( string $ability, array $args ): void { + $GLOBALS['__agents_api_smoke_abilities'][ $ability ] = $args; +} + +agents_api_smoke_require_module(); + +do_action( 'wp_abilities_api_categories_init' ); +do_action( 'wp_abilities_api_init' ); + +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_abilities'][ AgentsAPI\AI\Channels\AGENTS_GET_CHAT_RUN_ABILITY ] ), 'get-run ability registers', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_abilities'][ AgentsAPI\AI\Channels\AGENTS_CANCEL_CHAT_RUN_ABILITY ] ), 'cancel-run ability registers', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_abilities'][ AgentsAPI\AI\Channels\AGENTS_QUEUE_CHAT_MESSAGE_ABILITY ] ), 'queue-message ability registers', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'run_id', AgentsAPI\AI\Channels\agents_chat_output_schema()['required'] ?? array(), true ) || isset( AgentsAPI\AI\Channels\agents_chat_output_schema()['properties']['run_id'] ), 'chat output schema exposes run_id', $failures, $passes ); + +$captured_chat_input = array(); +add_filter( + 'wp_agent_chat_handler', + static function ( $handler, array $input ) use ( &$captured_chat_input ) { + unset( $handler ); + $captured_chat_input = $input; + return static function ( array $runtime_input ): array { + return array( + 'session_id' => 'session-1', + 'reply' => 'hello', + 'metadata' => array( 'runtime' => 'smoke' ), + ); + }; + }, + 10, + 2 +); + +$chat = AgentsAPI\AI\Channels\agents_chat_dispatch( + array( + 'agent' => 'demo-agent', + 'message' => 'Hello', + ) +); + +agents_api_smoke_assert_equals( true, is_array( $chat ), 'chat dispatch succeeds', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $captured_chat_input['run_id'] ) && '' !== $captured_chat_input['run_id'], 'chat dispatch passes generated run_id to runtime', $failures, $passes ); +agents_api_smoke_assert_equals( $captured_chat_input['run_id'], $chat['run_id'] ?? null, 'chat dispatch returns generated run_id', $failures, $passes ); + +add_filter( + 'wp_agent_chat_run_status_handler', + static fn() => static fn( array $input ): array => array( + 'run_id' => $input['run_id'], + 'session_id' => $input['session_id'], + 'status' => 'running', + 'started_at' => '2026-01-01T00:00:00Z', + 'updated_at' => '2026-01-01T00:00:01Z', + 'metadata' => array( 'provider' => 'test' ), + ), + 10, + 2 +); + +$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 ); + +add_filter( + 'wp_agent_chat_run_cancel_handler', + static fn() => static fn( array $input ): array => array( + 'run_id' => $input['run_id'], + 'session_id' => $input['session_id'], + 'status' => 'cancelling', + ), + 10, + 2 +); + +$cancelled = AgentsAPI\AI\Channels\agents_cancel_chat_run( array( 'session_id' => 'session-1', 'run_id' => 'run-1' ) ); +agents_api_smoke_assert_equals( true, $cancelled['cancelled'] ?? null, 'cancel-run marks cancelling as cancelled request accepted', $failures, $passes ); + +add_filter( + 'wp_agent_chat_message_queue_handler', + static fn() => static fn( array $input ): array => array( + 'queued_message_id' => 'queued-1', + 'run_id' => 'run-next', + 'session_id' => $input['session_id'], + 'position' => 1, + 'status' => 'queued', + ), + 10, + 2 +); + +$queued = AgentsAPI\AI\Channels\agents_queue_chat_message( + array( + 'agent' => 'demo-agent', + 'session_id' => 'session-1', + 'message' => 'Next', + ) +); +agents_api_smoke_assert_equals( 'queued-1', $queued['queued_message_id'] ?? null, 'queue-message returns queued message id', $failures, $passes ); +agents_api_smoke_assert_equals( 1, $queued['position'] ?? null, 'queue-message returns queue position', $failures, $passes ); + +$interrupt = AgentsAPI\AI\WP_Agent_Chat_Run_Control::cancellation_interrupt_message( 'run-1', 'session-1' ); +agents_api_smoke_assert_equals( 'cancel', $interrupt['metadata']['interrupt_action'] ?? null, 'cancellation helper maps to loop interrupt action', $failures, $passes ); +agents_api_smoke_assert_equals( 'run-1', $interrupt['metadata']['run_id'] ?? null, 'cancellation helper carries run id', $failures, $passes ); + +agents_api_smoke_finish( 'chat run-control', $failures, $passes );