From aa889bcd5e54656732c53a2c88f937c95a29c323 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 12 May 2026 19:56:40 -0400 Subject: [PATCH 1/2] Add frontend chat REST adapter --- agents-api.php | 1 + composer.json | 1 + .../register-frontend-chat-rest-route.php | 237 ++++++++++++++++++ tests/agents-api-smoke-helpers.php | 2 +- tests/frontend-chat-rest-smoke.php | 165 ++++++++++++ 5 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 src/Channels/register-frontend-chat-rest-route.php create mode 100644 tests/frontend-chat-rest-smoke.php diff --git a/agents-api.php b/agents-api.php index 715e428..83d2824 100644 --- a/agents-api.php +++ b/agents-api.php @@ -132,6 +132,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-frontend-chat-rest-route.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-bindings.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec.php'; diff --git a/composer.json b/composer.json index e3827ce..fb5d9e8 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", + "php tests/frontend-chat-rest-smoke.php", "php tests/webhook-safety-smoke.php", "php tests/remote-bridge-smoke.php", "php tests/context-authority-smoke.php", diff --git a/src/Channels/register-frontend-chat-rest-route.php b/src/Channels/register-frontend-chat-rest-route.php new file mode 100644 index 0000000..f37ed9f --- /dev/null +++ b/src/Channels/register-frontend-chat-rest-route.php @@ -0,0 +1,237 @@ + 'POST', + 'callback' => __NAMESPACE__ . '\\agents_frontend_chat_rest_dispatch', + 'permission_callback' => __NAMESPACE__ . '\\agents_frontend_chat_rest_permission', + 'args' => agents_frontend_chat_rest_args(), + ) + ); + } +); + +/** + * Dispatch one REST chat turn through the canonical agents/chat ability. + * + * @param \WP_REST_Request $request REST request. + * @return \WP_REST_Response|\WP_Error + */ +function agents_frontend_chat_rest_dispatch( \WP_REST_Request $request ) { + $input = agents_frontend_chat_rest_input( $request ); + if ( is_wp_error( $input ) ) { + return $input; + } + + $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( AGENTS_CHAT_ABILITY ) : null; + + if ( ! $ability ) { + return new \WP_Error( + 'agents_frontend_chat_ability_unavailable', + 'The agents/chat ability is not available.', + array( 'status' => 500 ) + ); + } + + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return rest_ensure_response( $result ); +} + +/** + * Permission gate for the frontend chat REST route. + * + * @param \WP_REST_Request $request REST request. + */ +function agents_frontend_chat_rest_permission( \WP_REST_Request $request ) { + $input = agents_frontend_chat_rest_input( $request ); + if ( is_wp_error( $input ) ) { + return $input; + } + + $agent = isset( $input['agent'] ) ? (string) $input['agent'] : ''; + $allowed = '' !== $agent && agents_chat_permission( $input ); + + if ( ! $allowed && '' !== $agent ) { + $allowed = \WP_Agent_Access::can_current_principal_access_agent( $agent, \WP_Agent_Access_Grant::ROLE_OPERATOR, agents_frontend_chat_rest_scope( $request ) ); + } + + /** + * Filter the frontend chat REST permission decision. + * + * @param bool $allowed Default access decision. + * @param array $input Canonical agents/chat input. + * @param \WP_REST_Request $request REST request. + */ + $allowed = (bool) apply_filters( 'agents_frontend_chat_rest_permission', $allowed, $input, $request ); + + if ( $allowed ) { + return true; + } + + return new \WP_Error( + 'agents_frontend_chat_forbidden', + 'You are not allowed to chat with this agent.', + array( 'status' => 403 ) + ); +} + +/** + * Build canonical agents/chat input from a REST request. + * + * @param \WP_REST_Request $request REST request. + * @return array|\WP_Error + */ +function agents_frontend_chat_rest_input( \WP_REST_Request $request ) { + static $cache = null; + + if ( ! $cache instanceof \SplObjectStorage ) { + $cache = new \SplObjectStorage(); + } + + if ( $cache->offsetExists( $request ) ) { + return $cache[ $request ]; + } + + $client_context = $request->get_param( 'client_context' ); + $client_context = is_array( $client_context ) ? $client_context : array(); + $session_id = $request->get_param( 'session_id' ); + $client_context = array_merge( + $client_context, + array( + 'source' => 'rest', + 'client_name' => isset( $client_context['client_name'] ) && '' !== (string) $client_context['client_name'] ? (string) $client_context['client_name'] : 'frontend-chat', + ) + ); + + $attachments = $request->get_param( 'attachments' ); + $input = array( + 'agent' => sanitize_title( (string) $request->get_param( 'agent' ) ), + 'message' => (string) $request->get_param( 'message' ), + 'session_id' => null !== $session_id ? (string) $session_id : null, + 'attachments' => is_array( $attachments ) ? $attachments : array(), + 'client_context' => $client_context, + ); + + /** + * Filter the canonical agents/chat input built by the REST adapter. + * + * @param array $input Canonical agents/chat input. + * @param \WP_REST_Request $request REST request. + */ + /** @var mixed $filtered_input Hosts may accidentally return invalid values from this filter. */ + $filtered_input = apply_filters( 'agents_frontend_chat_rest_input', $input, $request ); + if ( ! is_array( $filtered_input ) ) { + $cache[ $request ] = new \WP_Error( + 'agents_frontend_chat_invalid_input', + 'The frontend chat REST input filter must return an array.', + array( 'status' => 400 ) + ); + + return $cache[ $request ]; + } + $input = $filtered_input; + + if ( '' === (string) ( $input['agent'] ?? '' ) || '' === trim( (string) ( $input['message'] ?? '' ) ) ) { + $cache[ $request ] = new \WP_Error( + 'agents_frontend_chat_invalid_input', + 'The frontend chat REST request requires a non-empty agent and message.', + array( 'status' => 400 ) + ); + + return $cache[ $request ]; + } + + $cache[ $request ] = $input; + return $input; +} + +/** + * Build request context for principal/access helpers. + * + * @param \WP_REST_Request $request REST request. + * @return array + */ +function agents_frontend_chat_rest_scope( \WP_REST_Request $request ): array { + $scope = \AgentsAPI\AI\Auth\agents_access_request_scope( + array( + 'workspace_id' => $request->get_param( 'workspace_id' ), + 'client_id' => $request->get_param( 'client_id' ), + ) + ); + $scope['request_metadata'] = array( + 'rest_route' => AGENTS_FRONTEND_CHAT_REST_NAMESPACE . AGENTS_FRONTEND_CHAT_REST_ROUTE, + ); + + return $scope; +} + +/** + * REST argument schema. + * + * @return array> + */ +function agents_frontend_chat_rest_args(): array { + $schema = agents_chat_input_schema(); + $properties = $schema['properties'] ?? array(); + + return array( + 'agent' => array_merge( + $properties['agent'] ?? array( 'type' => 'string' ), + array( + 'required' => true, + 'sanitize_callback' => 'sanitize_title', + 'validate_callback' => __NAMESPACE__ . '\\agents_frontend_chat_rest_validate_non_empty_string', + ) + ), + 'message' => array_merge( + $properties['message'] ?? array( 'type' => 'string' ), + array( + 'required' => true, + 'validate_callback' => __NAMESPACE__ . '\\agents_frontend_chat_rest_validate_non_empty_string', + ) + ), + 'session_id' => array_merge( $properties['session_id'] ?? array( 'type' => array( 'string', 'null' ) ), array( 'required' => false ) ), + 'attachments' => array_merge( $properties['attachments'] ?? array( 'type' => 'array' ), array( 'required' => false ) ), + 'client_context' => array_merge( $properties['client_context'] ?? array( 'type' => 'object' ), array( 'required' => false ) ), + 'workspace_id' => array( + 'type' => array( 'string', 'null' ), + 'required' => false, + 'description' => 'Optional host workspace/scope identifier for access checks.', + ), + 'client_id' => array( + 'type' => array( 'string', 'null' ), + 'required' => false, + 'description' => 'Optional host client identifier for access checks.', + ), + ); +} + +/** + * Validate non-empty REST string arguments. + * + * @param mixed $value REST argument value. + */ +function agents_frontend_chat_rest_validate_non_empty_string( $value ): bool { + return is_string( $value ) && '' !== trim( $value ); +} diff --git a/tests/agents-api-smoke-helpers.php b/tests/agents-api-smoke-helpers.php index c8ea6fe..30f14a7 100644 --- a/tests/agents-api-smoke-helpers.php +++ b/tests/agents-api-smoke-helpers.php @@ -180,7 +180,7 @@ function wp_set_object_terms( int $post_id, $terms, string $taxonomy ): void { } function is_wp_error( $value ): bool { - return false; + return class_exists( 'WP_Error' ) && $value instanceof WP_Error; } function agents_api_smoke_assert_equals( $expected, $actual, string $name, array &$failures, int &$passes ): void { diff --git a/tests/frontend-chat-rest-smoke.php b/tests/frontend-chat-rest-smoke.php new file mode 100644 index 0000000..3450df4 --- /dev/null +++ b/tests/frontend-chat-rest-smoke.php @@ -0,0 +1,165 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } +} + +class WP_REST_Request { + public function __construct( private array $params = array() ) {} + public function get_param( string $key ) { + return $this->params[ $key ] ?? null; + } +} + +class WP_REST_Response { + public function __construct( public mixed $data ) {} +} + +function rest_ensure_response( $value ): WP_REST_Response { + return $value instanceof WP_REST_Response ? $value : new WP_REST_Response( $value ); +} + +function register_rest_route( string $namespace, string $route, array $args ): void { + $GLOBALS['__agents_api_smoke_routes'][ $namespace . $route ] = $args; +} + +function get_current_user_id(): int { + return (int) $GLOBALS['__agents_api_smoke_current_user_id']; +} + +function current_user_can( string $capability ): bool { + unset( $capability ); + return (bool) ( $GLOBALS['__agents_api_smoke_can_manage'] ?? false ); +} + +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; +} + +function wp_get_ability( string $name ) { + return $GLOBALS['__agents_api_smoke_abilities'][ $name ] ?? null; +} + +agents_api_smoke_require_module(); + +$grant = new WP_Agent_Access_Grant( 'support-agent', 7, WP_Agent_Access_Grant::ROLE_OPERATOR, 'site:42' ); + +$access_store = new class( $grant ) implements WP_Agent_Access_Store { + public function __construct( private WP_Agent_Access_Grant $grant ) {} + public function grant_access( WP_Agent_Access_Grant $grant ): WP_Agent_Access_Grant { $this->grant = $grant; return $grant; } + public function revoke_access( string $agent_id, int $user_id, ?string $workspace_id = null ): bool { return false; } + public function get_access( string $agent_id, int $user_id, ?string $workspace_id = null ): ?WP_Agent_Access_Grant { + return $this->grant->agent_id === $agent_id && $this->grant->user_id === $user_id && $this->grant->workspace_id === $workspace_id ? $this->grant : null; + } + public function get_agent_ids_for_user( int $user_id, ?string $minimum_role = null, ?string $workspace_id = null ): array { return array(); } + public function get_users_for_agent( string $agent_id, ?string $workspace_id = null ): array { return array(); } +}; + +add_filter( 'wp_agent_access_store', static fn( $store ) => $store instanceof WP_Agent_Access_Store ? $store : $access_store ); + +do_action( 'rest_api_init' ); + +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_routes']['agents-api/v1/chat'] ), 'chat REST route registers', $failures, $passes ); +agents_api_smoke_assert_equals( 'POST', $GLOBALS['__agents_api_smoke_routes']['agents-api/v1/chat']['methods'] ?? null, 'chat REST route uses POST', $failures, $passes ); +agents_api_smoke_assert_equals( true, isset( $GLOBALS['__agents_api_smoke_routes']['agents-api/v1/chat']['args']['attachments']['items'] ), 'chat REST args derive attachment schema', $failures, $passes ); + +$captured = array(); +$ability = new class( $captured ) { + private array $captured; + + public function __construct( array &$captured ) { + $this->captured =& $captured; + } + + public function execute( array $input ): array { + $this->captured = $input; + return array( 'session_id' => 's-1', 'reply' => 'hello from adapter' ); + } +}; + +$GLOBALS['__agents_api_smoke_abilities'][ AgentsAPI\AI\Channels\AGENTS_CHAT_ABILITY ] = $ability; + +$request = new WP_REST_Request( + array( + 'agent' => 'Support Agent', + 'message' => 'Hi there', + 'session_id' => 'existing-session', + 'attachments' => array( array( 'type' => 'image' ) ), + 'client_context' => array( 'client_name' => 'block-chat' ), + 'workspace_id' => 'site:42', + 'client_id' => 'browser-1', + ) +); + +$permission = AgentsAPI\AI\Channels\agents_frontend_chat_rest_permission( $request ); +agents_api_smoke_assert_equals( true, $permission, 'permission allows operator grant', $failures, $passes ); + +$response = AgentsAPI\AI\Channels\agents_frontend_chat_rest_dispatch( $request ); +agents_api_smoke_assert_equals( true, $response instanceof WP_REST_Response, 'dispatch returns REST response', $failures, $passes ); +agents_api_smoke_assert_equals( 'hello from adapter', $response->data['reply'] ?? null, 'dispatch returns ability reply', $failures, $passes ); +agents_api_smoke_assert_equals( 'support-agent', $captured['agent'] ?? null, 'dispatch sanitizes agent slug', $failures, $passes ); +agents_api_smoke_assert_equals( 'Hi there', $captured['message'] ?? null, 'dispatch forwards message', $failures, $passes ); +agents_api_smoke_assert_equals( 'rest', $captured['client_context']['source'] ?? null, 'dispatch marks REST source', $failures, $passes ); +agents_api_smoke_assert_equals( 'block-chat', $captured['client_context']['client_name'] ?? null, 'dispatch preserves client name', $failures, $passes ); + +$blocked = AgentsAPI\AI\Channels\agents_frontend_chat_rest_permission( + new WP_REST_Request( + array( + 'agent' => 'other-agent', + 'message' => 'Nope', + 'workspace_id' => 'site:42', + ) + ) +); +agents_api_smoke_assert_equals( true, $blocked instanceof WP_Error, 'permission blocks ungranted agent', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_frontend_chat_forbidden', $blocked->get_error_code(), 'permission returns forbidden code', $failures, $passes ); + +add_filter( 'agents_frontend_chat_rest_permission', static fn() => true ); +$filtered = AgentsAPI\AI\Channels\agents_frontend_chat_rest_permission( + new WP_REST_Request( array( 'agent' => 'other-agent', 'message' => 'Allowed by host' ) ) +); +agents_api_smoke_assert_equals( true, $filtered, 'permission is filterable for hosts', $failures, $passes ); + +$invalid = AgentsAPI\AI\Channels\agents_frontend_chat_rest_dispatch( new WP_REST_Request( array( 'agent' => '', 'message' => '' ) ) ); +agents_api_smoke_assert_equals( true, $invalid instanceof WP_Error, 'dispatch rejects empty input', $failures, $passes ); +agents_api_smoke_assert_equals( 'agents_frontend_chat_invalid_input', $invalid->get_error_code(), 'dispatch rejects empty input code', $failures, $passes ); + +agents_api_smoke_finish( 'frontend chat REST adapter', $failures, $passes ); From 17f20aa91b2ab3bad67cfc9c0c5b2c23e7519210 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 13 May 2026 07:50:29 -0400 Subject: [PATCH 2/2] Fix frontend chat REST lint --- src/Channels/register-frontend-chat-rest-route.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Channels/register-frontend-chat-rest-route.php b/src/Channels/register-frontend-chat-rest-route.php index f37ed9f..7a73106 100644 --- a/src/Channels/register-frontend-chat-rest-route.php +++ b/src/Channels/register-frontend-chat-rest-route.php @@ -69,7 +69,7 @@ function agents_frontend_chat_rest_permission( \WP_REST_Request $request ) { return $input; } - $agent = isset( $input['agent'] ) ? (string) $input['agent'] : ''; + $agent = isset( $input['agent'] ) ? (string) $input['agent'] : ''; $allowed = '' !== $agent && agents_chat_permission( $input ); if ( ! $allowed && '' !== $agent ) { @@ -173,7 +173,7 @@ function agents_frontend_chat_rest_input( \WP_REST_Request $request ) { * @return array */ function agents_frontend_chat_rest_scope( \WP_REST_Request $request ): array { - $scope = \AgentsAPI\AI\Auth\agents_access_request_scope( + $scope = \AgentsAPI\AI\Auth\agents_access_request_scope( array( 'workspace_id' => $request->get_param( 'workspace_id' ), 'client_id' => $request->get_param( 'client_id' ),