diff --git a/docs/experiments/suggest-reply.md b/docs/experiments/suggest-reply.md new file mode 100644 index 000000000..acb96a38d --- /dev/null +++ b/docs/experiments/suggest-reply.md @@ -0,0 +1,150 @@ +# Suggest Reply + +## Summary + +The Suggest Reply experiment adds an AI-powered "Suggest reply" action to the classic Comments admin screen and the Activity widget on the Dashboard. When activated, moderators can generate AI-suggested replies to comments, customize the tone, and provide specific guidelines for the reply. The experiment exposes one WordPress Ability (`ai/reply-suggestion`) that can be used from the UI or via REST API. + +## Overview + +When enabled, each comment in the Comments list table and the Dashboard Activity widget gets an additional **Suggest reply** action link. Clicking it opens a modal overlay allowing users to generate context-aware replies. + +**Key Features:** + +- Adds a "Suggest reply" action to comments in the list table and the Dashboard Activity widget +- Provides a modal interface to set the desired Tone (friendly, professional, casual) and optional editorial Guidelines +- Generates a single, relevant reply based on the comment text and parent post context +- Automatically populates the inline WordPress reply form when the generated reply is selected +- Uses one shared ability (`ai/reply-suggestion`) exposed via REST API + +### Input Schema + +The `ai/reply-suggestion` ability accepts: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => 'The ID of the comment to generate a reply for.', + 'required' => true, + ), + 'tone' => array( + 'type' => 'string', + 'enum' => array( 'professional', 'friendly', 'casual' ), + 'default' => 'friendly', + 'description' => 'The tone for the reply.', + ), + 'guidelines' => array( + 'type' => 'string', + 'default' => '', + 'description' => 'Optional free-text editorial guidelines to apply when writing the reply.', + ), + ), + 'required' => array( 'comment_id' ), +) +``` + +### Output Schema + +The ability returns: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => 'The comment ID.', + ), + 'reply' => array( + 'type' => 'string', + 'description' => 'The generated reply suggestion.', + ), + ), +) +``` + +### Permissions + +- `ai/reply-suggestion` requires `current_user_can( 'moderate_comments' )` + +## Using the Ability via REST API + +### Endpoint + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run +``` + +### Authentication + +You can authenticate using either: + +1. **Application Password** (Recommended) +2. **Cookie Authentication with Nonce** + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions. + +### Request Example + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "comment_id": 1, + "tone": "professional", + "guidelines": "Thank the user for their feedback." + } + }' +``` + +**Response:** + +```json +{ + "comment_id": 1, + "reply": "Thank you for your valuable feedback! We appreciate you taking the time to share your thoughts." +} +``` + +### Error Responses + +The ability may return: + +- `missing_comment_id`: `comment_id` was not provided +- `comment_not_found`: no comment exists for the given ID +- `insufficient_capabilities`: current user lacks moderation permissions + +## Testing + +### Manual Testing + +1. **Enable the experiment:** + - Go to `Settings -> AI` + - Enable global AI features and toggle **Suggest Reply** + - Ensure valid AI connector credentials are configured + +2. **Suggest reply modal:** + - Go to `Comments -> All Comments` + - Hover over an comment and click **Suggest reply** + - Select a Tone, enter Guidelines, and click **Generate** + - Verify that the AI generates a reply + - Click **Use this reply** and verify the inline comment reply textarea is populated with the text + +3. **REST API:** + - Call `POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run` with a valid `comment_id` + - Verify response shape and error handling for invalid IDs or insufficient permissions + +## Notes & Considerations + +### Requirements + +- Requires valid AI credentials and text-generation-capable models +- Requires users with comment moderation capabilities for ability access + +### Limitations + +- Works on the classic comments list table and the Dashboard Activity widget (no block-based comments UI integration here) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php new file mode 100644 index 000000000..f19e97371 --- /dev/null +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -0,0 +1,241 @@ + 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The ID of the comment to generate a reply for.', 'ai' ), + ), + 'tone' => array( + 'type' => 'string', + 'enum' => array( 'professional', 'friendly', 'casual' ), + 'default' => 'friendly', + 'description' => esc_html__( 'The tone for the reply.', 'ai' ), + ), + 'guidelines' => array( + 'type' => 'string', + 'default' => '', + 'description' => esc_html__( 'Optional free-text editorial guidelines to apply when writing the reply.', 'ai' ), + ), + ), + 'required' => array( 'comment_id' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The comment ID.', 'ai' ), + ), + 'reply' => array( + 'type' => 'string', + 'description' => esc_html__( 'The generated reply suggestion.', 'ai' ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @return array{comment_id: int, reply: string}|\WP_Error The result of the ability execution. + */ + protected function execute_callback( $input ) { + $input = wp_parse_args( + (array) $input, + array( + 'comment_id' => 0, + 'tone' => 'friendly', + 'guidelines' => '', + ) + ); + + $comment_id = absint( $input['comment_id'] ); + + if ( ! $comment_id ) { + return new WP_Error( + 'missing_comment_id', + esc_html__( 'A comment ID is required.', 'ai' ) + ); + } + + $comment = get_comment( $comment_id ); + + if ( ! $comment || ! is_a( $comment, '\WP_Comment' ) ) { + return new WP_Error( + 'comment_not_found', + sprintf( + /* translators: %d: Comment ID. */ + esc_html__( 'Comment with ID %d not found.', 'ai' ), + $comment_id + ) + ); + } + + // Fetch post context. + $post = get_post( (int) $comment->comment_post_ID ); + $post_title = $post instanceof \WP_Post ? $post->post_title : ''; + $post_excerpt = $post instanceof \WP_Post + ? wp_trim_words( wp_strip_all_tags( $post->post_content ), 50 ) + : ''; + + $tone = in_array( $input['tone'], array( 'professional', 'friendly', 'casual' ), true ) + ? $input['tone'] + : 'friendly'; + $guidelines = sanitize_textarea_field( (string) $input['guidelines'] ); + + // Build the prompt context. + $context = $this->build_context( $comment, $post_title, $post_excerpt, $tone, $guidelines ); + + // Generate the reply. + $reply = $this->generate_reply( $context ); + + if ( is_wp_error( $reply ) ) { + return $reply; + } + + return array( + 'comment_id' => $comment_id, + 'reply' => $reply, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $input ) { + if ( ! current_user_can( 'moderate_comments' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate reply suggestions.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Builds the prompt context string from the comment and post data. + * + * @since x.x.x + * + * @param \WP_Comment $comment The comment to reply to. + * @param string $post_title The title of the parent post. + * @param string $post_excerpt A short excerpt of the parent post content. + * @param string $tone The desired reply tone (e.g. 'friendly', 'professional'). + * @param string $guidelines Optional editorial guidelines to apply. + * @return string The assembled prompt context. + */ + private function build_context( + \WP_Comment $comment, + string $post_title, + string $post_excerpt, + string $tone, + string $guidelines = '' + ): string { + $parts = array(); + + if ( '' !== $post_title ) { + $parts[] = sprintf( 'Post Title: %s', $post_title ); + } + + if ( '' !== $post_excerpt ) { + $parts[] = sprintf( 'Post Context: %s', $post_excerpt ); + } + + $parts[] = sprintf( 'Comment Author: %s', $comment->comment_author ); + $parts[] = sprintf( 'Comment: """%s"""', $comment->comment_content ); + $parts[] = sprintf( 'Requested Tone: %s', $tone ); + + if ( '' !== $guidelines ) { + $parts[] = sprintf( 'Editorial Guidelines: %s', $guidelines ); + } + + return implode( "\n", $parts ); + } + + /** + * Generates a reply suggestion via the AI client. + * + * @since x.x.x + * + * @param string $context The assembled prompt context string. + * @return string|\WP_Error The sanitized reply text, or a WP_Error on failure. + */ + private function generate_reply( string $context ) { + $prompt_builder = wp_ai_client_prompt( $context ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ); + + $is_supported = $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Reply suggestion could not be generated. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + + if ( is_wp_error( $is_supported ) ) { + return $is_supported; + } + + $result = $prompt_builder->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return sanitize_textarea_field( trim( (string) $result ) ); + } +} diff --git a/includes/Abilities/Suggest_Reply/system-instruction.php b/includes/Abilities/Suggest_Reply/system-instruction.php new file mode 100644 index 000000000..89ae3e986 --- /dev/null +++ b/includes/Abilities/Suggest_Reply/system-instruction.php @@ -0,0 +1,29 @@ + __( 'Suggest Reply', 'ai' ), + 'description' => __( 'Adds a "Suggest Reply" action to the Comments screen and Activity widget, enabling moderators to generate and insert AI-powered reply suggestions.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + add_filter( 'comment_row_actions', array( $this, 'add_row_action' ), 10, 2 ); + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers the reply suggestion ability. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/reply-suggestion', + array( + 'label' => __( 'Reply Suggestion', 'ai' ), + 'description' => __( 'Generates AI-powered reply suggestions for a comment.', 'ai' ), + 'ability_class' => Reply_Suggestion_Ability::class, + ) + ); + } + + /** + * Adds a "Suggest reply" action link to the comment row actions. + * + * @since x.x.x + * + * @param mixed $actions The existing comment row actions. + * @param \WP_Comment $comment The comment object. + * @return array The modified actions array. + */ + public function add_row_action( $actions, $comment ): array { + if ( + ! is_array( $actions ) || + ! $comment || + ! is_a( $comment, '\WP_Comment' ) + ) { + return $actions; + } + + $actions['wpai_suggest_reply'] = sprintf( + '%s', + absint( $comment->comment_ID ), + esc_attr__( 'Suggest a reply for this comment', 'ai' ), + esc_html__( 'Suggest reply', 'ai' ) + ); + + return $actions; + } + + /** + * Enqueues assets for the Suggest Reply experiment. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + if ( ! in_array( $hook_suffix, array( 'edit-comments.php', 'index.php' ), true ) ) { + return; + } + + Asset_Loader::enqueue_script( + 'suggest_reply', + 'experiments/suggest-reply', + array( 'include_core_abilities' => true ) + ); + + Asset_Loader::localize_script( + 'suggest_reply', + 'SuggestReplyData', + array( + 'enabled' => $this->is_enabled(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } +} diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx new file mode 100644 index 000000000..1421c1089 --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -0,0 +1,223 @@ +/** + * Modal component for the Suggest Reply experiment. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + Modal, + Notice, + SelectControl, + Spinner, + TextareaControl, +} from '@wordpress/components'; +import { useState, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import { useCopyToClipboardFeedback } from '../../../hooks/use-copy-to-clipboard-feedback'; + +type Tone = 'professional' | 'friendly' | 'casual'; + +type ReplySuggestionResult = { + comment_id: number; + reply: string; +}; + +export type ReplyModalProps = { + commentId: number; + onClose: () => void; + onSelectReply: ( reply: string, commentId: number ) => void; +}; + +/** + * Renders the AI reply suggestion modal, allowing the moderator to choose + * a tone, provide optional guidelines, generate a reply, and insert it. + */ +export function ReplyModal( { + commentId, + onClose, + onSelectReply, +}: ReplyModalProps ): React.ReactElement { + const [ isLoading, setIsLoading ] = useState( false ); + const [ reply, setReply ] = useState< string >( '' ); + const [ tone, setTone ] = useState< Tone >( 'friendly' ); + const [ guidelines, setGuidelines ] = useState< string >( '' ); + const [ error, setError ] = useState< string | null >( null ); + + const { ref: copyRef, hasCopied } = + useCopyToClipboardFeedback< HTMLButtonElement >( { + text: reply, + announcement: __( 'Reply copied to clipboard.', 'ai' ), + } ); + + const generateReply = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const result = await runAbility< ReplySuggestionResult >( + 'ai/reply-suggestion', + { + comment_id: commentId, + tone, + guidelines, + } + ); + + setReply( result.reply ?? '' ); + } catch ( err: any ) { + setError( + Boolean( err.message ) + ? err.message + : __( 'Failed to generate reply suggestion.', 'ai' ) + ); + } finally { + setIsLoading( false ); + } + }, [ commentId, tone, guidelines ] ); + + const toneOptions = [ + { label: __( 'Friendly', 'ai' ), value: 'friendly' }, + { label: __( 'Professional', 'ai' ), value: 'professional' }, + { label: __( 'Casual', 'ai' ), value: 'casual' }, + ]; + + const getGenerateText = useCallback( () => { + if ( isLoading ) { + return __( 'Generating…', 'ai' ); + } + + if ( error ) { + return __( 'Retry', 'ai' ); + } + + if ( reply ) { + return __( 'Regenerate', 'ai' ); + } + + return __( 'Generate', 'ai' ); + }, [ error, reply, isLoading ] ); + + return ( + + + + setTone( value as Tone ) } + __next40pxDefaultSize + /> + + + { /* Guidelines */ } + + + + + { /* Loading spinner */ } + { isLoading && ( + + + + { __( 'Generating reply suggestion…', 'ai' ) } + + + ) } + + { /* Error notice */ } + { ! isLoading && error && ( + + + { error } + + + ) } + + { /* Generated reply */ } + { ! isLoading && ! error && reply && ( + +

