From f4132b0f7fb60e8d687474adbd9fb2bebcef8e75 Mon Sep 17 00:00:00 2001 From: Shadi Sharaf Date: Fri, 21 Aug 2026 14:43:21 +0300 Subject: [PATCH 1/2] Add AI Client connector for WP 7.0 AI usage auditing (XWPENG-20) Register a bundled Stream connector that listens to wp_ai_client_before/ after_generate_result and writes activity records with provider, model, token counts (input/output/thought), duration, and extended metadata. - Single opt-in toggle (log_prompt_and_response_text) for full prompt/ response capture, disabled by default with PII warning - wp_stream_ai_client_log_prompt / log_response filters for redaction - List table first-line summary preview for multiline entries - PHPUnit coverage for logging, toggles, filters, and graceful no-op - Docs, changelog, connectors.md regen; gitignore .ai/ local tooling --- changelog.md | 10 + classes/class-connectors.php | 2 + classes/class-list-table.php | 23 +- connectors.md | 27 +- connectors/class-connector-ai-client.php | 863 +++++++++++ readme.md | 2 + readme.txt | 27 + .../class-wp-ai-client-event-dispatcher.php | 8 + .../test-class-connector-ai-client.php | 1265 +++++++++++++++++ tests/phpunit/test-class-list-table.php | 66 + 10 files changed, 2289 insertions(+), 4 deletions(-) create mode 100644 connectors/class-connector-ai-client.php create mode 100644 tests/phpunit/connectors/stubs/class-wp-ai-client-event-dispatcher.php create mode 100644 tests/phpunit/connectors/test-class-connector-ai-client.php create mode 100644 tests/phpunit/test-class-list-table.php diff --git a/changelog.md b/changelog.md index cfcdc555b..b549735f8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # Stream Changelog +## [Unreleased] + +### New Features + +- Add AI Client connector for WordPress 7.0+: logs every AI generation call (operation, provider, model, token counts, duration, finish reason, and extended metadata) to Stream when `WP_AI_Client_Event_Dispatcher` is available (XWPENG-20). + +### Enhancements + +- Show only the first line of multiline summaries in the Stream list table so long entries stay readable in the activity feed. + ## 4.3.0 - July 18, 2026 ### Enhancements diff --git a/classes/class-connectors.php b/classes/class-connectors.php index ffeaa75dc..db55d1603 100644 --- a/classes/class-connectors.php +++ b/classes/class-connectors.php @@ -71,6 +71,7 @@ public function load_connectors() { /** * Core Connectors */ + 'ai-client', 'blogs', 'comments', 'editor', @@ -325,6 +326,7 @@ protected function build_full_connector_classes() { // Same canonical slug list load_connectors() uses. Keep in sync. $connectors = array( // Core Connectors. + 'ai-client', 'blogs', 'comments', 'editor', diff --git a/classes/class-list-table.php b/classes/class-list-table.php index 0c2ed5770..dcc89128c 100644 --- a/classes/class-list-table.php +++ b/classes/class-list-table.php @@ -317,7 +317,7 @@ public function column_default( $item, $column_name ) { break; case 'summary': - $out = $record->summary; + $out = self::format_summary_preview_html( (string) $record->summary ); $object_title = $record->get_object_title(); /* translators: %s: the title of any object, like a Post (e.g. "Hello World") */ $view_all_text = $object_title ? sprintf( esc_html__( 'View all activity for "%s"', 'stream' ), esc_attr( $object_title ) ) : esc_html__( 'View all activity for this object', 'stream' ); @@ -430,6 +430,27 @@ public function column_default( $item, $column_name ) { echo wp_kses( $out, $allowed_tags ); } + /** + * Formats a summary for the admin list table: first line only. + * + * @param string $summary Full summary stored in the database. + * @return string Summary preview text. + */ + public static function format_summary_preview_html( $summary ) { + if ( '' === $summary ) { + return ''; + } + + $lines = preg_split( '/\R/u', $summary, 2 ); + $first = isset( $lines[0] ) ? $lines[0] : $summary; + + if ( ! isset( $lines[1] ) || '' === $lines[1] ) { + return $summary; + } + + return $first; + } + /** * Returns the actions links for the provided record. (Eg. Edit, View) * diff --git a/connectors.md b/connectors.md index 989d38642..53825e1a6 100644 --- a/connectors.md +++ b/connectors.md @@ -40,6 +40,28 @@ +## Connector: WP_Stream\Connector_AI_Client + +### Actions + + - wp_ai_client_before_generate_result + - wp_ai_client_after_generate_result + +### Class register() + +
+This is the register method for the Connector. Occasionally there are additional actions in here. + +```php + public function register() { + parent::register(); + add_filter( 'wp_stream_settings_option_fields', array( $this, 'add_settings_fields' ) ); + add_filter( 'wp_stream_record_array', array( $this, 'filter_wp_stream_record_array' ), 10, 1 ); + } +``` +
+ + ## Connector: WP_Stream\Connector_BbPress ### Actions @@ -289,9 +311,8 @@ ```php public function register() { parent::register(); - add_action( 'load-theme-editor.php', array( $this, 'get_edition_data' ) ); - add_action( 'load-plugin-editor.php', array( $this, 'get_edition_data' ) ); - add_filter( 'wp_redirect', array( $this, 'log_changes' ) ); + + add_action( 'wp_ajax_edit-theme-plugin-file', array( $this, 'get_edition_data' ), 1 ); } ``` diff --git a/connectors/class-connector-ai-client.php b/connectors/class-connector-ai-client.php new file mode 100644 index 000000000..29774b4d2 --- /dev/null +++ b/connectors/class-connector-ai-client.php @@ -0,0 +1,863 @@ +() method + * by replacing non-alphanumeric characters with underscores. + * + * @var string[] + */ + public $actions = array( + 'wp_ai_client_before_generate_result', + 'wp_ai_client_after_generate_result', + ); + + /** + * In-flight generations keyed by the model object itself. + * + * WP AI Client fires before/after hooks with the *same* model instance and does + * not expose a request ID. SplObjectStorage uses object identity (O(1)), keeps + * the model alive so PHP cannot recycle spl_object_id, and avoids pairing a + * later generation with a stale pending row when the after-hook never fires. + * + * @var \SplObjectStorage|null + */ + private $pending; + + /** + * Registers action hooks and adds this connector's settings fields. + * + * @return void + */ + public function register() { + parent::register(); + add_filter( 'wp_stream_settings_option_fields', array( $this, 'add_settings_fields' ) ); + add_filter( 'wp_stream_record_array', array( $this, 'filter_wp_stream_record_array' ), 10, 1 ); + } + + /** + * Returns the connector's human-readable label. + * + * @return string + */ + public function get_label() { + return esc_html__( 'AI Client', 'stream' ); + } + + /** + * Returns translated context labels used to categorise log entries. + * + * @return array + */ + public function get_context_labels() { + return array( + 'prompts' => esc_html__( 'Prompts', 'stream' ), + ); + } + + /** + * Returns translated action labels used to describe what happened. + * + * @return array + */ + public function get_action_labels() { + return array( + 'generated' => esc_html__( 'Generated', 'stream' ), + ); + } + + /** + * True when something dispatches AI Client events onto WordPress actions. + * + * WP_AI_Client_Event_Dispatcher is the core adapter that calls + * do_action( 'wp_ai_client_{event}' ). The SDK class WordPress\AiClient\AiClient + * can exist without that wiring (e.g. a Composer copy of php-ai-client on + * WP 6.x). Core loads the adapter in wp-settings.php before Stream's + * init priority 9, so this check is valid at gate time on WP 7.0+. + * + * @return bool + */ + public function is_dependency_satisfied() { + // The SDK class alone does not fire WordPress actions. The adapter + // WP_AI_Client_Event_Dispatcher::dispatch() is what calls + // do_action( 'wp_ai_client_{event}' ). Core wires it in wp-settings.php + // before init, so this is true at connector load time on WP 7.0+. + return class_exists( 'WP_AI_Client_Event_Dispatcher' ); + } + + /** + * Injects the AI Client settings section into Stream's settings fields array. + * + * Adds opt-in checkbox — log_prompt_and_response_text — under + * a dedicated "AI Client" section in Stream → Settings. Both default to off. + * + * @param array>}> $fields Stream settings fields. + * @return array>}> + */ + public function add_settings_fields( array $fields ) { + $pii_warning = sprintf( + '%s %s', + esc_html__( 'Privacy Warning:', 'stream' ), + esc_html__( 'This content may include personally identifiable information (PII). Ensure your privacy policy covers AI data collection before enabling.', 'stream' ) + ); + + $fields[ $this->name ] = array( + 'title' => esc_html__( 'AI Client', 'stream' ), + 'fields' => array( + array( + 'name' => self::LOG_PROMPT_AND_RESPONSE_TEXT_OPTION_NAME, + 'title' => esc_html__( 'Log Prompt and Response text', 'stream' ), + 'type' => 'checkbox', + 'desc' => $pii_warning, + 'after_field' => esc_html__( 'Enabled', 'stream' ), + 'default' => 0, + ), + ), + ); + + return $fields; + } + + /** + * Returns true when the prompt and response text logging option is enabled in Stream's settings. + * + * @return bool + */ + protected function is_prompt_and_response_logging_enabled() { + $plugin = wp_stream_get_instance(); + if ( ! isset( $plugin->settings ) || ! ( $plugin->settings instanceof Settings ) ) { + return false; + } + + return ! empty( + $plugin->settings->get_setting_value( $this->name . '_' . self::LOG_PROMPT_AND_RESPONSE_TEXT_OPTION_NAME, false ) + ); + } + + /** + * Removes unnecessary meta data from the record array. + * + * @param array $record Record about to be inserted. + * @return array + */ + public function filter_wp_stream_record_array( array $record ) { + if ( ( isset( $record['connector'] ) ? $record['connector'] : '' ) !== $this->name || ! is_array( isset( $record['meta'] ) ? $record['meta'] : null ) ) { + return $record; + } + + // Remove the prompt and response text from the meta array if enabled, they're too long to be stored there (meta value is VARCHAR(200)). + if ( $this->is_prompt_and_response_logging_enabled() ) { + unset( $record['meta']['prompt_text'] ); + unset( $record['meta']['response_text'] ); + } + + return $record; + } + + // ------------------------------------------------------------------------- + // AI Client hooks + // ------------------------------------------------------------------------- + + /** + * Captures prompt context before the AI HTTP call is made. + * + * Stores provider, model, operation, optional prompt text, and a high-resolution + * start timestamp in the pending array, keyed by the model object's identity. + * The matching after-hook reads and clears this entry. + * + * @action wp_ai_client_before_generate_result + * + * @param object $event BeforeGenerateResultEvent instance (WordPress\AiClient\Events). + * @return void + */ + public function callback_wp_ai_client_before_generate_result( $event ) { + try { + $model = $event->getModel(); + if ( ! is_object( $model ) ) { + return; + } + + $provider = (string) $model->providerMetadata()->getId(); + $model_id = (string) $model->metadata()->getId(); + $operation = $this->normalize_capability( $event->getCapability() ); + + $prompt_text = ''; + // If prompt logged is enabled, extract the prompt text from the event. + if ( $this->is_prompt_and_response_logging_enabled() ) { + $prompt_text = $this->merge_model_system_instruction_into_prompt( + $this->extract_prompt_text( $event->getMessages() ), + $this->extract_model_system_instruction( $model ) + ); + + /** + * Filters the AI prompt text before Stream stores it. + * + * Return an empty string to omit the text. + * + * @param string $prompt_text The prompt text about to be logged. + * @param object $event The BeforeGenerateResultEvent instance. + */ + $prompt_text = (string) apply_filters( 'wp_stream_ai_client_log_prompt', $prompt_text, $event ); + } + + // Store the pending data in the pending array, so we can match the after-hook. + $pending = $this->get_pending_storage(); + $this->evict_oldest_pending_if_full( $pending ); + $pending[ $model ] = array( + 'provider' => $provider, + 'model' => $model_id, + 'operation' => $operation, + 'prompt_text' => $prompt_text, + 'start' => microtime( true ), + ); + } catch ( \Throwable $e ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Stream AI Connector: before_generate failed — ' . $e->getMessage() ); + } + } + } + + /** + * Finalises the Stream log entry after the AI HTTP call completes. + * + * Reads and clears the pending entry created by the before-hook, enriches it + * with response metadata (tokens, duration, finish reason, optional response + * text, extended metadata), and writes the Stream activity record. + * + * Returns without logging when no matching before-hook entry is found (e.g. + * when the connector was registered after the before-hook already fired). + * + * @action wp_ai_client_after_generate_result + * + * @param object $event AfterGenerateResultEvent instance (WordPress\AiClient\Events). + * @return void + */ + public function callback_wp_ai_client_after_generate_result( $event ) { + try { + $model = $event->getModel(); + $storage = $this->get_pending_storage(); + + if ( ! is_object( $model ) || ! isset( $storage[ $model ] ) ) { + return; + } + + $pending = $storage[ $model ]; + unset( $storage[ $model ] ); + + $result = $event->getResult(); + $token_usage = $result->getTokenUsage(); + $duration_ms = (int) round( ( microtime( true ) - $pending['start'] ) * 1000 ); + + $response_text = ''; + if ( $this->is_prompt_and_response_logging_enabled() ) { + try { + $response_text = (string) $result->toText(); + + /** + * Filters the AI response text before Stream stores it. + * + * Return an empty string to omit the text. + * + * @param string $response_text The response text about to be logged. + * @param object $event The AfterGenerateResultEvent instance. + */ + $response_text = (string) apply_filters( 'wp_stream_ai_client_log_response', $response_text, $event ); + } catch ( \Throwable $e ) { + unset( $e ); + } + } + + $input_tokens = (int) $token_usage->getPromptTokens(); + $output_tokens = (int) $token_usage->getCompletionTokens(); + $thought_tokens = (int) $token_usage->getThoughtTokens(); + $finish_reason = $this->extract_finish_reason( $result ); + $response_model = (string) $result->getModelMetadata()->getId(); + + $log_args = array( + 'operation' => $pending['operation'], + 'provider' => $pending['provider'], + 'model' => $response_model ? $response_model : $pending['model'], + 'input_tokens' => $input_tokens, + 'output_tokens' => $output_tokens, + 'thought_tokens' => $thought_tokens, + 'duration_ms' => $duration_ms, + 'prompt_text' => $pending['prompt_text'], + 'response_text' => $response_text, + 'finish_reason' => $finish_reason, + ); + + $log_args = $this->append_result_context_to_log_args( $log_args, $event, $model, $result ); + /* translators: 1: AI operation (e.g. "chat"), 2: provider slug, 3: model ID */ + $message = esc_html__( '%1$s via %2$s/%3$s (tokens: %4$d/%6$d/%5$d) in %7$dms', 'stream' ); + + // Add the prompt and response text to the message if enabled. + if ( $this->is_prompt_and_response_logging_enabled() ) { + $message .= sprintf( "\n\n[%s]\n%s\n\n[%s]\n%s", esc_html__( 'Prompt', 'stream' ), '%8$s', esc_html__( 'Response', 'stream' ), '%9$s' ); + } + + $this->log( + $message, + $log_args, + null, + 'prompts', + 'generated' + ); + } catch ( \Throwable $e ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Stream AI Connector: after_generate failed — ' . $e->getMessage() ); + } + } + } + + // ------------------------------------------------------------------------- + // Prompt extraction + // ------------------------------------------------------------------------- + + /** + * Builds logged prompt text from the full message list in order. + * + * Each message with a recognised role becomes a labeled block. A single + * user-only message is returned as plain text (no heading) for readability; + * two or more messages get section headings (e.g. "[User]"). + * + * Primary path: WordPress AI Client (WP 7.0+) provides MessageRoleEnum with + * ->value = 'user' or 'model'. Third-party callers may pass string roles or + * other enum-like objects — narrow fallbacks are kept for those. + * + * @param object[] $messages Array of Message DTOs from BeforeGenerateResultEvent. + * @return string + */ + private function extract_prompt_text( array $messages ) { + $sections = array(); + + foreach ( $messages as $message_index => $message ) { + if ( ! is_object( $message ) ) { + continue; + } + + $key = $this->prompt_section_key_for_message( $message ); + if ( null === $key ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Stream AI Connector: skipping message with unknown role (index ' . $message_index . ')' ); + } + continue; + } + + $body = $this->extract_all_text_from_message( $message ); + if ( '' === $body ) { + continue; + } + + $sections[] = array( + 'key' => $key, + 'body' => $body, + ); + } + + if ( array() === $sections ) { + return ''; + } + + // Single user message — plain text, no heading. + if ( 1 === count( $sections ) && 'user' === $sections[0]['key'] ) { + return $sections[0]['body']; + } + + $label_map = array( + 'user' => __( 'User', 'stream' ), + 'assistant' => __( 'Assistant', 'stream' ), + 'system' => __( 'System', 'stream' ), + 'developer' => __( 'Developer', 'stream' ), + ); + + $blocks = array(); + foreach ( $sections as $section ) { + $label = isset( $label_map[ $section['key'] ] ) ? $label_map[ $section['key'] ] : ''; + if ( '' === $label ) { + continue; + } + $blocks[] = '--- ' . $label . " ---\n" . $section['body']; + } + + return implode( "\n\n", $blocks ); + } + + /** + * Returns the canonical role key for a message, or null if unrecognised. + * + * @param object $message Message DTO. + * @return string|null 'user', 'assistant', 'system', 'developer', or null. + */ + private function prompt_section_key_for_message( $message ) { + try { + $role = $message->getRole(); + } catch ( \Throwable $e ) { + unset( $e ); + return null; + } + + $scalar = $this->enum_like_to_string( $role ); + if ( '' !== $scalar ) { + $key = $this->prompt_section_key_for_scalar_role( $scalar ); + if ( null !== $key ) { + return $key; + } + } + + if ( ! is_object( $role ) ) { + return null; + } + + try { + if ( method_exists( $role, 'isUser' ) && $role->isUser() ) { + return 'user'; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + try { + if ( method_exists( $role, 'isSystem' ) && $role->isSystem() ) { + return 'system'; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + try { + if ( method_exists( $role, 'isDeveloper' ) && $role->isDeveloper() ) { + return 'developer'; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + try { + if ( method_exists( $role, 'isModel' ) && $role->isModel() ) { + return 'assistant'; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return null; + } + + /** + * Maps a role string or enum value/name to a canonical section key. + * + * @param string $role Raw role value. + * @return string|null 'user', 'assistant', 'system', 'developer', or null. + */ + private function prompt_section_key_for_scalar_role( $role ) { + $role = strtolower( trim( $role ) ); + if ( 'user' === $role || 'human' === $role || 'input' === $role ) { + return 'user'; + } + if ( 'model' === $role || 'assistant' === $role ) { + return 'assistant'; + } + if ( 'system' === $role ) { + return 'system'; + } + if ( 'developer' === $role ) { + return 'developer'; + } + return null; + } + + /** + * Concatenates non-empty text from every text part of a message. + * + * Calls MessagePart::getText() (returns string|null in WP AI Client core). + * Parts of other types (file, function call/response) are skipped. + * Multiple parts are joined with newlines. + * + * @param object $message Message DTO. + * @return string + */ + private function extract_all_text_from_message( $message ) { + try { + $parts = $message->getParts(); + } catch ( \Throwable $e ) { + unset( $e ); + return ''; + } + + $chunks = array(); + foreach ( $parts as $part ) { + if ( ! is_object( $part ) ) { + continue; + } + $text = $this->extract_text_from_content_part( $part ); + if ( '' !== $text ) { + $chunks[] = $text; + } + } + + return implode( "\n", $chunks ); + } + + /** + * Extracts text from a single message part. + * + * Calls getText() (primary API in WP AI Client). Returns empty string for + * non-text part types (file, function call/response) or when getText() is + * null. + * + * @param object $part Message part DTO. + * @return string + */ + private function extract_text_from_content_part( $part ) { + try { + if ( method_exists( $part, 'getText' ) ) { + $text = $part->getText(); + if ( is_string( $text ) && '' !== $text ) { + return $text; + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return ''; + } + + /** + * Reads a model-level system instruction (separate from the message list). + * + * Tries model->getSystemInstruction() first, then model->getConfig()->getSystemInstruction(). + * Returns empty string when neither is available. + * + * @param object $model ModelInterface instance. + * @return string + */ + private function extract_model_system_instruction( $model ) { + try { + if ( method_exists( $model, 'getSystemInstruction' ) ) { + $instr = $model->getSystemInstruction(); + if ( is_string( $instr ) && '' !== $instr ) { + return $instr; + } + } + + if ( method_exists( $model, 'getConfig' ) ) { + $config = $model->getConfig(); + if ( is_object( $config ) && method_exists( $config, 'getSystemInstruction' ) ) { + $instr = $config->getSystemInstruction(); + if ( is_string( $instr ) && '' !== $instr ) { + return $instr; + } + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return ''; + } + + /** + * Prepends a model-level system instruction before message-derived prompt text. + * + * When a system instruction is present and the existing prompt is plain user + * text (no section headings), the user text is wrapped in a User heading so + * both blocks are visually distinct in Stream's UI. + * + * @param string $message_prompt Text assembled from getMessages(). + * @param string $model_instruction Text from model/config getSystemInstruction(). + * @return string + */ + private function merge_model_system_instruction_into_prompt( $message_prompt, $model_instruction ) { + if ( '' === $model_instruction ) { + return $message_prompt; + } + + $system_block = "\n[System]\n" . $model_instruction; + + if ( '' === $message_prompt ) { + return $system_block; + } + + // Already has section headings — prepend system block directly. + if ( false !== strpos( $message_prompt, '--- ' ) ) { + return $system_block . "\n\n" . $message_prompt; + } + + // Plain user text — add a User heading for visual consistency. + return $system_block . "\n[User]\n" . $message_prompt; + } + + // ------------------------------------------------------------------------- + // Result metadata helpers + // ------------------------------------------------------------------------- + + /** + * Extracts the finish reason string from the first result candidate. + * + * FinishReasonEnum (WP AI Client) extends AbstractEnum; casting to string + * returns the enum value (e.g. 'stop'). + * + * @param object $result GenerativeAiResult instance. + * @return string + */ + private function extract_finish_reason( $result ) { + try { + $candidates = $result->getCandidates(); + if ( ! empty( $candidates ) ) { + $finish_reason = $candidates[0]->getFinishReason(); + // AbstractEnum and BackedEnum both cast cleanly to string. + return (string) $finish_reason; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + return ''; + } + + /** + * Maps a WP AI Client capability to a Stream operation label. + * + * Supports AbstractEnum (WP AI Client), BackedEnum, UnitEnum, and objects + * with a ->value property. Unknown values pass through as the operation label; + * null or unresolvable capabilities fall back to 'chat'. + * + * @param object|null $capability CapabilityEnum instance or null. + * @return string + */ + private function normalize_capability( $capability ) { + if ( null === $capability ) { + return 'unknown'; + } + + $raw = strtolower( $this->enum_like_to_string( $capability ) ); + + if ( '' === $raw ) { + return 'unknown_operation'; + } + + return $raw; + } + + /** + * Normalises a role or capability value to a string. + * + * WP AI Client AbstractEnum stores a private $value with __get() and + * __toString() but no __isset(), so isset( $enum->value ) is always false. + * Native PHP 8.1 BackedEnum throws if cast to string, so UnitEnum is + * checked first. + * + * @param mixed $value Role or capability value. + * @return string Empty string when nothing usable is found. + */ + private function enum_like_to_string( $value ) { + if ( is_string( $value ) ) { + return $value; + } + + if ( ! is_object( $value ) ) { + return is_scalar( $value ) ? (string) $value : ''; + } + + if ( $value instanceof \WordPress\AiClient\Common\AbstractEnum ) { + return isset( $value->value ) ? (string) $value->value : (string) $value->name; + } + + if ( method_exists( $value, '__toString' ) ) { + try { + $cast = (string) $value; + if ( '' !== $cast ) { + return $cast; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + } + + try { + $raw = $value->value; + if ( is_scalar( $raw ) ) { + return (string) $raw; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return is_object( $value ) ? get_class( $value ) : ''; + } + + /** + * Appends extended metadata from the result, model, and event to the log args. + * + * All reads are guarded by method_exists / try-catch so the core logging path + * succeeds even when provider-specific fields are absent. + * + * @param array $log_args Base log args. + * @param object $event AfterGenerateResultEvent. + * @param object $model ModelInterface instance. + * @param object $result GenerativeAiResult instance. + * @return array + */ + private function append_result_context_to_log_args( array $log_args, $event, $model, $result ) { + $log_args['generator_model_class'] = get_class( $model ); + + // Message count. + if ( method_exists( $event, 'getMessages' ) ) { + try { + $log_args['message_count'] = count( $event->getMessages() ); + } catch ( \Throwable $e ) { + unset( $e ); + } + } + + // Result ID. + try { + if ( method_exists( $result, 'getId' ) ) { + $log_args['result_id'] = (string) $result->getId(); + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + // Candidate count. + try { + if ( method_exists( $result, 'getCandidateCount' ) ) { + $log_args['candidate_count'] = (int) $result->getCandidateCount(); + } elseif ( method_exists( $result, 'getCandidates' ) ) { + $log_args['candidate_count'] = count( $result->getCandidates() ); + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + // Total and thought tokens. + try { + $tu = $result->getTokenUsage(); + if ( method_exists( $tu, 'getTotalTokens' ) ) { + $log_args['total_tokens'] = (int) $tu->getTotalTokens(); + } + if ( method_exists( $tu, 'getThoughtTokens' ) ) { + $thought_tokens = $tu->getThoughtTokens(); + $log_args['thought_tokens'] = null === $thought_tokens ? null : (int) $thought_tokens; + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + // Provider metadata from result. + try { + if ( method_exists( $result, 'getProviderMetadata' ) ) { + $pm = $result->getProviderMetadata(); + if ( is_object( $pm ) ) { + if ( method_exists( $pm, 'getName' ) ) { + $log_args['provider_name'] = (string) $pm->getName(); + } + if ( method_exists( $pm, 'getType' ) ) { + $log_args['provider_type'] = $this->enum_like_to_string( $pm->getType() ); + } + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + // Model metadata from result. + try { + $mm = $result->getModelMetadata(); + if ( is_object( $mm ) ) { + if ( method_exists( $mm, 'getName' ) ) { + $log_args['model_name'] = (string) $mm->getName(); + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + // Additional data keys (sorted for deterministic meta). + try { + if ( method_exists( $result, 'getAdditionalData' ) ) { + $extra = $result->getAdditionalData(); + if ( is_array( $extra ) ) { + $keys = array_keys( $extra ); + sort( $keys, SORT_STRING ); + $log_args['additional_data_keys'] = array_map( 'strval', $keys ); + } + } + } catch ( \Throwable $e ) { + unset( $e ); + } + + return $log_args; + } + + /** + * Returns the pending-generation map, creating it on first use. + * + * @return \SplObjectStorage + */ + private function get_pending_storage() { + if ( ! $this->pending instanceof \SplObjectStorage ) { + $this->pending = new \SplObjectStorage(); + } + + return $this->pending; + } + + /** + * Drops the oldest in-flight row when the cap is reached. + * + * @param \SplObjectStorage $pending Pending map. + * @return void + */ + private function evict_oldest_pending_if_full( $pending ) { + if ( $pending->count() < self::MAX_PENDING_GENERATIONS ) { + return; + } + + $pending->rewind(); + if ( $pending->valid() ) { + $pending->detach( $pending->current() ); + } + } +} diff --git a/readme.md b/readme.md index 345d9dd41..77d9d5511 100755 --- a/readme.md +++ b/readme.md @@ -18,6 +18,8 @@ A list of the connectors is in [connectors.md](connectors.md). ### Configuration +See [readme.txt](readme.txt) for configuration details, including the [AI Client connector (WordPress 7.0+)](readme.txt#ai-client-wordpress-70). + To customize who can manage Stream settings, you can define the `WP_STREAM_SETTINGS_CAPABILITY` constant in your `wp-config.php` file. By default, capability will be set to `manage_options`. ```php diff --git a/readme.txt b/readme.txt index 1dd9594be..027c88654 100644 --- a/readme.txt +++ b/readme.txt @@ -41,6 +41,7 @@ With Stream’s powerful activity logging, you’ll have the information you nee = Built-In Tracking For Core Actions: = + * WordPress AI Client (WP 7.0+) * Posts * Pages * Custom Post Types @@ -101,6 +102,20 @@ As a workaround, you can use the `wp_stream_client_ip_address` filter to adapt t ⚠️ **WARNING:** The above is an insecure workaround that you should only use when you fully understand what this implies. Relying on any variable with the `HTTP_*` prefix is prone to spoofing and cannot be trusted! += AI Client (WordPress 7.0+) = + +When WordPress 7.0+ provides the AI Client event dispatcher (`WP_AI_Client_Event_Dispatcher`), Stream logs each AI generation as an activity record under **AI Client → Prompts → Generated**. If the dispatcher is not available, the connector registers no hooks and has no effect. + +By default, Stream stores metadata for every generation: operation, provider, model, input/output/thought token counts, duration, finish reason, and other extended fields when present. The activity summary shows a one-line preview (for example, `chat via openai/gpt-4o (tokens: 120/0/45) in 842ms`). Multiline summaries display only the first line in the list table; open a record to see the full text. + +Prompt and response text are **not** logged by default. To opt in, enable **Log Prompt and Response text** under **Stream → Settings → AI Client**. When enabled, prompt and response text are appended to the activity summary (not stored in meta, which is size-limited). **Privacy Warning:** This content may include personally identifiable information (PII). Ensure your privacy policy covers AI data collection before enabling. + +Developers can filter stored text when the setting is enabled: + +* `wp_stream_ai_client_log_prompt` — filter prompt text before it is stored; return an empty string to omit it. +* `wp_stream_ai_client_log_response` — filter response text before it is stored; return an empty string to omit it. + + == Known Issues == * We have temporarily disabled the data removal feature through plugin uninstallation, starting with version 3.9.3. We identified a few edge cases that did not behave as expected and we decided that a temporary removal is preferable at this time for such an impactful and irreversible operation. Our team is actively working on refining this feature to ensure it performs optimally and securely. We plan to reintroduce it in a future update with enhanced safeguards. @@ -139,6 +154,18 @@ Use only `$_SERVER['REMOTE_ADDR']` as the client IP address for event logs witho == Changelog == += Unreleased = + +New Features: + +* Add AI Client connector for WordPress 7.0+: logs AI generation metadata (operation, provider, model, tokens, duration, and more) when the WordPress AI Client event dispatcher is available. + +Enhancements: + +* Show only the first line of multiline summaries in the Stream list table. + +[View the full release notes on GitHub.](https://github.com/xwp/stream/blob/master/changelog.md#unreleased) + = 4.3.0 - July 18, 2026 = Enhancements: diff --git a/tests/phpunit/connectors/stubs/class-wp-ai-client-event-dispatcher.php b/tests/phpunit/connectors/stubs/class-wp-ai-client-event-dispatcher.php new file mode 100644 index 000000000..83469a797 --- /dev/null +++ b/tests/phpunit/connectors/stubs/class-wp-ai-client-event-dispatcher.php @@ -0,0 +1,8 @@ +|null + */ + private static $captured_log_args; + + /** + * Stores log() args for later assertions. Named callable so tests do not + * register closures with PHPUnit. + * + * @param string $message Log message template. + * @param array $args Log meta args. + * @return void + */ + public static function capture_log_args( $message, $args ) { + unset( $message ); + self::$captured_log_args = $args; + } + + /** + * Stores log() message and args for summary-template assertions. + * + * @param string $message Log message template. + * @param array $args Log meta args. + * @return void + */ + public static function capture_log_call( $message, $args ) { + self::$captured_log_message = $message; + self::$captured_log_args = $args; + } + + /** + * Enables prompt and response text logging when used as an is_prompt_and_response_logging_enabled stub. + * + * @return bool + */ + public static function enable_prompt_and_response_logging() { + return true; + } + + /** + * Replaces logged AI text so filter tests can assert the hook ran. + * + * @param string $text Original text. + * @param object $event AI Client event instance. + * @return string + */ + public static function prefix_redacted_log_text( $text, $event ) { + unset( $event ); + return 'REDACTED:' . $text; + } + + /** + * Set up a mocked connector instance before each test. + */ + public function setUp(): void { + parent::setUp(); + + $this->plugin->connectors->unload_connectors(); + + // Partial mock: override log() so we can assert calls without DB writes. + $this->mock = $this->getMockBuilder( Connector_AI_Client::class ) + ->onlyMethods( array( 'log', 'is_prompt_and_response_logging_enabled' ) ) + ->getMock(); + + // NOTE: Do NOT stub is_prompt_and_response_logging_enabled() here. PHPUnit keeps the first + // stub configured for a method with no argument matcher, so a per-test + // willReturnCallback() added later would be silently ignored. Each test + // owns its own option state instead (disabled tests stub false explicitly; + // un-stubbed returns null which is falsy, keeping text gated off by default). + $this->mock->register(); + } + + // ------------------------------------------------------------------------- + // Registration + // ------------------------------------------------------------------------- + + /** + * Registration is gated on WP_AI_Client_Event_Dispatcher, not the SDK class. + * + * Asserts the absent branch first when the class is missing, then loads the + * stub so the present branch is covered on WP 6.x CI. The require_once leaks + * the stub into the rest of this PHPUnit process; no other test asserts the + * connector is absent. + */ + public function test_connector_registration_is_gated_on_event_dispatcher() { + $connector = new Connector_AI_Client(); + + if ( ! class_exists( 'WP_AI_Client_Event_Dispatcher' ) ) { + $this->assertFalse( $connector->is_dependency_satisfied() ); + + $this->plugin->connectors->unload_connectors(); + $this->plugin->connectors->load_connectors(); + $this->assertArrayNotHasKey( 'ai-client', $this->plugin->connectors->connectors ); + + require_once __DIR__ . '/stubs/class-wp-ai-client-event-dispatcher.php'; + } + + $this->assertTrue( $connector->is_dependency_satisfied() ); + + $this->plugin->connectors->unload_connectors(); + $this->plugin->connectors->load_connectors(); + $this->assertArrayHasKey( 'ai-client', $this->plugin->connectors->connectors ); + } + + /** + * Connector slug, label, context, and action are declared correctly. + */ + public function test_connector_metadata() { + $connector = new Connector_AI_Client(); + + $this->assertSame( 'ai-client', $connector->name ); + $this->assertNotEmpty( $connector->get_label() ); + $this->assertArrayHasKey( 'prompts', $connector->get_context_labels() ); + $this->assertArrayHasKey( 'generated', $connector->get_action_labels() ); + } + + /** + * Action hooks are attached after register(). + */ + public function test_action_hooks_are_registered() { + $this->assertNotFalse( + has_action( 'wp_ai_client_before_generate_result', array( $this->mock, 'callback' ) ), + 'Before hook should be registered' + ); + $this->assertNotFalse( + has_action( 'wp_ai_client_after_generate_result', array( $this->mock, 'callback' ) ), + 'After hook should be registered' + ); + } + + // ------------------------------------------------------------------------- + // Core logging + // ------------------------------------------------------------------------- + + /** + * One log() call per before/after pair with correct context and action. + */ + public function test_log_is_called_once_per_generation() { + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->with( + $this->anything(), + $this->anything(), + null, + 'prompts', + 'generated' + ); + + $pair = $this->make_event_pair(); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + } + + /** + * log() args contain provider, model, operation, token counts, duration. + */ + public function test_log_args_contain_core_fields() { + $captured_args = null; + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( + array( + 'provider' => 'openai', + 'model' => 'gpt-4o', + 'operation' => 'text_generation', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertIsArray( $captured_args ); + $this->assertSame( 'text_generation', $captured_args['operation'] ); + $this->assertSame( 'openai', $captured_args['provider'] ); + $this->assertSame( 'gpt-4o', $captured_args['model'] ); + $this->assertIsInt( $captured_args['input_tokens'] ); + $this->assertIsInt( $captured_args['output_tokens'] ); + $this->assertIsInt( $captured_args['duration_ms'] ); + $this->assertSame( 'stop', $captured_args['finish_reason'] ); + } + + /** + * After-hook without a matching before-hook does not log anything. + */ + public function test_after_without_before_does_not_log() { + $this->mock->expects( $this->never() )->method( 'log' ); + + $pair = $this->make_event_pair(); + // Fire only the after hook — no before. + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + } + + /** + * Two concurrent generations on different model instances are correlated correctly. + */ + public function test_two_concurrent_generations_are_correlated_independently() { + $log_count = 0; + $this->mock->method( 'log' ) + ->willReturnCallback( + function () use ( &$log_count ) { + $log_count++; + } + ); + $this->mock->expects( $this->exactly( 2 ) )->method( 'log' ); + + $pair_a = $this->make_event_pair( array( 'model' => 'gpt-4o' ) ); + $pair_b = $this->make_event_pair( array( 'model' => 'claude-3' ) ); + + do_action( 'wp_ai_client_before_generate_result', $pair_a['before'] ); + do_action( 'wp_ai_client_before_generate_result', $pair_b['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair_a['after'] ); + do_action( 'wp_ai_client_after_generate_result', $pair_b['after'] ); + } + + /** + * An unmatched before-hook must not attach its prompt to a later generation + * on a different model instance (the old spl_object_id reuse failure mode). + */ + public function test_orphaned_before_does_not_attach_to_later_generation() { + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + self::$captured_log_args = null; + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_args' ) ); + + $pair_a = $this->make_event_pair( + array( + 'model' => 'model-a', + 'user_message' => 'Prompt A', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair_a['before'] ); + unset( $pair_a ); + + $pair_b = $this->make_event_pair( + array( + 'model' => 'model-b', + 'user_message' => 'Prompt B', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair_b['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair_b['after'] ); + + $this->assertStringContainsString( 'Prompt B', self::$captured_log_args['prompt_text'] ); + $this->assertStringNotContainsString( 'Prompt A', self::$captured_log_args['prompt_text'] ); + } + + // ------------------------------------------------------------------------- + // Text logging gating (prompt + response) + // ------------------------------------------------------------------------- + + /** + * prompt_text and response_text are empty when both options are off (default). + */ + public function test_text_fields_empty_when_options_disabled() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' )->willReturn( false ); + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( array( 'user_message' => 'Hello world' ) ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertSame( '', $captured_args['prompt_text'] ); + $this->assertSame( '', $captured_args['response_text'] ); + } + + /** + * prompt_text and response_text are populated when log_prompt_and_response_text is enabled. + */ + public function test_prompt_text_captured_when_option_enabled() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( + array( + 'user_message' => 'What is the capital of France?', + 'response_text' => 'The capital of France is Paris.', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertStringContainsString( 'What is the capital of France?', $captured_args['prompt_text'] ); + $this->assertStringContainsString( 'The capital of France is Paris.', $captured_args['response_text'] ); + } + + /** + * response_text is populated when log_prompt_and_response_text is enabled. + */ + public function test_response_text_captured_when_option_enabled() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( + array( + 'user_message' => 'Hidden prompt', + 'response_text' => 'The capital of France is Paris.', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertStringContainsString( 'Hidden prompt', $captured_args['prompt_text'] ); + $this->assertStringContainsString( 'The capital of France is Paris.', $captured_args['response_text'] ); + } + + /** + * Default-off: the real settings accessor leaves prompt/response empty + * when ai-client_log_prompt_and_response_text is absent or zero. Does not stub + * is_prompt_and_response_logging_enabled(), so a wrong option key would fail this test. + */ + public function test_text_fields_empty_when_real_settings_option_disabled() { + $original_options = $this->plugin->settings->options; + + $this->plugin->settings->options['ai-client_log_prompt_and_response_text'] = 0; + + try { + $this->register_settings_backed_connector(); + $args = $this->fire_generation_and_get_log_args( + array( + 'user_message' => 'Secret prompt', + 'response_text' => 'Secret response', + ) + ); + + $this->assertSame( '', $args['prompt_text'] ); + $this->assertSame( '', $args['response_text'] ); + } finally { + $this->plugin->settings->options = $original_options; + } + } + + /** + * Enabling the real Stream setting populates prompt_text and response_text. + */ + public function test_prompt_and_response_text_follows_real_settings_option() { + $original_options = $this->plugin->settings->options; + + try { + $this->plugin->settings->options['ai-client_log_prompt_and_response_text'] = 1; + $this->register_settings_backed_connector(); + $args = $this->fire_generation_and_get_log_args( + array( + 'user_message' => 'Visible prompt', + 'response_text' => 'Visible response', + ) + ); + $this->assertStringContainsString( 'Visible prompt', $args['prompt_text'] ); + $this->assertStringContainsString( 'Visible response', $args['response_text'] ); + } finally { + $this->plugin->settings->options = $original_options; + } + } + + /** + * On network-activated multisite, the toggle lives in wp_stream_network. + * In-memory per-site options must not win. + * + * @group ms-required + */ + public function test_log_option_reads_network_setting_when_network_activated() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires multisite.' ); + } + + $network_key = $this->plugin->settings->network_options_key; + $original_network = get_site_option( $network_key, false ); + $original_options = $this->plugin->settings->options; + + add_filter( 'wp_stream_is_network_activated', '__return_true' ); + $this->plugin->settings->options['ai-client_log_prompt_and_response_text'] = 0; + + try { + update_site_option( + $network_key, + array( 'ai-client_log_prompt_and_response_text' => 1 ) + ); + $this->register_settings_backed_connector(); + $args = $this->fire_generation_and_get_log_args( + array( + 'user_message' => 'Network prompt', + 'response_text' => 'Network response', + ) + ); + $this->assertStringContainsString( + 'Network prompt', + $args['prompt_text'], + 'Network option enabled must win over empty per-site options.' + ); + $this->assertStringContainsString( + 'Network response', + $args['response_text'], + 'Network option enabled must win over empty per-site options.' + ); + } finally { + remove_filter( 'wp_stream_is_network_activated', '__return_true' ); + $this->plugin->settings->options = $original_options; + if ( false === $original_network ) { + delete_site_option( $network_key ); + } else { + update_site_option( $network_key, $original_network ); + } + }//end try + } + + // ------------------------------------------------------------------------- + // Prompt extraction edge cases + // ------------------------------------------------------------------------- + + /** + * A single user message is extracted as plain text (no heading). + */ + public function test_single_user_message_extracted_as_plain_text() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( array( 'user_message' => 'Single user turn' ) ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + // Plain text, no "--- User ---" heading. + $this->assertSame( 'Single user turn', $captured_args['prompt_text'] ); + } + + /** + * Multiple messages receive section headings. + */ + public function test_multi_turn_messages_receive_section_headings() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( + array( + 'messages' => array( + $this->make_message( 'user', 'Hello' ), + $this->make_message( 'model', 'Hi there!' ), + $this->make_message( 'user', 'How are you?' ), + ), + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $prompt = $captured_args['prompt_text']; + $this->assertStringContainsString( '--- User ---', $prompt ); + $this->assertStringContainsString( '--- Assistant ---', $prompt ); + $this->assertStringContainsString( 'Hello', $prompt ); + $this->assertStringContainsString( 'Hi there!', $prompt ); + } + + /** + * Model-level system instruction is prepended before message-derived prompt text. + */ + public function test_model_system_instruction_prepended_to_prompt() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + $pair = $this->make_event_pair( + array( + 'user_message' => 'Hello', + 'system_instruction' => 'You are a helpful assistant.', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $prompt = $captured_args['prompt_text']; + $this->assertStringContainsString( '[System]', $prompt ); + $this->assertStringContainsString( 'You are a helpful assistant.', $prompt ); + $this->assertStringContainsString( '[User]', $prompt ); + $this->assertStringContainsString( 'Hello', $prompt ); + // System block must come before user text. + $this->assertLessThan( + strpos( $prompt, 'Hello' ), + strpos( $prompt, 'You are a helpful assistant.' ) + ); + } + + /** + * Messages with unrecognised roles are skipped (no fatal, no partial output). + */ + public function test_unknown_role_message_skipped_gracefully() { + $captured_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( + function ( $message, $args ) use ( &$captured_args ) { + $captured_args = $args; + } + ); + + // One good user message + one message with an unrecognised role. + $unknown_role_message = new class() { + public function getRole() { + return new class() { + // No value, no string map, no is*() methods. + }; + } + public function getParts() { + return array(); + } + }; + + $pair = $this->make_event_pair( + array( + 'messages' => array( + $this->make_message( 'user', 'Hello' ), + $unknown_role_message, + ), + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + // Still logs — only the valid user message appears in the prompt. + $this->assertSame( 'Hello', $captured_args['prompt_text'] ); + } + + // ------------------------------------------------------------------------- + // Summary routing (message placeholders + meta stripping) + // ------------------------------------------------------------------------- + + /** + * Builds a connector mock for filter_wp_stream_record_array tests. + * + * @param bool $logging_enabled Whether prompt/response logging is enabled. + * @return Connector_AI_Client + */ + private function make_filter_connector_mock( $logging_enabled ) { + $connector = $this->getMockBuilder( Connector_AI_Client::class ) + ->onlyMethods( array( 'is_prompt_and_response_logging_enabled' ) ) + ->getMock(); + + $connector->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturn( $logging_enabled ); + + return $connector; + } + + /** + * Log message includes prompt/response placeholders when text logging is enabled. + */ + public function test_log_message_includes_prompt_and_response_placeholders_when_enabled() { + self::$captured_log_message = null; + self::$captured_log_args = null; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_call' ) ); + + $pair = $this->make_event_pair( + array( + 'user_message' => 'Hello prompt', + 'response_text' => 'Hello response', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertStringContainsString( 'Prompt', self::$captured_log_message ); + $this->assertStringContainsString( 'Response', self::$captured_log_message ); + $this->assertStringContainsString( '%8$s', self::$captured_log_message ); + $this->assertStringContainsString( '%9$s', self::$captured_log_message ); + $this->assertSame( 'Hello prompt', self::$captured_log_args['prompt_text'] ); + $this->assertSame( 'Hello response', self::$captured_log_args['response_text'] ); + } + + /** + * filter_wp_stream_record_array removes prompt/response meta when logging is enabled. + */ + public function test_filter_record_array_removes_text_meta_when_logging_enabled() { + $connector = $this->make_filter_connector_mock( true ); + $record = array( + 'connector' => 'ai-client', + 'meta' => array( + 'operation' => 'chat', + 'provider' => 'openai', + 'prompt_text' => 'The user prompt.', + 'response_text' => 'The AI response.', + ), + ); + + $result = $connector->filter_wp_stream_record_array( $record ); + + $this->assertArrayHasKey( 'operation', $result['meta'] ); + $this->assertArrayHasKey( 'provider', $result['meta'] ); + $this->assertArrayNotHasKey( 'prompt_text', $result['meta'] ); + $this->assertArrayNotHasKey( 'response_text', $result['meta'] ); + } + + /** + * filter_wp_stream_record_array keeps prompt/response meta when logging is disabled. + */ + public function test_filter_record_array_keeps_text_meta_when_logging_disabled() { + $connector = $this->make_filter_connector_mock( false ); + $record = array( + 'connector' => 'ai-client', + 'meta' => array( + 'operation' => 'chat', + 'prompt_text' => 'The user prompt.', + 'response_text' => 'The AI response.', + ), + ); + + $result = $connector->filter_wp_stream_record_array( $record ); + + $this->assertSame( $record, $result ); + } + + /** + * filter_wp_stream_record_array ignores records for other connectors. + */ + public function test_filter_record_array_ignores_other_connectors() { + $connector = $this->make_filter_connector_mock( true ); + $record = array( + 'connector' => 'posts', + 'meta' => array( + 'prompt_text' => 'Should stay', + 'response_text' => 'Should stay', + 'post_id' => 42, + ), + ); + + $result = $connector->filter_wp_stream_record_array( $record ); + + $this->assertSame( $record, $result ); + } + + // ------------------------------------------------------------------------- + // Settings: add_settings_fields + // ------------------------------------------------------------------------- + + /** + * add_settings_fields injects one checkbox under the 'ai-client' key. + */ + public function test_add_settings_fields_injects_checkboxes() { + $connector = new Connector_AI_Client(); + $fields = $connector->add_settings_fields( array() ); + + $this->assertArrayHasKey( 'ai-client', $fields ); + $this->assertCount( 1, $fields['ai-client']['fields'] ); + + $field_names = array_column( $fields['ai-client']['fields'], 'name' ); + $this->assertContains( Connector_AI_Client::LOG_PROMPT_AND_RESPONSE_TEXT_OPTION_NAME, $field_names ); + + // Defaults to off. + foreach ( $fields['ai-client']['fields'] as $field ) { + $this->assertSame( 0, $field['default'] ); + } + } + + // ------------------------------------------------------------------------- + // Log-text filter + // ------------------------------------------------------------------------- + + /** + * wp_stream_ai_client_log_prompt can replace stored prompt text. + */ + public function test_log_prompt_filter_can_redact_prompt() { + // Arrange + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + self::$captured_log_args = null; + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_args' ) ); + add_filter( 'wp_stream_ai_client_log_prompt', array( self::class, 'prefix_redacted_log_text' ), 10, 2 ); + + try { + // Act + $pair = $this->make_event_pair( array( 'user_message' => 'Secret' ) ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + // Assert + $this->assertStringStartsWith( 'REDACTED:', self::$captured_log_args['prompt_text'] ); + $this->assertStringContainsString( 'Secret', self::$captured_log_args['prompt_text'] ); + } finally { + remove_filter( 'wp_stream_ai_client_log_prompt', array( self::class, 'prefix_redacted_log_text' ), 10 ); + } + } + + /** + * wp_stream_ai_client_log_response can replace stored response text. + */ + public function test_log_response_filter_can_redact_response() { + // Arrange + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + self::$captured_log_args = null; + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_args' ) ); + add_filter( 'wp_stream_ai_client_log_response', array( self::class, 'prefix_redacted_log_text' ), 10, 2 ); + + try { + // Act + $pair = $this->make_event_pair( + array( + 'user_message' => 'Visible prompt', + 'response_text' => 'Secret response', + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + // Assert + $this->assertStringContainsString( 'Visible prompt', self::$captured_log_args['prompt_text'] ); + $this->assertStringStartsWith( 'REDACTED:', self::$captured_log_args['response_text'] ); + $this->assertStringContainsString( 'Secret response', self::$captured_log_args['response_text'] ); + } finally { + remove_filter( 'wp_stream_ai_client_log_response', array( self::class, 'prefix_redacted_log_text' ), 10 ); + } + } + + /** + * Real WP AI Client MessageRoleEnum extracts as a user prompt. + */ + public function test_real_message_role_enum_extracts_user_prompt() { + if ( ! class_exists( '\WordPress\AiClient\Messages\Enums\MessageRoleEnum' ) ) { + $this->markTestSkipped( 'WP AI Client MessageRoleEnum is not available.' ); + } + + // Arrange + $role = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(); + $message = new class( $role ) { + private $role; + + public function __construct( $role ) { + $this->role = $role; + } + + public function getRole() { + return $this->role; + } + + public function getParts() { + $part = new class() { + public function getText() { + return 'Hello from core enum'; + } + }; + return array( $part ); + } + }; + + $this->mock->method( 'is_prompt_and_response_logging_enabled' ) + ->willReturnCallback( array( self::class, 'enable_prompt_and_response_logging' ) ); + self::$captured_log_args = null; + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_args' ) ); + + // Act + $pair = $this->make_event_pair( array( 'messages' => array( $message ) ) ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + // Assert + $this->assertSame( 'Hello from core enum', self::$captured_log_args['prompt_text'] ); + } + + // ------------------------------------------------------------------------- + // DTO double helpers + // ------------------------------------------------------------------------- + + /** + * Replaces the setUp() mock with one that uses the real is_prompt_and_response_logging_enabled(). + * + * @return void + */ + private function register_settings_backed_connector() { + $this->mock->unregister(); + + self::$captured_log_args = null; + + $connector = $this->getMockBuilder( Connector_AI_Client::class ) + ->onlyMethods( array( 'log' ) ) + ->getMock(); + + $connector->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_args' ) ); + + $connector->register(); + $this->mock = $connector; + } + + /** + * Fires a matched before/after generation and returns the captured log args. + * + * @param array $opts Options for make_event_pair(). + * @return array|null + */ + private function fire_generation_and_get_log_args( array $opts ) { + $pair = $this->make_event_pair( $opts ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + return self::$captured_log_args; + } + + /** + * Builds a matched before/after event pair sharing the same model instance. + * + * @param array $opts Options: + * - provider (string) + * - model (string) + * - operation (string) raw capability value + * - user_message (string) shortcut for single user message + * - messages (object[]) explicit message list (overrides user_message) + * - system_instruction (string) model-level system instruction + * - response_text (string) text returned by result->toText() + * - input_tokens (int) + * - output_tokens (int) + * - finish_reason (string) + * @return array{before: object, after: object} + */ + private function make_event_pair( array $opts = array() ) { + $provider = isset( $opts['provider'] ) ? $opts['provider'] : 'test-provider'; + $model_id = isset( $opts['model'] ) ? $opts['model'] : 'test-model'; + $capability_value = isset( $opts['operation'] ) ? $opts['operation'] : 'text_generation'; + $response_text = isset( $opts['response_text'] ) ? $opts['response_text'] : 'Test response.'; + $input_tokens = isset( $opts['input_tokens'] ) ? $opts['input_tokens'] : 10; + $output_tokens = isset( $opts['output_tokens'] ) ? $opts['output_tokens'] : 5; + $finish_reason = isset( $opts['finish_reason'] ) ? $opts['finish_reason'] : 'stop'; + $system_instr = isset( $opts['system_instruction'] ) ? $opts['system_instruction'] : ''; + + if ( isset( $opts['messages'] ) ) { + $messages = $opts['messages']; + } elseif ( isset( $opts['user_message'] ) ) { + $messages = array( $this->make_message( 'user', $opts['user_message'] ) ); + } else { + $messages = array( $this->make_message( 'user', 'Test prompt.' ) ); + } + + $model = $this->make_model_double( $provider, $model_id, $system_instr ); + $capability = $this->make_capability_double( $capability_value ); + $result = $this->make_result_double( $model_id, $input_tokens, $output_tokens, $finish_reason, $response_text ); + + $before = new class( $messages, $model, $capability ) { + private $messages; + private $model; + private $capability; + + public function __construct( $messages, $model, $capability ) { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + } + + public function getMessages() { + return $this->messages; + } + + public function getModel() { + return $this->model; + } + + public function getCapability() { + return $this->capability; + } + }; + + $after = new class( $messages, $model, $capability, $result ) { + private $messages; + private $model; + private $capability; + private $result; + + public function __construct( $messages, $model, $capability, $result ) { + $this->messages = $messages; + $this->model = $model; + $this->capability = $capability; + $this->result = $result; + } + + public function getMessages() { + return $this->messages; + } + + public function getModel() { + return $this->model; + } + + public function getCapability() { + return $this->capability; + } + + public function getResult() { + return $this->result; + } + }; + + return array( + 'before' => $before, + 'after' => $after, + ); + } + + /** + * Builds a Message DTO double with the given role string and text content. + * + * @param string $role_value 'user' or 'model'. + * @param string $text Message text content. + * @return object + */ + private function make_message( $role_value, $text ) { + $role = new class( $role_value ) { + private $value; + + public function __construct( $value ) { + $this->value = $value; + } + + public function __get( $name ) { + return 'value' === $name ? $this->value : null; + } + + public function __toString() { + return $this->value; + } + }; + + $part = new class( $text ) { + private $text; + + public function __construct( $text ) { + $this->text = $text; + } + + public function getText() { + return $this->text; + } + }; + + return new class( $role, array( $part ) ) { + private $role; + private $parts; + + public function __construct( $role, $parts ) { + $this->role = $role; + $this->parts = $parts; + } + + public function getRole() { + return $this->role; + } + + public function getParts() { + return $this->parts; + } + }; + } + + /** + * Builds a ModelInterface DTO double. + * + * @param string $provider_id Provider ID string. + * @param string $model_id Model ID string. + * @param string $system_instr Optional system instruction. + * @return object + */ + private function make_model_double( $provider_id, $model_id, $system_instr = '' ) { + $provider_meta = new class( $provider_id ) { + private $id; + + public function __construct( $id ) { + $this->id = $id; + } + + public function getId() { + return $this->id; + } + }; + + $model_meta = new class( $model_id ) { + private $id; + + public function __construct( $id ) { + $this->id = $id; + } + + public function getId() { + return $this->id; + } + + public function getName() { + return $this->id . '-display'; + } + + public function getSupportedCapabilities() { + return array(); + } + }; + + return new class( $provider_meta, $model_meta, $system_instr ) { + private $provider_meta; + private $model_meta; + private $system_instr; + + public function __construct( $provider_meta, $model_meta, $system_instr ) { + $this->provider_meta = $provider_meta; + $this->model_meta = $model_meta; + $this->system_instr = $system_instr; + } + + public function providerMetadata() { + return $this->provider_meta; + } + + public function metadata() { + return $this->model_meta; + } + + public function getSystemInstruction() { + return $this->system_instr; + } + }; + } + + /** + * Builds a CapabilityEnum DTO double with ->value. + * + * @param string $value Raw capability value (e.g. 'text_generation'). + * @return object + */ + private function make_capability_double( $value ) { + return new class( $value ) { + public $value; + + public function __construct( $value ) { + $this->value = $value; + } + + public function __toString() { + return $this->value; + } + }; + } + + /** + * Builds a GenerativeAiResult DTO double. + * + * @param string $model_id Model ID for response metadata. + * @param int $input_tokens Prompt tokens. + * @param int $output_tokens Completion tokens. + * @param string $finish_reason Finish reason string (e.g. 'stop'). + * @param string $response_text Text to return from toText(). + * @return object + */ + private function make_result_double( $model_id, $input_tokens, $output_tokens, $finish_reason, $response_text ) { + $token_usage = new class( $input_tokens, $output_tokens ) { + private $input; + private $output; + + public function __construct( $input, $output ) { + $this->input = $input; + $this->output = $output; + } + + public function getPromptTokens() { + return $this->input; + } + + public function getCompletionTokens() { + return $this->output; + } + + public function getTotalTokens() { + return $this->input + $this->output; + } + + public function getThoughtTokens() { + return null; + } + }; + + $finish_reason_obj = new class( $finish_reason ) { + private $value; + + public function __construct( $value ) { + $this->value = $value; + } + + public function __toString() { + return $this->value; + } + }; + + $candidate = new class( $finish_reason_obj ) { + private $finish_reason; + + public function __construct( $finish_reason ) { + $this->finish_reason = $finish_reason; + } + + public function getFinishReason() { + return $this->finish_reason; + } + }; + + $model_meta = new class( $model_id ) { + private $id; + + public function __construct( $id ) { + $this->id = $id; + } + + public function getId() { + return $this->id; + } + + public function getName() { + return $this->id . '-display'; + } + + public function getSupportedCapabilities() { + return array(); + } + }; + + return new class( $token_usage, $candidate, $model_meta, $response_text ) { + private $token_usage; + private $candidate; + private $model_meta; + private $response_text; + + public function __construct( $token_usage, $candidate, $model_meta, $response_text ) { + $this->token_usage = $token_usage; + $this->candidate = $candidate; + $this->model_meta = $model_meta; + $this->response_text = $response_text; + } + + public function getTokenUsage() { + return $this->token_usage; + } + + public function getCandidates() { + return array( $this->candidate ); + } + + public function getCandidateCount() { + return 1; + } + + public function getModelMetadata() { + return $this->model_meta; + } + + public function getId() { + return 'result-001'; + } + + public function toText() { + return $this->response_text; + } + + public function getAdditionalData() { + return array(); + } + }; + } +} diff --git a/tests/phpunit/test-class-list-table.php b/tests/phpunit/test-class-list-table.php new file mode 100644 index 000000000..73985bc9d --- /dev/null +++ b/tests/phpunit/test-class-list-table.php @@ -0,0 +1,66 @@ +assertMatchesRegularExpression( $expected_pattern, $result, $description ); + } + + public static function provide_format_summary_preview_html_cases(): array { + return array( + 'empty string returns empty' => array( + '', + '/^$/', + 'Empty summary should return empty string.', + ), + 'single-line returns as-is' => array( + 'Post "Hello World" was published', + '/^Post "Hello World" was published$|^Post "Hello World" was published$/', + 'Summary without newlines should be returned verbatim.', + ), + 'LF newline shows only first line' => array( + "First line\nSecond line", + '/^First line$/', + 'LF-separated summary should return only the first line.', + ), + 'CRLF newline shows only first line' => array( + "First line\r\nSecond line", + '/^First line$/', + 'CRLF-separated summary should return only the first line.', + ), + 'CR newline shows only first line' => array( + "First line\rSecond line", + '/^First line$/', + 'CR-separated summary should return only the first line.', + ), + 'only trailing newline treated as single line' => array( + "Single line\n", + '/^Single line\n$|^Single line$/', + 'A summary with only a trailing newline should be returned as-is.', + ), + ); + } + + public function test_format_summary_preview_html_single_line_no_wrapper(): void { + $input = 'A simple one-line summary'; + $result = List_Table::format_summary_preview_html( $input ); + + $this->assertStringNotContainsString( 'assertSame( $input, $result, 'Single-line summary must be returned unchanged.' ); + } + + public function test_format_summary_preview_html_multiline_does_not_contain_second_line(): void { + $input = "Line one is shown\nLine two is hidden"; + $result = List_Table::format_summary_preview_html( $input ); + + $this->assertSame( 'Line one is shown', $result, 'Multiline summary must return only the first line.' ); + $this->assertStringNotContainsString( 'Line two is hidden', $result, 'Second line must not appear in output.' ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'title=', $result, 'Multiline summary must not include a title attribute.' ); + } +} From c777b722798e77823ecaace0a09e65859191de42 Mon Sep 17 00:00:00 2001 From: Shadi Sharaf Date: Fri, 28 Aug 2026 14:48:04 +0300 Subject: [PATCH 2/2] Harden AI Client logging after review so list previews, translations, and PII warnings match Stream's existing patterns. Truncated summaries now show an ellipsis, log templates stay translatable without early escaping, and operators are warned that stored prompt text may be forwarded to alerts or webhooks. --- classes/class-list-table.php | 11 ++- connectors/class-connector-ai-client.php | 99 ++++++++++--------- readme.txt | 12 --- .../test-class-connector-ai-client.php | 56 ++++++++++- tests/phpunit/test-class-list-table.php | 36 +++---- 5 files changed, 129 insertions(+), 85 deletions(-) diff --git a/classes/class-list-table.php b/classes/class-list-table.php index dcc89128c..b22722b0c 100644 --- a/classes/class-list-table.php +++ b/classes/class-list-table.php @@ -317,7 +317,7 @@ public function column_default( $item, $column_name ) { break; case 'summary': - $out = self::format_summary_preview_html( (string) $record->summary ); + $out = self::format_summary_preview( (string) $record->summary ); $object_title = $record->get_object_title(); /* translators: %s: the title of any object, like a Post (e.g. "Hello World") */ $view_all_text = $object_title ? sprintf( esc_html__( 'View all activity for "%s"', 'stream' ), esc_attr( $object_title ) ) : esc_html__( 'View all activity for this object', 'stream' ); @@ -433,10 +433,13 @@ public function column_default( $item, $column_name ) { /** * Formats a summary for the admin list table: first line only. * + * When the summary contains additional lines, an ellipsis is appended so it + * is clear the stored record has more text than the feed preview shows. + * * @param string $summary Full summary stored in the database. - * @return string Summary preview text. + * @return string Summary preview text (plain text, not HTML). */ - public static function format_summary_preview_html( $summary ) { + public static function format_summary_preview( $summary ) { if ( '' === $summary ) { return ''; } @@ -448,7 +451,7 @@ public static function format_summary_preview_html( $summary ) { return $summary; } - return $first; + return $first . '…'; } /** diff --git a/connectors/class-connector-ai-client.php b/connectors/class-connector-ai-client.php index 29774b4d2..0d0fbcdd2 100644 --- a/connectors/class-connector-ai-client.php +++ b/connectors/class-connector-ai-client.php @@ -132,14 +132,19 @@ public function is_dependency_satisfied() { * Adds opt-in checkbox — log_prompt_and_response_text — under * a dedicated "AI Client" section in Stream → Settings. Both default to off. * - * @param array>}> $fields Stream settings fields. - * @return array>}> + * @param array>}>|mixed $fields Stream settings fields. + * @return array>}>|mixed */ - public function add_settings_fields( array $fields ) { + public function add_settings_fields( $fields ) { + if ( ! is_array( $fields ) ) { + return $fields; + } + $pii_warning = sprintf( - '%s %s', + '%s %s %s', esc_html__( 'Privacy Warning:', 'stream' ), - esc_html__( 'This content may include personally identifiable information (PII). Ensure your privacy policy covers AI data collection before enabling.', 'stream' ) + esc_html__( 'This content may include personally identifiable information (PII). Ensure your privacy policy covers AI data collection before enabling.', 'stream' ), + esc_html__( 'When enabled, prompt and response text are stored in the record summary and may be forwarded verbatim to any configured Stream alerts or webhooks (e.g. Slack, IFTTT). Use the wp_stream_ai_client_log_prompt and wp_stream_ai_client_log_response filters to redact or omit text before it is stored.', 'stream' ) ); $fields[ $this->name ] = array( @@ -178,10 +183,14 @@ protected function is_prompt_and_response_logging_enabled() { /** * Removes unnecessary meta data from the record array. * - * @param array $record Record about to be inserted. - * @return array + * @param array|mixed $record Record about to be inserted. + * @return array|mixed */ - public function filter_wp_stream_record_array( array $record ) { + public function filter_wp_stream_record_array( $record ) { + if ( ! is_array( $record ) ) { + return $record; + } + if ( ( isset( $record['connector'] ) ? $record['connector'] : '' ) !== $this->name || ! is_array( isset( $record['meta'] ) ? $record['meta'] : null ) ) { return $record; } @@ -225,9 +234,11 @@ public function callback_wp_ai_client_before_generate_result( $event ) { $prompt_text = ''; // If prompt logged is enabled, extract the prompt text from the event. if ( $this->is_prompt_and_response_logging_enabled() ) { + $extracted = $this->extract_prompt_text( $event->getMessages() ); $prompt_text = $this->merge_model_system_instruction_into_prompt( - $this->extract_prompt_text( $event->getMessages() ), - $this->extract_model_system_instruction( $model ) + $extracted['text'], + $this->extract_model_system_instruction( $model ), + $extracted['has_sections'] ); /** @@ -312,6 +323,7 @@ public function callback_wp_ai_client_after_generate_result( $event ) { $input_tokens = (int) $token_usage->getPromptTokens(); $output_tokens = (int) $token_usage->getCompletionTokens(); $thought_tokens = (int) $token_usage->getThoughtTokens(); + $total_tokens = (int) $token_usage->getTotalTokens(); $finish_reason = $this->extract_finish_reason( $result ); $response_model = (string) $result->getModelMetadata()->getId(); @@ -326,15 +338,17 @@ public function callback_wp_ai_client_after_generate_result( $event ) { 'prompt_text' => $pending['prompt_text'], 'response_text' => $response_text, 'finish_reason' => $finish_reason, + 'total_tokens' => $total_tokens, ); $log_args = $this->append_result_context_to_log_args( $log_args, $event, $model, $result ); - /* translators: 1: AI operation (e.g. "chat"), 2: provider slug, 3: model ID */ - $message = esc_html__( '%1$s via %2$s/%3$s (tokens: %4$d/%6$d/%5$d) in %7$dms', 'stream' ); + /* translators: 1: AI operation (e.g. "chat"), 2: provider slug, 3: model ID, 4: input token count, 5: output token count, 6: thought token count, 7: duration in milliseconds */ + $message = __( '%1$s via %2$s/%3$s (tokens: %4$d/%6$d/%5$d) in %7$dms', 'stream' ); // Add the prompt and response text to the message if enabled. if ( $this->is_prompt_and_response_logging_enabled() ) { - $message .= sprintf( "\n\n[%s]\n%s\n\n[%s]\n%s", esc_html__( 'Prompt', 'stream' ), '%8$s', esc_html__( 'Response', 'stream' ), '%9$s' ); + /* translators: Placeholders %8$s and %9$s are the prompt and response text bodies. */ + $message .= sprintf( "\n\n[%s]\n%s\n\n[%s]\n%s", __( 'Prompt', 'stream' ), '%8$s', __( 'Response', 'stream' ), '%9$s' ); } $this->log( @@ -368,7 +382,7 @@ public function callback_wp_ai_client_after_generate_result( $event ) { * other enum-like objects — narrow fallbacks are kept for those. * * @param object[] $messages Array of Message DTOs from BeforeGenerateResultEvent. - * @return string + * @return array{text: string, has_sections: bool} Assembled prompt text and whether section headings were used. */ private function extract_prompt_text( array $messages ) { $sections = array(); @@ -399,12 +413,18 @@ private function extract_prompt_text( array $messages ) { } if ( array() === $sections ) { - return ''; + return array( + 'text' => '', + 'has_sections' => false, + ); } // Single user message — plain text, no heading. if ( 1 === count( $sections ) && 'user' === $sections[0]['key'] ) { - return $sections[0]['body']; + return array( + 'text' => $sections[0]['body'], + 'has_sections' => false, + ); } $label_map = array( @@ -420,10 +440,13 @@ private function extract_prompt_text( array $messages ) { if ( '' === $label ) { continue; } - $blocks[] = '--- ' . $label . " ---\n" . $section['body']; + $blocks[] = '[' . $label . ']' . "\n" . $section['body']; } - return implode( "\n\n", $blocks ); + return array( + 'text' => implode( "\n\n", $blocks ), + 'has_sections' => true, + ); } /** @@ -605,28 +628,31 @@ private function extract_model_system_instruction( $model ) { * text (no section headings), the user text is wrapped in a User heading so * both blocks are visually distinct in Stream's UI. * - * @param string $message_prompt Text assembled from getMessages(). + * @param string $message_prompt Text assembled from getMessages(). * @param string $model_instruction Text from model/config getSystemInstruction(). + * @param bool $has_sections Whether $message_prompt already uses [Label] section headings. * @return string */ - private function merge_model_system_instruction_into_prompt( $message_prompt, $model_instruction ) { + private function merge_model_system_instruction_into_prompt( $message_prompt, $model_instruction, $has_sections = false ) { if ( '' === $model_instruction ) { return $message_prompt; } - $system_block = "\n[System]\n" . $model_instruction; + $system_label = __( 'System', 'stream' ); + $user_label = __( 'User', 'stream' ); + $system_block = '[' . $system_label . ']' . "\n" . $model_instruction; if ( '' === $message_prompt ) { return $system_block; } // Already has section headings — prepend system block directly. - if ( false !== strpos( $message_prompt, '--- ' ) ) { + if ( $has_sections ) { return $system_block . "\n\n" . $message_prompt; } // Plain user text — add a User heading for visual consistency. - return $system_block . "\n[User]\n" . $message_prompt; + return $system_block . sprintf( "\n[%s]\n", $user_label ) . $message_prompt; } // ------------------------------------------------------------------------- @@ -661,7 +687,7 @@ private function extract_finish_reason( $result ) { * * Supports AbstractEnum (WP AI Client), BackedEnum, UnitEnum, and objects * with a ->value property. Unknown values pass through as the operation label; - * null or unresolvable capabilities fall back to 'chat'. + * null or unresolvable capabilities fall back to 'unknown'. * * @param object|null $capability CapabilityEnum instance or null. * @return string @@ -674,7 +700,7 @@ private function normalize_capability( $capability ) { $raw = strtolower( $this->enum_like_to_string( $capability ) ); if ( '' === $raw ) { - return 'unknown_operation'; + return 'unknown'; } return $raw; @@ -683,11 +709,6 @@ private function normalize_capability( $capability ) { /** * Normalises a role or capability value to a string. * - * WP AI Client AbstractEnum stores a private $value with __get() and - * __toString() but no __isset(), so isset( $enum->value ) is always false. - * Native PHP 8.1 BackedEnum throws if cast to string, so UnitEnum is - * checked first. - * * @param mixed $value Role or capability value. * @return string Empty string when nothing usable is found. */ @@ -700,10 +721,6 @@ private function enum_like_to_string( $value ) { return is_scalar( $value ) ? (string) $value : ''; } - if ( $value instanceof \WordPress\AiClient\Common\AbstractEnum ) { - return isset( $value->value ) ? (string) $value->value : (string) $value->name; - } - if ( method_exists( $value, '__toString' ) ) { try { $cast = (string) $value; @@ -771,20 +788,6 @@ private function append_result_context_to_log_args( array $log_args, $event, $mo unset( $e ); } - // Total and thought tokens. - try { - $tu = $result->getTokenUsage(); - if ( method_exists( $tu, 'getTotalTokens' ) ) { - $log_args['total_tokens'] = (int) $tu->getTotalTokens(); - } - if ( method_exists( $tu, 'getThoughtTokens' ) ) { - $thought_tokens = $tu->getThoughtTokens(); - $log_args['thought_tokens'] = null === $thought_tokens ? null : (int) $thought_tokens; - } - } catch ( \Throwable $e ) { - unset( $e ); - } - // Provider metadata from result. try { if ( method_exists( $result, 'getProviderMetadata' ) ) { diff --git a/readme.txt b/readme.txt index 027c88654..54ef140b2 100644 --- a/readme.txt +++ b/readme.txt @@ -154,18 +154,6 @@ Use only `$_SERVER['REMOTE_ADDR']` as the client IP address for event logs witho == Changelog == -= Unreleased = - -New Features: - -* Add AI Client connector for WordPress 7.0+: logs AI generation metadata (operation, provider, model, tokens, duration, and more) when the WordPress AI Client event dispatcher is available. - -Enhancements: - -* Show only the first line of multiline summaries in the Stream list table. - -[View the full release notes on GitHub.](https://github.com/xwp/stream/blob/master/changelog.md#unreleased) - = 4.3.0 - July 18, 2026 = Enhancements: diff --git a/tests/phpunit/connectors/test-class-connector-ai-client.php b/tests/phpunit/connectors/test-class-connector-ai-client.php index 296f7c0e8..a979022cd 100644 --- a/tests/phpunit/connectors/test-class-connector-ai-client.php +++ b/tests/phpunit/connectors/test-class-connector-ai-client.php @@ -209,6 +209,7 @@ function ( $message, $args ) use ( &$captured_args ) { $this->assertSame( 'gpt-4o', $captured_args['model'] ); $this->assertIsInt( $captured_args['input_tokens'] ); $this->assertIsInt( $captured_args['output_tokens'] ); + $this->assertIsInt( $captured_args['thought_tokens'] ); $this->assertIsInt( $captured_args['duration_ms'] ); $this->assertSame( 'stop', $captured_args['finish_reason'] ); } @@ -492,7 +493,7 @@ function ( $message, $args ) use ( &$captured_args ) { do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); - // Plain text, no "--- User ---" heading. + // Plain text, no "[User]" heading. $this->assertSame( 'Single user turn', $captured_args['prompt_text'] ); } @@ -526,8 +527,8 @@ function ( $message, $args ) use ( &$captured_args ) { do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); $prompt = $captured_args['prompt_text']; - $this->assertStringContainsString( '--- User ---', $prompt ); - $this->assertStringContainsString( '--- Assistant ---', $prompt ); + $this->assertStringContainsString( '[User]', $prompt ); + $this->assertStringContainsString( '[Assistant]', $prompt ); $this->assertStringContainsString( 'Hello', $prompt ); $this->assertStringContainsString( 'Hi there!', $prompt ); } @@ -665,6 +666,50 @@ public function test_log_message_includes_prompt_and_response_placeholders_when_ $this->assertSame( 'Hello response', self::$captured_log_args['response_text'] ); } + /** + * Summary token placeholders render as input/output/thought in that order. + */ + public function test_log_message_token_order_is_input_output_thought() { + self::$captured_log_message = null; + self::$captured_log_args = null; + + $this->mock->expects( $this->once() ) + ->method( 'log' ) + ->willReturnCallback( array( self::class, 'capture_log_call' ) ); + + $pair = $this->make_event_pair( + array( + 'provider' => 'openai', + 'model' => 'gpt-4o', + 'operation' => 'text_generation', + 'input_tokens' => 10, + 'output_tokens' => 5, + ) + ); + do_action( 'wp_ai_client_before_generate_result', $pair['before'] ); + do_action( 'wp_ai_client_after_generate_result', $pair['after'] ); + + $this->assertIsString( self::$captured_log_message ); + $this->assertIsArray( self::$captured_log_args ); + $this->assertSame( 10, self::$captured_log_args['input_tokens'] ); + $this->assertSame( 5, self::$captured_log_args['output_tokens'] ); + $this->assertSame( 0, self::$captured_log_args['thought_tokens'] ); + + $rendered = vsprintf( self::$captured_log_message, array_values( self::$captured_log_args ) ); + $this->assertStringContainsString( '(tokens: 10/5/0)', $rendered ); + $this->assertStringNotContainsString( '(tokens: 10/0/5)', $rendered ); + } + + /** + * filter_wp_stream_record_array passes through non-array values unchanged. + */ + public function test_filter_record_array_passes_through_non_array() { + $connector = $this->make_filter_connector_mock( true ); + + $this->assertFalse( $connector->filter_wp_stream_record_array( false ) ); + $this->assertNull( $connector->filter_wp_stream_record_array( null ) ); + } + /** * filter_wp_stream_record_array removes prompt/response meta when logging is enabled. */ @@ -747,6 +792,11 @@ public function test_add_settings_fields_injects_checkboxes() { foreach ( $fields['ai-client']['fields'] as $field ) { $this->assertSame( 0, $field['default'] ); } + + $desc = $fields['ai-client']['fields'][0]['desc']; + $this->assertStringContainsString( 'Privacy Warning:', $desc ); + $this->assertStringContainsString( 'alerts', $desc ); + $this->assertStringContainsString( 'wp_stream_ai_client_log_prompt', $desc ); } // ------------------------------------------------------------------------- diff --git a/tests/phpunit/test-class-list-table.php b/tests/phpunit/test-class-list-table.php index 73985bc9d..11ff75e70 100644 --- a/tests/phpunit/test-class-list-table.php +++ b/tests/phpunit/test-class-list-table.php @@ -4,14 +4,14 @@ class Test_List_Table extends WP_StreamTestCase { /** - * @dataProvider provide_format_summary_preview_html_cases + * @dataProvider provide_format_summary_preview_cases */ - public function test_format_summary_preview_html( string $input, string $expected_pattern, string $description ): void { - $result = List_Table::format_summary_preview_html( $input ); + public function test_format_summary_preview( string $input, string $expected_pattern, string $description ): void { + $result = List_Table::format_summary_preview( $input ); $this->assertMatchesRegularExpression( $expected_pattern, $result, $description ); } - public static function provide_format_summary_preview_html_cases(): array { + public static function provide_format_summary_preview_cases(): array { return array( 'empty string returns empty' => array( '', @@ -23,20 +23,20 @@ public static function provide_format_summary_preview_html_cases(): array { '/^Post "Hello World" was published$|^Post "Hello World" was published$/', 'Summary without newlines should be returned verbatim.', ), - 'LF newline shows only first line' => array( + 'LF newline shows only first line with ellipsis' => array( "First line\nSecond line", - '/^First line$/', - 'LF-separated summary should return only the first line.', + '/^First line…$/', + 'LF-separated summary should return only the first line with an ellipsis.', ), - 'CRLF newline shows only first line' => array( + 'CRLF newline shows only first line with ellipsis' => array( "First line\r\nSecond line", - '/^First line$/', - 'CRLF-separated summary should return only the first line.', + '/^First line…$/', + 'CRLF-separated summary should return only the first line with an ellipsis.', ), - 'CR newline shows only first line' => array( + 'CR newline shows only first line with ellipsis' => array( "First line\rSecond line", - '/^First line$/', - 'CR-separated summary should return only the first line.', + '/^First line…$/', + 'CR-separated summary should return only the first line with an ellipsis.', ), 'only trailing newline treated as single line' => array( "Single line\n", @@ -46,19 +46,19 @@ public static function provide_format_summary_preview_html_cases(): array { ); } - public function test_format_summary_preview_html_single_line_no_wrapper(): void { + public function test_format_summary_preview_single_line_no_wrapper(): void { $input = 'A simple one-line summary'; - $result = List_Table::format_summary_preview_html( $input ); + $result = List_Table::format_summary_preview( $input ); $this->assertStringNotContainsString( 'assertSame( $input, $result, 'Single-line summary must be returned unchanged.' ); } - public function test_format_summary_preview_html_multiline_does_not_contain_second_line(): void { + public function test_format_summary_preview_multiline_does_not_contain_second_line(): void { $input = "Line one is shown\nLine two is hidden"; - $result = List_Table::format_summary_preview_html( $input ); + $result = List_Table::format_summary_preview( $input ); - $this->assertSame( 'Line one is shown', $result, 'Multiline summary must return only the first line.' ); + $this->assertSame( 'Line one is shown…', $result, 'Multiline summary must return only the first line with an ellipsis.' ); $this->assertStringNotContainsString( 'Line two is hidden', $result, 'Second line must not appear in output.' ); $this->assertStringNotContainsString( 'assertStringNotContainsString( 'title=', $result, 'Multiline summary must not include a title attribute.' );