From 1628c7c21cb6b8f563ddf8ed679345e4628840c7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 19:23:05 -0400 Subject: [PATCH 1/3] Add chat run lifecycle events --- ...ster-agents-chat-run-control-abilities.php | 93 ++++++++++- .../class-wp-agent-chat-run-control.php | 148 +++++++++++++++++- .../class-wp-agent-conversation-loop.php | 18 +++ tests/chat-run-control-smoke.php | 57 +++++++ tests/conversation-loop-events-smoke.php | 43 +++++ 5 files changed, 353 insertions(+), 6 deletions(-) diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 7162424..1a3ce19 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -11,14 +11,23 @@ defined( 'ABSPATH' ) || exit; -const AGENTS_GET_CHAT_RUN_ABILITY = 'agents/get-chat-run'; -const AGENTS_CANCEL_CHAT_RUN_ABILITY = 'agents/cancel-chat-run'; -const AGENTS_QUEUE_CHAT_MESSAGE_ABILITY = 'agents/queue-chat-message'; +const AGENTS_GET_CHAT_RUN_ABILITY = 'agents/get-chat-run'; +const AGENTS_CANCEL_CHAT_RUN_ABILITY = 'agents/cancel-chat-run'; +const AGENTS_QUEUE_CHAT_MESSAGE_ABILITY = 'agents/queue-chat-message'; +const AGENTS_LIST_CHAT_RUN_EVENTS_ABILITY = 'agents/list-chat-run-events'; add_action( 'wp_abilities_api_init', static function (): void { $abilities = array( + AGENTS_LIST_CHAT_RUN_EVENTS_ABILITY => array( + 'label' => 'List Chat Run Events', + 'description' => 'List canonical lifecycle events for an addressable chat run.', + 'input_schema' => agents_chat_run_events_input_schema(), + 'output_schema' => agents_chat_run_events_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_list_chat_run_events', + 'annotations' => array( 'idempotent' => true ), + ), AGENTS_GET_CHAT_RUN_ABILITY => array( 'label' => 'Get Chat Run', 'description' => 'Read the canonical status for an addressable chat run.', @@ -95,6 +104,26 @@ function agents_get_chat_run( array $input ) { return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', 'No chat run was found for the requested run_id.' ); } +/** @return array|\WP_Error */ +function agents_list_chat_run_events( array $input ) { + $handler = apply_filters( 'wp_agent_chat_run_events_handler', null, $input ); + if ( is_callable( $handler ) ) { + $result = call_user_func( $handler, $input ); + return agents_chat_run_events_normalize_result( $result ); + } + + try { + return WP_Agent_Chat_Run_Control::list_events( + (string) ( $input['session_id'] ?? '' ), + (string) ( $input['run_id'] ?? '' ), + (string) ( $input['cursor'] ?? '' ), + (int) ( $input['limit'] ?? 100 ) + ); + } catch ( \InvalidArgumentException $error ) { + return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', $error->getMessage() ); + } +} + /** @return array|\WP_Error */ function agents_cancel_chat_run( array $input ) { $handler = apply_filters( 'wp_agent_chat_run_cancel_handler', null, $input ); @@ -208,6 +237,26 @@ function agents_chat_run_control_normalize_result( $result, string $error_code ) } } +/** @return array|\WP_Error */ +function agents_chat_run_events_normalize_result( $result ) { + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! is_array( $result ) ) { + return new \WP_Error( 'agents_chat_run_invalid_events_result', 'Chat run event handlers must return an array or WP_Error.' ); + } + + $result['run_id'] = (string) ( $result['run_id'] ?? '' ); + $result['session_id'] = (string) ( $result['session_id'] ?? '' ); + $result['status'] = WP_Agent_Chat_Run_Control::normalize_status( $result['status'] ?? WP_Agent_Chat_Run_Control::STATUS_RUNNING ); + $result['events'] = is_array( $result['events'] ?? null ) ? array_values( $result['events'] ) : array(); + $result['cursor'] = (string) ( $result['cursor'] ?? '' ); + $result['has_more'] = (bool) ( $result['has_more'] ?? false ); + + return $result; +} + function agents_chat_run_control_no_handler( string $code, string $message ): \WP_Error { return new \WP_Error( $code, $message ); } @@ -224,6 +273,13 @@ function agents_chat_run_id_input_schema(): array { ); } +function agents_chat_run_events_input_schema(): array { + $schema = agents_chat_run_id_input_schema(); + $schema['properties']['cursor'] = array( 'type' => 'string' ); + $schema['properties']['limit'] = array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 1000 ); + return $schema; +} + function agents_chat_run_output_schema(): array { return array( 'type' => 'object', @@ -242,6 +298,37 @@ function agents_chat_run_output_schema(): array { ); } +function agents_chat_run_events_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'run_id', 'session_id', 'status', 'events', 'cursor' ), + 'properties' => array( + 'run_id' => array( 'type' => 'string' ), + 'session_id' => array( 'type' => 'string' ), + 'status' => array( + 'type' => 'string', + 'enum' => WP_Agent_Chat_Run_Control::statuses(), + ), + 'events' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'required' => array( 'id', 'type', 'created_at', 'metadata' ), + 'properties' => array( + 'id' => array( 'type' => 'string' ), + 'type' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + 'created_at' => array( 'type' => 'string' ), + 'metadata' => array( 'type' => 'object' ), + ), + ), + ), + 'cursor' => array( 'type' => 'string' ), + 'has_more' => array( 'type' => 'boolean' ), + ), + ); +} + function agents_cancel_chat_run_output_schema(): array { $schema = agents_chat_run_output_schema(); $schema['required'][] = 'cancelled'; diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 5d935d0..ddceaa9 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -21,6 +21,7 @@ class WP_Agent_Chat_Run_Control { public const STATUS_COMPLETED = 'completed'; public const STATUS_FAILED = 'failed'; private const OPTION_KEY = 'agents_api_chat_run_control'; + private const MAX_EVENTS = 200; /** @return string[] */ public static function statuses(): array { @@ -122,11 +123,97 @@ public static function start_run( string $run_id, string $session_id, array $met $state = self::state(); $state['runs'][ $run_id ] = $run; + $state['events'][ $session_id ][ $run_id ] = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); self::save_state( $state ); return self::normalize_run( $run ); } + /** + * Persist a UI-safe lifecycle event for an addressable chat run. + * + * @param string $session_id Session ID. + * @param string $run_id Run ID. + * @param string $type Lifecycle event type. + * @param array $payload Raw internal event payload. + * @return array Stored event. + */ + public static function record_event( string $session_id, string $run_id, string $type, array $payload = array() ): array { + $session_id = trim( $session_id ); + $run_id = trim( $run_id ); + $type = strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $type ) ?? '' ); + + if ( '' === $session_id || '' === $run_id || '' === $type ) { + throw new \InvalidArgumentException( 'session_id, run_id, and event type must be non-empty.' ); + } + + $state = self::state(); + $events = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); + $next = (int) ( $state['event_sequences'][ $session_id ][ $run_id ] ?? 0 ) + 1; + $event = array( + 'id' => 'evt_' . $next, + 'type' => $type, + 'message' => self::event_message( $type, $payload ), + 'created_at' => self::now(), + 'metadata' => self::safe_event_metadata( $payload ), + ); + + $events[] = $event; + $events = array_slice( $events, -1 * self::event_limit() ); + + $state['events'][ $session_id ][ $run_id ] = $events; + $state['event_sequences'][ $session_id ][ $run_id ] = $next; + if ( isset( $state['runs'][ $run_id ] ) ) { + $state['runs'][ $run_id ]['updated_at'] = self::now(); + } + + self::save_state( $state ); + + return $event; + } + + /** + * List persisted lifecycle events after an optional cursor. + * + * @param string $session_id Session ID. + * @param string $run_id Run ID. + * @param string $cursor Last event ID seen by the client. + * @param int $limit Maximum events to return. + * @return array Cursorable event page. + */ + public static function list_events( string $session_id, string $run_id, string $cursor = '', int $limit = 100 ): array { + $state = self::state(); + $run = self::get_run( $run_id ); + $events = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); + + if ( null === $run || $session_id !== (string) $run['session_id'] ) { + throw new \InvalidArgumentException( 'No chat run was found for the requested session_id and run_id.' ); + } + + $offset = 0; + if ( '' !== $cursor ) { + foreach ( $events as $index => $event ) { + if ( $cursor === (string) ( $event['id'] ?? '' ) ) { + $offset = $index + 1; + break; + } + } + } + + $limit = max( 1, min( self::event_limit(), $limit ) ); + $page = array_slice( $events, $offset, $limit ); + $next_cursor = ! empty( $page ) ? (string) ( $page[ count( $page ) - 1 ]['id'] ?? $cursor ) : $cursor; + + return array( + 'run_id' => $run_id, + 'session_id' => $session_id, + 'status' => $run['status'], + 'events' => $page, + 'cursor' => $next_cursor, + 'has_more' => ( $offset + count( $page ) ) < count( $events ), + ); + } + /** * Complete a stored chat run. * @@ -293,15 +380,70 @@ public static function cancellation_interrupt_message( ); } - /** @return array{runs:array>,queues:array>>} */ + /** @return array{runs:array>,queues:array>>,events:array>>>,event_sequences:array>} */ private static function state(): array { $state = function_exists( 'get_option' ) ? get_option( self::OPTION_KEY, array() ) : array(); return array( - 'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(), - 'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(), + 'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(), + 'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(), + 'events' => is_array( $state['events'] ?? null ) ? $state['events'] : array(), + 'event_sequences' => is_array( $state['event_sequences'] ?? null ) ? $state['event_sequences'] : array(), ); } + /** @param array $payload Raw event payload. */ + private static function safe_event_metadata( array $payload ): array { + $metadata = array(); + foreach ( array( 'turn', 'max_turns', 'message_count', 'tool_results', 'tool_name', 'tool_call_id', 'success', 'action_id', 'budget', 'name', 'limit', 'current', 'elapsed_seconds' ) as $key ) { + if ( array_key_exists( $key, $payload ) && is_scalar( $payload[ $key ] ) ) { + $metadata[ 'name' === $key ? 'budget_name' : $key ] = $payload[ $key ]; + } + } + + if ( isset( $payload['error'] ) && is_scalar( $payload['error'] ) ) { + $metadata['error'] = self::summarize_error( (string) $payload['error'] ); + } + + return $metadata; + } + + /** @param array $payload Raw event payload. */ + private static function event_message( string $type, array $payload ): string { + $tool = isset( $payload['tool_name'] ) && is_scalar( $payload['tool_name'] ) ? (string) $payload['tool_name'] : ''; + switch ( $type ) { + case 'turn_started': + return 'Thinking...'; + case 'tool_call': + return '' !== $tool ? 'Calling ' . $tool . '...' : 'Calling tool...'; + case 'tool_result': + return '' !== $tool ? 'Finished ' . $tool . '.' : 'Tool finished.'; + case 'approval_required': + return 'Approval required.'; + case 'budget_exceeded': + return 'Budget exceeded.'; + case 'completed': + return 'Run completed.'; + case 'failed': + return 'Run failed.'; + default: + return str_replace( '_', ' ', $type ) . '.'; + } + } + + private static function summarize_error( string $error ): string { + $error = trim( preg_replace( '/\s+/', ' ', $error ) ?? $error ); + return substr( $error, 0, 300 ); + } + + private static function event_limit(): int { + $limit = self::MAX_EVENTS; + if ( function_exists( 'apply_filters' ) ) { + $limit = (int) apply_filters( 'agents_api_chat_run_event_limit', $limit ); + } + + return max( 1, min( 1000, $limit ) ); + } + /** @param array $state State to persist. */ private static function save_state( array $state ): void { if ( function_exists( 'update_option' ) ) { diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index f1b636c..4953d26 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -90,6 +90,9 @@ public static function run( array $messages, callable $turn_runner, array $optio $request = self::resolve_request( $messages, $options ); $lock_session_id = self::resolve_lock_session_id( $options, $request ); $run_id = self::resolve_run_id( $options, $request ); + if ( '' !== $run_id && '' !== $lock_session_id ) { + $on_event = self::decorate_chat_run_event_sink( $on_event, $lock_session_id, $run_id ); + } $lock_ttl = self::resolve_lock_ttl( $options ); $lock_token = null; $budget_resolution = self::resolve_budgets( $options, $max_turns ); @@ -1012,6 +1015,21 @@ private static function emit_event( ?callable $on_event, string $event, array $p } } + private static function decorate_chat_run_event_sink( ?callable $on_event, string $session_id, string $run_id ): callable { + return static function ( string $event, array $payload = array() ) use ( $on_event, $session_id, $run_id ): void { + try { + WP_Agent_Chat_Run_Control::record_event( $session_id, $run_id, $event, $payload ); + } catch ( \Throwable $error ) { + // Event persistence must not change loop results. + unset( $error ); + } + + if ( null !== $on_event ) { + call_user_func( $on_event, $event, $payload ); + } + }; + } + /** * Build a stable, safe audit entry for a mediated tool call. * diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index 8923b2d..8f2f39c 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -68,6 +68,7 @@ function update_option( string $option, $value, $autoload = null ): bool { 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, isset( $GLOBALS['__agents_api_smoke_abilities'][ AgentsAPI\AI\Channels\AGENTS_LIST_CHAT_RUN_EVENTS_ABILITY ] ), 'list-run-events 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(); @@ -174,4 +175,60 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 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 ); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-events-1', 'session-events-1' ); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( + 'session-events-1', + 'run-events-1', + 'tool_call', + array( + 'turn' => 1, + 'tool_name' => 'client/secret-tool', + 'tool_call_id' => 'call-1', + 'parameters' => array( 'api_key' => 'secret-value' ), + ) +); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( + 'session-events-1', + 'run-events-1', + 'custom_event', + array( 'success' => true ) +); +AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( + 'session-events-1', + 'run-events-1', + 'tool_result', + array( + 'turn' => 1, + 'tool_name' => 'client/secret-tool', + 'tool_call_id' => 'call-1', + 'success' => true, + 'error' => str_repeat( 'x', 400 ), + ) +); + +$event_page = AgentsAPI\AI\Channels\agents_list_chat_run_events( + array( + 'session_id' => 'session-events-1', + 'run_id' => 'run-events-1', + 'limit' => 2, + ) +); +agents_api_smoke_assert_equals( 2, count( $event_page['events'] ?? array() ), 'run events list respects limit', $failures, $passes ); +agents_api_smoke_assert_equals( true, $event_page['has_more'] ?? null, 'run events page reports more results', $failures, $passes ); +agents_api_smoke_assert_equals( 'client/secret-tool', $event_page['events'][0]['metadata']['tool_name'] ?? null, 'run events preserve safe tool name', $failures, $passes ); +agents_api_smoke_assert_equals( false, isset( $event_page['events'][0]['metadata']['parameters'] ), 'run events omit raw tool parameters', $failures, $passes ); + +$next_event_page = AgentsAPI\AI\Channels\agents_list_chat_run_events( + array( + 'session_id' => 'session-events-1', + 'run_id' => 'run-events-1', + 'cursor' => $event_page['cursor'], + ) +); +agents_api_smoke_assert_equals( 1, count( $next_event_page['events'] ?? array() ), 'run events cursor returns only newer events', $failures, $passes ); +agents_api_smoke_assert_equals( 300, strlen( $next_event_page['events'][0]['metadata']['error'] ?? '' ), 'run events summarize errors', $failures, $passes ); + +$wrong_session_events = AgentsAPI\AI\Channels\agents_list_chat_run_events( array( 'session_id' => 'session-other', 'run_id' => 'run-events-1' ) ); +agents_api_smoke_assert_equals( true, $wrong_session_events instanceof WP_Error, 'run events are scoped by session and run id', $failures, $passes ); + agents_api_smoke_finish( 'chat run-control', $failures, $passes ); diff --git a/tests/conversation-loop-events-smoke.php b/tests/conversation-loop-events-smoke.php index d333605..8636fd3 100644 --- a/tests/conversation-loop-events-smoke.php +++ b/tests/conversation-loop-events-smoke.php @@ -17,6 +17,23 @@ echo "agents-api-conversation-loop-events-smoke\n"; require_once __DIR__ . '/agents-api-smoke-helpers.php'; + +$GLOBALS['__agents_api_smoke_options'] = array(); + +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(); // Build a tool executor. @@ -218,4 +235,30 @@ static function ( array $messages ): array { agents_api_smoke_assert_equals( 1, count( $no_event_result['messages'] ), 'loop works without event sink', $failures, $passes ); +echo "\n[7] Addressable runs persist safe lifecycle events:\n"; + +$addressable_result = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( + array( array( 'role' => 'user', 'content' => 'persist' ) ), + static function ( array $messages ): array { + $messages[] = AgentsAPI\AI\WP_Agent_Message::text( 'assistant', 'stored' ); + + return array( + 'messages' => $messages, + 'tool_execution_results' => array(), + 'events' => array(), + ); + }, + array( + 'max_turns' => 1, + 'transcript_session_id' => 'session-loop-events', + 'run_id' => 'run-loop-events', + ) +); + +$stored_event_page = AgentsAPI\AI\WP_Agent_Chat_Run_Control::list_events( 'session-loop-events', 'run-loop-events' ); +$stored_event_names = array_column( $stored_event_page['events'], 'type' ); +agents_api_smoke_assert_equals( 2, count( $addressable_result['messages'] ), 'addressable loop still returns result', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'turn_started', $stored_event_names, true ), 'addressable loop persists turn_started event', $failures, $passes ); +agents_api_smoke_assert_equals( true, in_array( 'completed', $stored_event_names, true ), 'addressable loop persists completed event', $failures, $passes ); + agents_api_smoke_finish( 'Agents API conversation loop events', $failures, $passes ); From cd32f4483ee6bdd9c55ae5f883a510c91aa2cf11 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 19:36:20 -0400 Subject: [PATCH 2/3] Fix chat run events lint --- ...ster-agents-chat-run-control-abilities.php | 16 +++++---- .../class-wp-agent-chat-run-control.php | 24 ++++++++++--- .../class-wp-agent-conversation-loop.php | 36 +++++++++---------- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 1a3ce19..4246ab1 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -28,7 +28,7 @@ static function (): void { 'execute_callback' => __NAMESPACE__ . '\\agents_list_chat_run_events', 'annotations' => array( 'idempotent' => true ), ), - AGENTS_GET_CHAT_RUN_ABILITY => array( + AGENTS_GET_CHAT_RUN_ABILITY => array( 'label' => 'Get Chat Run', 'description' => 'Read the canonical status for an addressable chat run.', 'input_schema' => agents_chat_run_id_input_schema(), @@ -36,7 +36,7 @@ static function (): void { 'execute_callback' => __NAMESPACE__ . '\\agents_get_chat_run', 'annotations' => array( 'idempotent' => true ), ), - AGENTS_CANCEL_CHAT_RUN_ABILITY => array( + 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(), @@ -47,7 +47,7 @@ static function (): void { 'idempotent' => true, ), ), - AGENTS_QUEUE_CHAT_MESSAGE_ABILITY => array( + 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(), @@ -274,9 +274,13 @@ function agents_chat_run_id_input_schema(): array { } function agents_chat_run_events_input_schema(): array { - $schema = agents_chat_run_id_input_schema(); - $schema['properties']['cursor'] = array( 'type' => 'string' ); - $schema['properties']['limit'] = array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 1000 ); + $schema = agents_chat_run_id_input_schema(); + $schema['properties']['cursor'] = array( 'type' => 'string' ); + $schema['properties']['limit'] = array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 1000, + ); return $schema; } diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index ddceaa9..370edf3 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -121,8 +121,8 @@ public static function start_run( string $run_id, string $session_id, array $met 'metadata' => $metadata, ); - $state = self::state(); - $state['runs'][ $run_id ] = $run; + $state = self::state(); + $state['runs'][ $run_id ] = $run; $state['events'][ $session_id ][ $run_id ] = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); self::save_state( $state ); @@ -193,7 +193,7 @@ public static function list_events( string $session_id, string $run_id, string $ $offset = 0; if ( '' !== $cursor ) { foreach ( $events as $index => $event ) { - if ( $cursor === (string) ( $event['id'] ?? '' ) ) { + if ( (string) ( $event['id'] ?? '' ) === $cursor ) { $offset = $index + 1; break; } @@ -394,7 +394,23 @@ private static function state(): array { /** @param array $payload Raw event payload. */ private static function safe_event_metadata( array $payload ): array { $metadata = array(); - foreach ( array( 'turn', 'max_turns', 'message_count', 'tool_results', 'tool_name', 'tool_call_id', 'success', 'action_id', 'budget', 'name', 'limit', 'current', 'elapsed_seconds' ) as $key ) { + foreach ( + array( + 'turn', + 'max_turns', + 'message_count', + 'tool_results', + 'tool_name', + 'tool_call_id', + 'success', + 'action_id', + 'budget', + 'name', + 'limit', + 'current', + 'elapsed_seconds', + ) as $key + ) { if ( array_key_exists( $key, $payload ) && is_scalar( $payload[ $key ] ) ) { $metadata[ 'name' === $key ? 'budget_name' : $key ] = $payload[ $key ]; } diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index 4953d26..c83b4bd 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -72,24 +72,24 @@ class WP_Agent_Conversation_Loop { * @return array Normalized conversation result. */ public static function run( array $messages, callable $turn_runner, array $options = array() ): array { - $runtime_overrides = self::resolve_runtime_overrides( $options ); - $options = self::apply_runtime_overrides_to_options( $options, $runtime_overrides ); - $max_turns = self::max_turns( $options['max_turns'] ?? 1 ); - $context = isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(); - $tool_executor = self::resolve_tool_executor( $options ); - $tool_declarations = self::resolve_tool_declarations( $options ); - $should_continue = self::resolve_should_continue( $options, $tool_executor, $tool_declarations ); - $completion_policy = self::resolve_completion_policy( $options ); - $transcript_persister = self::resolve_transcript_persister( $options ); - $transcript_lock = self::resolve_transcript_lock( $options ); - $on_event = self::resolve_event_sink( $options ); - $spin_detector = self::resolve_spin_detector( $options ); - $failure_tracker = self::resolve_identical_failure_tracker( $options ); - $result_truncator = self::resolve_tool_result_truncator( $options ); - $interrupt_source = self::resolve_interrupt_source( $options ); - $request = self::resolve_request( $messages, $options ); - $lock_session_id = self::resolve_lock_session_id( $options, $request ); - $run_id = self::resolve_run_id( $options, $request ); + $runtime_overrides = self::resolve_runtime_overrides( $options ); + $options = self::apply_runtime_overrides_to_options( $options, $runtime_overrides ); + $max_turns = self::max_turns( $options['max_turns'] ?? 1 ); + $context = isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(); + $tool_executor = self::resolve_tool_executor( $options ); + $tool_declarations = self::resolve_tool_declarations( $options ); + $should_continue = self::resolve_should_continue( $options, $tool_executor, $tool_declarations ); + $completion_policy = self::resolve_completion_policy( $options ); + $transcript_persister = self::resolve_transcript_persister( $options ); + $transcript_lock = self::resolve_transcript_lock( $options ); + $on_event = self::resolve_event_sink( $options ); + $spin_detector = self::resolve_spin_detector( $options ); + $failure_tracker = self::resolve_identical_failure_tracker( $options ); + $result_truncator = self::resolve_tool_result_truncator( $options ); + $interrupt_source = self::resolve_interrupt_source( $options ); + $request = self::resolve_request( $messages, $options ); + $lock_session_id = self::resolve_lock_session_id( $options, $request ); + $run_id = self::resolve_run_id( $options, $request ); if ( '' !== $run_id && '' !== $lock_session_id ) { $on_event = self::decorate_chat_run_event_sink( $on_event, $lock_session_id, $run_id ); } From 981d791e2d2a672573e2fb395423d53c6e2e6c2f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 29 May 2026 19:47:18 -0400 Subject: [PATCH 3/3] Keep chat run events host-owned --- ...ster-agents-chat-run-control-abilities.php | 11 +- .../class-wp-agent-chat-run-control.php | 168 +----------------- .../class-wp-agent-conversation-loop.php | 54 ++---- tests/chat-run-control-smoke.php | 81 ++++----- tests/conversation-loop-events-smoke.php | 43 ----- 5 files changed, 58 insertions(+), 299 deletions(-) diff --git a/src/Channels/register-agents-chat-run-control-abilities.php b/src/Channels/register-agents-chat-run-control-abilities.php index 4246ab1..b394ebb 100644 --- a/src/Channels/register-agents-chat-run-control-abilities.php +++ b/src/Channels/register-agents-chat-run-control-abilities.php @@ -112,16 +112,7 @@ function agents_list_chat_run_events( array $input ) { return agents_chat_run_events_normalize_result( $result ); } - try { - return WP_Agent_Chat_Run_Control::list_events( - (string) ( $input['session_id'] ?? '' ), - (string) ( $input['run_id'] ?? '' ), - (string) ( $input['cursor'] ?? '' ), - (int) ( $input['limit'] ?? 100 ) - ); - } catch ( \InvalidArgumentException $error ) { - return agents_chat_run_control_no_handler( 'agents_chat_run_not_found', $error->getMessage() ); - } + return agents_chat_run_control_no_handler( 'agents_chat_run_events_no_handler', 'No chat run events handler is registered.' ); } /** @return array|\WP_Error */ diff --git a/src/Runtime/class-wp-agent-chat-run-control.php b/src/Runtime/class-wp-agent-chat-run-control.php index 370edf3..5d935d0 100644 --- a/src/Runtime/class-wp-agent-chat-run-control.php +++ b/src/Runtime/class-wp-agent-chat-run-control.php @@ -21,7 +21,6 @@ class WP_Agent_Chat_Run_Control { public const STATUS_COMPLETED = 'completed'; public const STATUS_FAILED = 'failed'; private const OPTION_KEY = 'agents_api_chat_run_control'; - private const MAX_EVENTS = 200; /** @return string[] */ public static function statuses(): array { @@ -121,99 +120,13 @@ public static function start_run( string $run_id, string $session_id, array $met 'metadata' => $metadata, ); - $state = self::state(); - $state['runs'][ $run_id ] = $run; - $state['events'][ $session_id ][ $run_id ] = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); + $state = self::state(); + $state['runs'][ $run_id ] = $run; self::save_state( $state ); return self::normalize_run( $run ); } - /** - * Persist a UI-safe lifecycle event for an addressable chat run. - * - * @param string $session_id Session ID. - * @param string $run_id Run ID. - * @param string $type Lifecycle event type. - * @param array $payload Raw internal event payload. - * @return array Stored event. - */ - public static function record_event( string $session_id, string $run_id, string $type, array $payload = array() ): array { - $session_id = trim( $session_id ); - $run_id = trim( $run_id ); - $type = strtolower( preg_replace( '/[^a-z0-9_\-]/', '', $type ) ?? '' ); - - if ( '' === $session_id || '' === $run_id || '' === $type ) { - throw new \InvalidArgumentException( 'session_id, run_id, and event type must be non-empty.' ); - } - - $state = self::state(); - $events = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); - $next = (int) ( $state['event_sequences'][ $session_id ][ $run_id ] ?? 0 ) + 1; - $event = array( - 'id' => 'evt_' . $next, - 'type' => $type, - 'message' => self::event_message( $type, $payload ), - 'created_at' => self::now(), - 'metadata' => self::safe_event_metadata( $payload ), - ); - - $events[] = $event; - $events = array_slice( $events, -1 * self::event_limit() ); - - $state['events'][ $session_id ][ $run_id ] = $events; - $state['event_sequences'][ $session_id ][ $run_id ] = $next; - if ( isset( $state['runs'][ $run_id ] ) ) { - $state['runs'][ $run_id ]['updated_at'] = self::now(); - } - - self::save_state( $state ); - - return $event; - } - - /** - * List persisted lifecycle events after an optional cursor. - * - * @param string $session_id Session ID. - * @param string $run_id Run ID. - * @param string $cursor Last event ID seen by the client. - * @param int $limit Maximum events to return. - * @return array Cursorable event page. - */ - public static function list_events( string $session_id, string $run_id, string $cursor = '', int $limit = 100 ): array { - $state = self::state(); - $run = self::get_run( $run_id ); - $events = array_values( $state['events'][ $session_id ][ $run_id ] ?? array() ); - - if ( null === $run || $session_id !== (string) $run['session_id'] ) { - throw new \InvalidArgumentException( 'No chat run was found for the requested session_id and run_id.' ); - } - - $offset = 0; - if ( '' !== $cursor ) { - foreach ( $events as $index => $event ) { - if ( (string) ( $event['id'] ?? '' ) === $cursor ) { - $offset = $index + 1; - break; - } - } - } - - $limit = max( 1, min( self::event_limit(), $limit ) ); - $page = array_slice( $events, $offset, $limit ); - $next_cursor = ! empty( $page ) ? (string) ( $page[ count( $page ) - 1 ]['id'] ?? $cursor ) : $cursor; - - return array( - 'run_id' => $run_id, - 'session_id' => $session_id, - 'status' => $run['status'], - 'events' => $page, - 'cursor' => $next_cursor, - 'has_more' => ( $offset + count( $page ) ) < count( $events ), - ); - } - /** * Complete a stored chat run. * @@ -380,86 +293,15 @@ public static function cancellation_interrupt_message( ); } - /** @return array{runs:array>,queues:array>>,events:array>>>,event_sequences:array>} */ + /** @return array{runs:array>,queues:array>>} */ private static function state(): array { $state = function_exists( 'get_option' ) ? get_option( self::OPTION_KEY, array() ) : array(); return array( - 'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(), - 'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(), - 'events' => is_array( $state['events'] ?? null ) ? $state['events'] : array(), - 'event_sequences' => is_array( $state['event_sequences'] ?? null ) ? $state['event_sequences'] : array(), + 'runs' => is_array( $state['runs'] ?? null ) ? $state['runs'] : array(), + 'queues' => is_array( $state['queues'] ?? null ) ? $state['queues'] : array(), ); } - /** @param array $payload Raw event payload. */ - private static function safe_event_metadata( array $payload ): array { - $metadata = array(); - foreach ( - array( - 'turn', - 'max_turns', - 'message_count', - 'tool_results', - 'tool_name', - 'tool_call_id', - 'success', - 'action_id', - 'budget', - 'name', - 'limit', - 'current', - 'elapsed_seconds', - ) as $key - ) { - if ( array_key_exists( $key, $payload ) && is_scalar( $payload[ $key ] ) ) { - $metadata[ 'name' === $key ? 'budget_name' : $key ] = $payload[ $key ]; - } - } - - if ( isset( $payload['error'] ) && is_scalar( $payload['error'] ) ) { - $metadata['error'] = self::summarize_error( (string) $payload['error'] ); - } - - return $metadata; - } - - /** @param array $payload Raw event payload. */ - private static function event_message( string $type, array $payload ): string { - $tool = isset( $payload['tool_name'] ) && is_scalar( $payload['tool_name'] ) ? (string) $payload['tool_name'] : ''; - switch ( $type ) { - case 'turn_started': - return 'Thinking...'; - case 'tool_call': - return '' !== $tool ? 'Calling ' . $tool . '...' : 'Calling tool...'; - case 'tool_result': - return '' !== $tool ? 'Finished ' . $tool . '.' : 'Tool finished.'; - case 'approval_required': - return 'Approval required.'; - case 'budget_exceeded': - return 'Budget exceeded.'; - case 'completed': - return 'Run completed.'; - case 'failed': - return 'Run failed.'; - default: - return str_replace( '_', ' ', $type ) . '.'; - } - } - - private static function summarize_error( string $error ): string { - $error = trim( preg_replace( '/\s+/', ' ', $error ) ?? $error ); - return substr( $error, 0, 300 ); - } - - private static function event_limit(): int { - $limit = self::MAX_EVENTS; - if ( function_exists( 'apply_filters' ) ) { - $limit = (int) apply_filters( 'agents_api_chat_run_event_limit', $limit ); - } - - return max( 1, min( 1000, $limit ) ); - } - /** @param array $state State to persist. */ private static function save_state( array $state ): void { if ( function_exists( 'update_option' ) ) { diff --git a/src/Runtime/class-wp-agent-conversation-loop.php b/src/Runtime/class-wp-agent-conversation-loop.php index c83b4bd..f1b636c 100644 --- a/src/Runtime/class-wp-agent-conversation-loop.php +++ b/src/Runtime/class-wp-agent-conversation-loop.php @@ -72,27 +72,24 @@ class WP_Agent_Conversation_Loop { * @return array Normalized conversation result. */ public static function run( array $messages, callable $turn_runner, array $options = array() ): array { - $runtime_overrides = self::resolve_runtime_overrides( $options ); - $options = self::apply_runtime_overrides_to_options( $options, $runtime_overrides ); - $max_turns = self::max_turns( $options['max_turns'] ?? 1 ); - $context = isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(); - $tool_executor = self::resolve_tool_executor( $options ); - $tool_declarations = self::resolve_tool_declarations( $options ); - $should_continue = self::resolve_should_continue( $options, $tool_executor, $tool_declarations ); - $completion_policy = self::resolve_completion_policy( $options ); - $transcript_persister = self::resolve_transcript_persister( $options ); - $transcript_lock = self::resolve_transcript_lock( $options ); - $on_event = self::resolve_event_sink( $options ); - $spin_detector = self::resolve_spin_detector( $options ); - $failure_tracker = self::resolve_identical_failure_tracker( $options ); - $result_truncator = self::resolve_tool_result_truncator( $options ); - $interrupt_source = self::resolve_interrupt_source( $options ); - $request = self::resolve_request( $messages, $options ); - $lock_session_id = self::resolve_lock_session_id( $options, $request ); - $run_id = self::resolve_run_id( $options, $request ); - if ( '' !== $run_id && '' !== $lock_session_id ) { - $on_event = self::decorate_chat_run_event_sink( $on_event, $lock_session_id, $run_id ); - } + $runtime_overrides = self::resolve_runtime_overrides( $options ); + $options = self::apply_runtime_overrides_to_options( $options, $runtime_overrides ); + $max_turns = self::max_turns( $options['max_turns'] ?? 1 ); + $context = isset( $options['context'] ) && is_array( $options['context'] ) ? $options['context'] : array(); + $tool_executor = self::resolve_tool_executor( $options ); + $tool_declarations = self::resolve_tool_declarations( $options ); + $should_continue = self::resolve_should_continue( $options, $tool_executor, $tool_declarations ); + $completion_policy = self::resolve_completion_policy( $options ); + $transcript_persister = self::resolve_transcript_persister( $options ); + $transcript_lock = self::resolve_transcript_lock( $options ); + $on_event = self::resolve_event_sink( $options ); + $spin_detector = self::resolve_spin_detector( $options ); + $failure_tracker = self::resolve_identical_failure_tracker( $options ); + $result_truncator = self::resolve_tool_result_truncator( $options ); + $interrupt_source = self::resolve_interrupt_source( $options ); + $request = self::resolve_request( $messages, $options ); + $lock_session_id = self::resolve_lock_session_id( $options, $request ); + $run_id = self::resolve_run_id( $options, $request ); $lock_ttl = self::resolve_lock_ttl( $options ); $lock_token = null; $budget_resolution = self::resolve_budgets( $options, $max_turns ); @@ -1015,21 +1012,6 @@ private static function emit_event( ?callable $on_event, string $event, array $p } } - private static function decorate_chat_run_event_sink( ?callable $on_event, string $session_id, string $run_id ): callable { - return static function ( string $event, array $payload = array() ) use ( $on_event, $session_id, $run_id ): void { - try { - WP_Agent_Chat_Run_Control::record_event( $session_id, $run_id, $event, $payload ); - } catch ( \Throwable $error ) { - // Event persistence must not change loop results. - unset( $error ); - } - - if ( null !== $on_event ) { - call_user_func( $on_event, $event, $payload ); - } - }; - } - /** * Build a stable, safe audit entry for a mediated tool call. * diff --git a/tests/chat-run-control-smoke.php b/tests/chat-run-control-smoke.php index 8f2f39c..0a175a6 100644 --- a/tests/chat-run-control-smoke.php +++ b/tests/chat-run-control-smoke.php @@ -175,60 +175,47 @@ static function ( $handler, array $input ) use ( &$captured_chat_input ) { 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 ); -AgentsAPI\AI\WP_Agent_Chat_Run_Control::start_run( 'run-events-1', 'session-events-1' ); -AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( - 'session-events-1', - 'run-events-1', - 'tool_call', - array( - 'turn' => 1, - 'tool_name' => 'client/secret-tool', - 'tool_call_id' => 'call-1', - 'parameters' => array( 'api_key' => 'secret-value' ), - ) -); -AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( - 'session-events-1', - 'run-events-1', - 'custom_event', - array( 'success' => true ) -); -AgentsAPI\AI\WP_Agent_Chat_Run_Control::record_event( - 'session-events-1', - 'run-events-1', - 'tool_result', - array( - 'turn' => 1, - 'tool_name' => 'client/secret-tool', - 'tool_call_id' => 'call-1', - 'success' => true, - 'error' => str_repeat( 'x', 400 ), - ) -); +$no_events_handler = AgentsAPI\AI\Channels\agents_list_chat_run_events( array( 'session_id' => 'session-events-1', 'run_id' => 'run-events-1' ) ); +agents_api_smoke_assert_equals( true, $no_events_handler instanceof WP_Error, 'run events require host handler', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_chat_run_events_no_handler', $no_events_handler->get_error_code(), 'run events no-handler error is explicit', $failures, $passes ); -$event_page = AgentsAPI\AI\Channels\agents_list_chat_run_events( - array( - 'session_id' => 'session-events-1', - 'run_id' => 'run-events-1', - 'limit' => 2, - ) +add_filter( + 'wp_agent_chat_run_events_handler', + static fn() => static fn( array $input ): array => array( + 'run_id' => $input['run_id'], + 'session_id' => $input['session_id'], + 'status' => 'running', + 'events' => array( + array( + 'id' => 'evt_1', + 'type' => 'tool_call', + 'message' => 'Calling client/tool...', + 'created_at' => '2026-01-01T00:00:00Z', + 'metadata' => array( + 'turn' => 1, + 'tool_name' => 'client/tool', + 'tool_call_id' => 'call-1', + ), + ), + ), + 'cursor' => 'evt_1', + 'has_more' => false, + ), + 10, + 2 ); -agents_api_smoke_assert_equals( 2, count( $event_page['events'] ?? array() ), 'run events list respects limit', $failures, $passes ); -agents_api_smoke_assert_equals( true, $event_page['has_more'] ?? null, 'run events page reports more results', $failures, $passes ); -agents_api_smoke_assert_equals( 'client/secret-tool', $event_page['events'][0]['metadata']['tool_name'] ?? null, 'run events preserve safe tool name', $failures, $passes ); -agents_api_smoke_assert_equals( false, isset( $event_page['events'][0]['metadata']['parameters'] ), 'run events omit raw tool parameters', $failures, $passes ); -$next_event_page = AgentsAPI\AI\Channels\agents_list_chat_run_events( +$event_page = AgentsAPI\AI\Channels\agents_list_chat_run_events( array( 'session_id' => 'session-events-1', 'run_id' => 'run-events-1', - 'cursor' => $event_page['cursor'], + 'cursor' => 'evt_0', ) ); -agents_api_smoke_assert_equals( 1, count( $next_event_page['events'] ?? array() ), 'run events cursor returns only newer events', $failures, $passes ); -agents_api_smoke_assert_equals( 300, strlen( $next_event_page['events'][0]['metadata']['error'] ?? '' ), 'run events summarize errors', $failures, $passes ); - -$wrong_session_events = AgentsAPI\AI\Channels\agents_list_chat_run_events( array( 'session_id' => 'session-other', 'run_id' => 'run-events-1' ) ); -agents_api_smoke_assert_equals( true, $wrong_session_events instanceof WP_Error, 'run events are scoped by session and run id', $failures, $passes ); +agents_api_smoke_assert_equals( 'run-events-1', $event_page['run_id'] ?? null, 'run events handler preserves run id', $failures, $passes ); +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( 'client/tool', $event_page['events'][0]['metadata']['tool_name'] ?? null, 'run events handler returns safe metadata', $failures, $passes ); agents_api_smoke_finish( 'chat run-control', $failures, $passes ); diff --git a/tests/conversation-loop-events-smoke.php b/tests/conversation-loop-events-smoke.php index 8636fd3..d333605 100644 --- a/tests/conversation-loop-events-smoke.php +++ b/tests/conversation-loop-events-smoke.php @@ -17,23 +17,6 @@ echo "agents-api-conversation-loop-events-smoke\n"; require_once __DIR__ . '/agents-api-smoke-helpers.php'; - -$GLOBALS['__agents_api_smoke_options'] = array(); - -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(); // Build a tool executor. @@ -235,30 +218,4 @@ static function ( array $messages ): array { agents_api_smoke_assert_equals( 1, count( $no_event_result['messages'] ), 'loop works without event sink', $failures, $passes ); -echo "\n[7] Addressable runs persist safe lifecycle events:\n"; - -$addressable_result = AgentsAPI\AI\WP_Agent_Conversation_Loop::run( - array( array( 'role' => 'user', 'content' => 'persist' ) ), - static function ( array $messages ): array { - $messages[] = AgentsAPI\AI\WP_Agent_Message::text( 'assistant', 'stored' ); - - return array( - 'messages' => $messages, - 'tool_execution_results' => array(), - 'events' => array(), - ); - }, - array( - 'max_turns' => 1, - 'transcript_session_id' => 'session-loop-events', - 'run_id' => 'run-loop-events', - ) -); - -$stored_event_page = AgentsAPI\AI\WP_Agent_Chat_Run_Control::list_events( 'session-loop-events', 'run-loop-events' ); -$stored_event_names = array_column( $stored_event_page['events'], 'type' ); -agents_api_smoke_assert_equals( 2, count( $addressable_result['messages'] ), 'addressable loop still returns result', $failures, $passes ); -agents_api_smoke_assert_equals( true, in_array( 'turn_started', $stored_event_names, true ), 'addressable loop persists turn_started event', $failures, $passes ); -agents_api_smoke_assert_equals( true, in_array( 'completed', $stored_event_names, true ), 'addressable loop persists completed event', $failures, $passes ); - agents_api_smoke_finish( 'Agents API conversation loop events', $failures, $passes );