+ { reply } +

+
+ ) } + + { /* Action buttons */ } + + + { reply && ! error && ( + <> + + + + + ) } + +
+
+ ); +} diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx new file mode 100644 index 000000000..43636cbeb --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -0,0 +1,159 @@ +/** + * Controller for the Suggest Reply modal. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModal } from './ReplyModal'; + +type ModalState = { + isOpen: boolean; + commentId: number | null; +}; + +/** + * Mounts a delegated click listener on the comment list and renders the + * reply modal when a "Suggest reply" action link is clicked. + */ +export function ReplyModalController(): React.ReactElement { + const [ modalState, setModalState ] = useState< ModalState >( { + isOpen: false, + commentId: null, + } ); + + const populateTimeoutRef = useRef< number | null >( null ); + + /** Writes the generated reply into the inline reply textarea and focuses it. */ + const populateReplyTextarea = ( reply: string ) => { + const textarea = document.querySelector< HTMLTextAreaElement >( + '#replycontainer #replycontent' + ); + + if ( ! textarea ) { + return; + } + + textarea.value = reply; + textarea.focus(); + textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); + }; + + /** Returns true when the WordPress inline reply form is already open for the given comment. */ + const isInlineReplyOpenForComment = ( commentId: number ): boolean => { + const replyRow = document.querySelector< HTMLElement >( '#replyrow' ); + const commentIdInput = document.querySelector< HTMLInputElement >( + '#replyrow #comment_ID' + ); + + if ( ! replyRow || ! commentIdInput ) { + return false; + } + + const isVisible = + replyRow.style.display !== 'none' && replyRow.offsetParent !== null; + const isForComment = parseInt( commentIdInput.value, 10 ) === commentId; + + return isVisible && isForComment; + }; + + const closeModal = () => + setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); + + /** + * Closes the modal and inserts the selected reply into the comment reply form. + * Opens the inline reply row first if it is not already visible. + */ + const handleSelectReply = ( reply: string, commentId: number ) => { + closeModal(); + + if ( isInlineReplyOpenForComment( commentId ) ) { + populateReplyTextarea( reply ); + return; + } + + const replyButton = document.querySelector< HTMLButtonElement >( + `#comment-${ commentId } .reply button` + ); + + if ( replyButton ) { + replyButton.click(); + } + + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + + populateTimeoutRef.current = window.setTimeout( () => { + populateReplyTextarea( reply ); + populateTimeoutRef.current = null; + }, 150 ); + }; + + useEffect( () => { + const handleClick = ( event: MouseEvent ) => { + const target = event.target as HTMLElement; + + if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { + return; + } + + event.preventDefault(); + const commentId = parseInt( + target.getAttribute( 'data-comment-id' ) ?? '0', + 10 + ); + + if ( commentId > 0 ) { + setModalState( { isOpen: true, commentId } ); + } + }; + + const commentList = document.querySelector( '#the-comment-list' ); + + if ( commentList ) { + commentList.addEventListener( + 'click', + handleClick as EventListener + ); + + return () => + commentList.removeEventListener( + 'click', + handleClick as EventListener + ); + } + + return undefined; + }, [] ); + + useEffect( () => { + return () => { + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + }; + }, [] ); + + return ( + <> + { modalState.isOpen && modalState.commentId !== null && ( + + ) } + + ); +} diff --git a/src/experiments/suggest-reply/index.tsx b/src/experiments/suggest-reply/index.tsx new file mode 100644 index 000000000..6516e7fe8 --- /dev/null +++ b/src/experiments/suggest-reply/index.tsx @@ -0,0 +1,39 @@ +/** + * Suggest reply experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModalController } from './components/ReplyModalController'; + +declare global { + interface Window { + aiSuggestReplyData?: { + enabled: boolean; + nonce: string; + }; + } +} + +function init(): void { + const data = window.aiSuggestReplyData; + + if ( ! data?.enabled ) { + return; + } + + const container = document.createElement( 'div' ); + container.id = 'wpai-suggest-reply-root'; + document.body.appendChild( container ); + + createRoot( container ).render( ); +} + +domReady( init ); diff --git a/tests/Integration/Includes/Abilities/Suggest_ReplyTest.php b/tests/Integration/Includes/Abilities/Suggest_ReplyTest.php new file mode 100644 index 000000000..22ed7e394 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Suggest_ReplyTest.php @@ -0,0 +1,405 @@ + 'Suggest Reply', + 'description' => 'Adds a "Suggest Reply" action to the Comments screen, enabling moderators to generate AI-powered reply suggestions.', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Reply_Suggestion Ability test case. + * + * @since x.x.x + */ +class Suggest_ReplyTest extends WP_UnitTestCase { + /** + * Reply_Suggestion ability instance. + * + * @var \WordPress\AI\Abilities\Suggest_Reply\Reply_Suggestion + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Suggest_Reply_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Suggest_Reply_Experiment(); + $this->ability = new Reply_Suggestion( + 'ai/reply-suggestion', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertSame( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'comment_id', $schema['properties'] ); + $this->assertSame( 'integer', $schema['properties']['comment_id']['type'] ); + $this->assertArrayHasKey( 'tone', $schema['properties'] ); + $this->assertSame( 'string', $schema['properties']['tone']['type'] ); + $this->assertSame( array( 'professional', 'friendly', 'casual' ), $schema['properties']['tone']['enum'] ); + $this->assertSame( 'friendly', $schema['properties']['tone']['default'] ); + $this->assertArrayHasKey( 'guidelines', $schema['properties'] ); + $this->assertSame( 'string', $schema['properties']['guidelines']['type'] ); + $this->assertContains( 'comment_id', $schema['required'] ); + } + + /** + * Test that output_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'comment_id', $schema['properties'] ); + $this->assertSame( 'integer', $schema['properties']['comment_id']['type'] ); + $this->assertArrayHasKey( 'reply', $schema['properties'] ); + $this->assertSame( 'string', $schema['properties']['reply']['type'] ); + } + + /** + * Test that execute_callback() returns error when comment_id is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_comment_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'missing_comment_id', $result->get_error_code() ); + } + + /** + * Test that execute_callback() returns error for invalid comment ID. + * + * @since x.x.x + */ + public function test_execute_callback_with_invalid_comment_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'comment_id' => 999999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'comment_not_found', $result->get_error_code() ); + } + + /** + * Test that permission_callback() allows users who can moderate comments. + * + * @since x.x.x + */ + public function test_permission_callback_allows_moderate_comments_capability() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'comment_id' => 1 ) ); + + $this->assertTrue( $result ); + } + + /** + * Test that permission_callback() denies users without moderate_comments capability. + * + * @since x.x.x + */ + public function test_permission_callback_denies_without_moderate_comments_capability() { + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'comment_id' => 1 ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Test that meta() returns expected shape. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta ); + $this->assertArrayHasKey( 'show_in_rest', $meta ); + $this->assertTrue( $meta['show_in_rest'] ); + } + + /** + * Test that get_system_instruction() returns a non-empty string with expected content. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_expected_content() { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction ); + $this->assertNotEmpty( $system_instruction ); + $this->assertStringContainsString( 'WordPress site moderator', $system_instruction ); + $this->assertStringContainsString( 'tone', $system_instruction ); + $this->assertStringContainsString( 'reply', $system_instruction ); + } + + /** + * Test that build_context() assembles the expected prompt parts. + * + * @since x.x.x + */ + public function test_build_context_includes_all_parts() { + $post_id = self::factory()->post->create( + array( + 'post_title' => 'Test Post Title', + 'post_content' => 'This is the test post content for the reply suggestion.', + ) + ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $post_id, + 'comment_author' => 'Jane Doe', + 'comment_content' => 'This is a great article!', + ) + ); + + $comment = get_comment( $comment_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_context' ); + $method->setAccessible( true ); + + $context = $method->invoke( + $this->ability, + $comment, + 'Test Post Title', + 'This is the test post content for the reply suggestion.', + 'professional', + 'Be concise.' + ); + + $this->assertIsString( $context ); + $this->assertStringContainsString( 'Post Title: Test Post Title', $context ); + $this->assertStringContainsString( 'Post Context:', $context ); + $this->assertStringContainsString( 'Comment Author: Jane Doe', $context ); + $this->assertStringContainsString( 'Comment: """This is a great article!"""', $context ); + $this->assertStringContainsString( 'Requested Tone: professional', $context ); + $this->assertStringContainsString( 'Editorial Guidelines: Be concise.', $context ); + } + + /** + * Test that build_context() omits post sections when post data is empty. + * + * @since x.x.x + */ + public function test_build_context_omits_empty_post_sections() { + $comment_id = self::factory()->comment->create( + array( + 'comment_author' => 'John Smith', + 'comment_content' => 'Nice work!', + ) + ); + + $comment = get_comment( $comment_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_context' ); + $method->setAccessible( true ); + + $context = $method->invoke( + $this->ability, + $comment, + '', + '', + 'casual' + ); + + $this->assertStringNotContainsString( 'Post Title:', $context ); + $this->assertStringNotContainsString( 'Post Context:', $context ); + $this->assertStringContainsString( 'Comment Author: John Smith', $context ); + $this->assertStringContainsString( 'Requested Tone: casual', $context ); + } + + /** + * Test that build_context() omits the guidelines line when no guidelines are provided. + * + * @since x.x.x + */ + public function test_build_context_omits_empty_guidelines() { + $comment_id = self::factory()->comment->create( + array( + 'comment_author' => 'Alice', + 'comment_content' => 'Great post!', + ) + ); + + $comment = get_comment( $comment_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_context' ); + $method->setAccessible( true ); + + $context = $method->invoke( + $this->ability, + $comment, + 'My Post', + 'Some excerpt.', + 'friendly', + '' + ); + + $this->assertStringNotContainsString( 'Editorial Guidelines:', $context ); + } + + /** + * Test that execute_callback() returns a WP_Error when no text-generation model is available. + * + * The generate_reply() path calls ensure_text_generation_supported(), which returns + * a WP_Error with code 'unsupported_model' when the prompt builder cannot find a + * model capable of text generation. + * + * @since x.x.x + */ + public function test_execute_callback_returns_error_when_no_text_generation_model_available() { + remove_filter( 'wpai_has_ai_credentials', '__return_true' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + delete_option( 'wp_ai_client_provider_credentials' ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_author' => 'Carol', + 'comment_content' => 'Interesting read.', + ) + ); + + $reflection = new \ReflectionClass( $this->ability ); + $exec_method = $reflection->getMethod( 'execute_callback' ); + $exec_method->setAccessible( true ); + + $result = $exec_method->invoke( + $this->ability, + array( + 'comment_id' => $comment_id, + 'tone' => 'friendly', + 'guidelines' => '', + ) + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'unsupported_model', $result->get_error_code() ); + } +} diff --git a/tests/Integration/Includes/Experiments/ExperimentsTest.php b/tests/Integration/Includes/Experiments/ExperimentsTest.php index 652b0677a..86ca73869 100644 --- a/tests/Integration/Includes/Experiments/ExperimentsTest.php +++ b/tests/Integration/Includes/Experiments/ExperimentsTest.php @@ -11,6 +11,7 @@ use WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer; use WordPress\AI\Experiments\Connector_Approval\Connector_Approval; use WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation; +use WordPress\AI\Experiments\Suggest_Reply\Suggest_Reply; use WordPress\AI\Experiments\Experiments; /** @@ -39,5 +40,6 @@ public function test_init_hooks_filter() { $this->assertContains( Abilities_Explorer::class, $results, 'Abilities_Explorer should be registered as a default experiment.' ); $this->assertContains( Connector_Approval::class, $results, 'Connector_Approval should be registered as a default experiment.' ); $this->assertContains( Comment_Moderation::class, $results, 'Comment_Moderation should be registered as a default experiment.' ); + $this->assertContains( Suggest_Reply::class, $results, 'Suggest_Reply should be registered as a default experiment.' ); } } diff --git a/tests/Integration/Includes/Experiments/Suggest_Reply/Suggest_ReplyTest.php b/tests/Integration/Includes/Experiments/Suggest_Reply/Suggest_ReplyTest.php new file mode 100644 index 000000000..c6df16b33 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Suggest_Reply/Suggest_ReplyTest.php @@ -0,0 +1,175 @@ + 'test-api-key' ) ); + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + add_filter( 'wpai_has_ai_credentials', '__return_true' ); + + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_suggest-reply_enabled', true ); + + $this->admin_user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $this->admin_user_id ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->init(); + + $experiment = $registry->get_feature( 'suggest-reply' ); + $this->assertInstanceOf( + Suggest_Reply::class, + $experiment, + 'Suggest reply experiment should be registered in the registry.' + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_suggest-reply_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + remove_filter( 'wpai_has_ai_credentials', '__return_true' ); + parent::tearDown(); + } + + /** + * Test that the experiment metadata is correct. + * + * @since x.x.x + */ + public function test_experiment_registration() { + $experiment = new Suggest_Reply(); + + $this->assertSame( 'suggest-reply', $experiment->get_id() ); + $this->assertSame( 'Suggest Reply', $experiment->get_label() ); + $this->assertSame( Experiment_Category::ADMIN, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Test that register() adds expected hooks. + * + * @since x.x.x + */ + public function test_register_adds_expected_hooks() { + $experiment = new Suggest_Reply(); + $experiment->register(); + + $this->assertIsInt( has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ) ); + $this->assertIsInt( has_filter( 'comment_row_actions', array( $experiment, 'add_row_action' ) ) ); + $this->assertIsInt( has_action( 'admin_enqueue_scripts', array( $experiment, 'enqueue_assets' ) ) ); + } + + /** + * Test that register_abilities() registers the reply suggestion ability. + * + * @since x.x.x + */ + public function test_register_abilities_registers_reply_suggestion() { + $experiment = new Suggest_Reply(); + $experiment->register(); + + $this->assertIsInt( has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ) ); + } + + /** + * Test add_row_action() adds a suggest reply action link to a valid comment. + * + * @since x.x.x + */ + public function test_add_row_action_adds_suggest_reply_link() { + $comment_id = self::factory()->comment->create(); + $comment = get_comment( $comment_id ); + $experiment = new Suggest_Reply(); + + $actions = $experiment->add_row_action( array(), $comment ); + + $this->assertArrayHasKey( 'wpai_suggest_reply', $actions ); + $this->assertStringContainsString( 'wpai-suggest-reply', $actions['wpai_suggest_reply'] ); + $this->assertStringContainsString( 'data-comment-id="' . $comment_id . '"', $actions['wpai_suggest_reply'] ); + $this->assertStringContainsString( 'Suggest reply', $actions['wpai_suggest_reply'] ); + } + + /** + * Test add_row_action() preserves existing actions. + * + * @since x.x.x + */ + public function test_add_row_action_preserves_existing_actions() { + $comment_id = self::factory()->comment->create(); + $comment = get_comment( $comment_id ); + $experiment = new Suggest_Reply(); + + $existing_actions = array( 'edit' => 'Edit', 'reply' => 'Reply' ); + $actions = $experiment->add_row_action( $existing_actions, $comment ); + + $this->assertArrayHasKey( 'edit', $actions ); + $this->assertArrayHasKey( 'reply', $actions ); + $this->assertArrayHasKey( 'wpai_suggest_reply', $actions ); + } + + /** + * Test add_row_action() returns early when $comment is not a WP_Comment. + * + * @since x.x.x + */ + public function test_add_row_action_returns_early_for_non_comment_object() { + $experiment = new Suggest_Reply(); + + $result = $experiment->add_row_action( array( 'edit' => 'Edit' ), null ); + + $this->assertSame( array( 'edit' => 'Edit' ), $result ); + } + + /** + * Test enqueue_assets() returns early for unrelated admin screens. + * + * @since x.x.x + */ + public function test_enqueue_assets_returns_early_for_non_target_screens() { + $experiment = new Suggest_Reply(); + $experiment->enqueue_assets( 'options-general.php' ); + + $this->assertFalse( wp_script_is( 'suggest_reply', 'enqueued' ) ); + } +} diff --git a/tests/e2e/specs/experiments/suggest-reply.spec.js b/tests/e2e/specs/experiments/suggest-reply.spec.js new file mode 100644 index 000000000..4c311375c --- /dev/null +++ b/tests/e2e/specs/experiments/suggest-reply.spec.js @@ -0,0 +1,174 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + disableExperiment, + disableExperiments, + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +const EXPERIMENT_LABEL = 'Suggest Reply'; + +test.describe( 'Suggest Reply Experiment', () => { + test.beforeEach( async ( { requestUtils } ) => { + await requestUtils.deleteAllComments(); + } ); + + test( 'Can enable the suggest reply experiment', async ( { + admin, + page, + } ) => { + await enableExperiments( admin, page ); + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + } ); + + test( 'Can use the Suggest Reply Experiment on the Comments admin page', async ( { + admin, + page, + requestUtils, + } ) => { + await enableExperiments( admin, page ); + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + const post = await requestUtils.createPost( { + title: 'Test Suggest Reply Experiment', + status: 'publish', + } ); + + await requestUtils.createComment( { + content: 'This is a test comment for suggest reply.', + post: post.id, + } ); + + await admin.visitAdminPage( 'edit-comments.php' ); + + // Hover to reveal the hidden WordPress row actions, then click. + await page.locator( '#the-comment-list tr:first-child' ).hover(); + await page + .locator( '#the-comment-list tr:first-child a.wpai-suggest-reply' ) + .click(); + + // Verify modal UI elements. + await expect( page.getByText( 'Guidelines (optional)' ) ).toBeVisible(); + await expect( page.getByText( 'Tone' ) ).toBeVisible(); + await expect( + page.getByRole( 'button', { name: 'Generate' } ) + ).toBeVisible(); + + await page.getByRole( 'button', { name: 'Generate' } ).click(); + + // Wait for the AI suggestion and action buttons to appear. + await expect( + page.getByRole( 'button', { name: 'Use this reply' } ) + ).toBeVisible( { timeout: 30000 } ); + await expect( + page.getByRole( 'button', { name: 'Copy' } ) + ).toBeVisible(); + + await page.getByRole( 'button', { name: 'Use this reply' } ).click(); + } ); + + test( 'Can use the Suggest Reply Experiment on the Activity dashboard widget', async ( { + admin, + page, + requestUtils, + } ) => { + await enableExperiments( admin, page ); + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + const post = await requestUtils.createPost( { + title: 'Test Suggest Reply Dashboard Widget', + status: 'publish', + } ); + + await requestUtils.createComment( { + content: 'This is a test comment for the dashboard widget.', + post: post.id, + } ); + + await admin.visitAdminPage( 'index.php' ); + + // Hover to reveal the hidden row actions in the Activity widget. + await page + .locator( '#activity-widget' ) + .locator( 'li' ) + .first() + .hover(); + + const suggestReplyLink = page + .locator( '#activity-widget a.wpai-suggest-reply' ) + .first(); + await expect( suggestReplyLink ).toBeVisible(); + await suggestReplyLink.click(); + + // Verify modal UI elements. + await expect( page.getByText( 'Guidelines (optional)' ) ).toBeVisible(); + await expect( page.getByText( 'Tone' ) ).toBeVisible(); + await expect( + page.getByRole( 'button', { name: 'Generate' } ) + ).toBeVisible(); + + await page.getByRole( 'button', { name: 'Close' } ).click(); + await expect( + page.getByRole( 'button', { name: 'Generate' } ) + ).not.toBeVisible(); + } ); + + test( 'Ensure the Suggest Reply Experiment UI is not visible when Experiments are globally disabled', async ( { + admin, + page, + requestUtils, + } ) => { + await enableExperiments( admin, page ); + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + const post = await requestUtils.createPost( { + title: 'Test Suggest Reply Disabled Globally', + status: 'publish', + } ); + + await requestUtils.createComment( { + content: 'This is a test comment.', + post: post.id, + } ); + + await disableExperiments( admin, page ); + + await admin.visitAdminPage( 'edit-comments.php' ); + await expect( page.locator( 'a.wpai-suggest-reply' ) ).toHaveCount( 0 ); + + await admin.visitAdminPage( 'index.php' ); + await expect( page.locator( 'a.wpai-suggest-reply' ) ).toHaveCount( 0 ); + } ); + + test( 'Ensure the Suggest Reply Experiment UI is not visible when the experiment is disabled', async ( { + admin, + page, + requestUtils, + } ) => { + await enableExperiments( admin, page ); + await disableExperiment( admin, page, EXPERIMENT_LABEL ); + + const post = await requestUtils.createPost( { + title: 'Test Suggest Reply Experiment Disabled', + status: 'publish', + } ); + + await requestUtils.createComment( { + content: 'This is a test comment.', + post: post.id, + } ); + + await admin.visitAdminPage( 'edit-comments.php' ); + await expect( page.locator( 'a.wpai-suggest-reply' ) ).toHaveCount( 0 ); + + await admin.visitAdminPage( 'index.php' ); + await expect( page.locator( 'a.wpai-suggest-reply' ) ).toHaveCount( 0 ); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 454133270..a31b1b8ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,6 +100,11 @@ module.exports = { 'src/experiments/comment-moderation', 'index.tsx' ), + 'experiments/suggest-reply': path.resolve( + process.cwd(), + 'src/experiments/suggest-reply', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation',