From b526750e24f96e3ab934997ac6c2d3fcced91e2a Mon Sep 17 00:00:00 2001 From: Ibrahim Hajjaj Date: Fri, 29 May 2026 13:50:48 +0300 Subject: [PATCH] feat(abilities): observe wp_ability_invoked for entry-point telemetry --- ...lass-wp-agent-ability-lifecycle-bridge.php | 34 +++++++++++++++++-- tests/ability-lifecycle-bridge-smoke.php | 32 +++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/Abilities/class-wp-agent-ability-lifecycle-bridge.php b/src/Abilities/class-wp-agent-ability-lifecycle-bridge.php index c1124c7..c7dbf5b 100644 --- a/src/Abilities/class-wp-agent-ability-lifecycle-bridge.php +++ b/src/Abilities/class-wp-agent-ability-lifecycle-bridge.php @@ -26,21 +26,27 @@ * adopts them slice by slice (see #94). * * Wired today: + * - `wp_ability_invoked` -> `agents_api_ability_invoked` action. Fires at the + * top of `execute()` for every call, before any processing, so observers see + * invocations that never reach a result (validation failure, permission + * denial, pre-execute short-circuit). * - `wp_ability_execute_result` -> `agents_api_ability_executed` action. * Telemetry without each ability author opting in. * - `wp_pre_execute_ability` -> decision-driven approval gate. Hosts decide * via {@see self::FILTER_PRE_EXECUTE_DECISION}; the bridge handles the * sentinel, minting, staging, and `approval_required` envelope. * - * The execute-result observer returns the filter input unchanged. The - * pre-execute gate short-circuits with an `approval_required` envelope when - * the host signals approval is needed, and passes through otherwise. + * The invoked and execute-result observers return the filter/value input + * unchanged. The pre-execute gate short-circuits with an `approval_required` + * envelope when the host signals approval is needed, and passes through + * otherwise. * * On WordPress < 7.1 the underlying filters are never applied, so registered * handlers stay idle. */ class WP_Agent_Ability_Lifecycle_Bridge { + public const ACTION_ABILITY_INVOKED = 'agents_api_ability_invoked'; public const ACTION_ABILITY_EXECUTED = 'agents_api_ability_executed'; public const FILTER_PRE_EXECUTE_DECISION = 'agents_api_ability_pre_execute_decision'; public const FILTER_PENDING_ACTION_STORE = 'wp_agent_pending_action_store'; @@ -57,10 +63,32 @@ public static function register(): void { return; } + add_action( 'wp_ability_invoked', array( __CLASS__, 'observe_invoked' ), 10, 3 ); add_filter( 'wp_ability_execute_result', array( __CLASS__, 'observe_execute_result' ), 10, 4 ); add_filter( 'wp_pre_execute_ability', array( __CLASS__, 'gate_pre_execute' ), 10, 4 ); } + /** + * Observer for the `wp_ability_invoked` action. + * + * Re-emits `agents_api_ability_invoked` at the entry point of every ability + * call. Unlike {@see self::ACTION_ABILITY_EXECUTED}, which only fires on the + * registered execute_callback's success path, this fires for every call + * regardless of outcome, so observers can record invocations that fail input + * validation, fail the permission check, or get short-circuited before the + * callback runs. The input is the raw value passed to `execute()`, before + * normalization. + * + * @param string $ability_name Ability name. + * @param mixed $input Raw input passed to execute(), before normalization. + * @param object $ability `WP_Ability` instance. + */ + public static function observe_invoked( string $ability_name, $input, $ability ): void { + if ( function_exists( 'do_action' ) ) { + do_action( self::ACTION_ABILITY_INVOKED, $ability_name, $input, $ability ); + } + } + /** * Observer for the `wp_ability_execute_result` filter. * diff --git a/tests/ability-lifecycle-bridge-smoke.php b/tests/ability-lifecycle-bridge-smoke.php index e7747e7..9700bad 100644 --- a/tests/ability-lifecycle-bridge-smoke.php +++ b/tests/ability-lifecycle-bridge-smoke.php @@ -72,4 +72,36 @@ static function ( string $ability_name, $result, $input, $ability ) use ( &$obse agents_api_smoke_assert_equals( 'test/one', $observed[0]['ability_name'] ?? null, 'first observation carries ability name', $failures, $passes ); agents_api_smoke_assert_equals( 'test/three', $observed[2]['ability_name'] ?? null, 'third observation carries ability name', $failures, $passes ); +echo "\n[4] Invoked observer re-emits the entry-point action with raw input:\n"; +$invoked = array(); +add_action( + WP_Agent_Ability_Lifecycle_Bridge::ACTION_ABILITY_INVOKED, + static function ( string $ability_name, $input, $ability ) use ( &$invoked ): void { + $invoked[] = array( + 'ability_name' => $ability_name, + 'input' => $input, + 'ability' => $ability, + ); + }, + 10, + 3 +); + +$raw_input = array( 'q' => 'raw' ); +do_action( 'wp_ability_invoked', 'test/echo', $raw_input, $ability_stub ); + +agents_api_smoke_assert_equals( 1, count( $invoked ), 'invoked observer fired once', $failures, $passes ); +agents_api_smoke_assert_equals( 'test/echo', $invoked[0]['ability_name'] ?? null, 'invoked observer received ability name', $failures, $passes ); +agents_api_smoke_assert_equals( $raw_input, $invoked[0]['input'] ?? null, 'invoked observer received raw input', $failures, $passes ); +agents_api_smoke_assert_equals( true, ( $invoked[0]['ability'] ?? null ) === $ability_stub, 'invoked observer received the ability instance', $failures, $passes ); + +echo "\n[5] Invoked fires for calls that never reach a result:\n"; +$invoked = array(); +// An invocation that fails validation/permission still fires wp_ability_invoked +// at the top of execute(), but never reaches wp_ability_execute_result. +do_action( 'wp_ability_invoked', 'test/denied', array(), $ability_stub ); + +agents_api_smoke_assert_equals( 1, count( $invoked ), 'invoked observer fires even when no result follows', $failures, $passes ); +agents_api_smoke_assert_equals( 'test/denied', $invoked[0]['ability_name'] ?? null, 'invoked observer records the short-circuited ability', $failures, $passes ); + agents_api_smoke_finish( 'ability lifecycle bridge', $failures, $passes );