diff --git a/agents-api.php b/agents-api.php index 80951bb..ded0a48 100644 --- a/agents-api.php +++ b/agents-api.php @@ -4,6 +4,7 @@ * Description: WordPress-shaped agent runtime substrate. * Version: 0.1.0 * Requires at least: 7.0 + * Tested up to: 7.0 * Requires PHP: 8.1 * Author: Automattic * License: GPL-2.0-or-later @@ -136,17 +137,20 @@ require_once AGENTS_API_PATH . 'src/Channels/class-wp-agent-channel.php'; require_once AGENTS_API_PATH . 'src/Channels/register-agents-chat-ability.php'; require_once AGENTS_API_PATH . 'src/Channels/register-frontend-chat-rest-route.php'; +require_once AGENTS_API_PATH . 'src/Channels/register-agents-dispatch-message-ability.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-bindings.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec-validator.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-spec.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-run-result.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-store.php'; +require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-lifecycle.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-run-recorder.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-runner.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-registry.php'; require_once AGENTS_API_PATH . 'src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php'; require_once AGENTS_API_PATH . 'src/Workflows/register-workflows.php'; require_once AGENTS_API_PATH . 'src/Workflows/register-agents-workflow-abilities.php'; +require_once AGENTS_API_PATH . 'src/Workflows/register-workflow-bridge-sync.php'; require_once AGENTS_API_PATH . 'src/Workflows/register-action-scheduler-listener.php'; require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine.php'; require_once AGENTS_API_PATH . 'src/Routines/class-wp-agent-routine-registry.php'; diff --git a/composer.json b/composer.json index a7ea1e9..4225695 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", "php tests/frontend-chat-rest-smoke.php", + "php tests/agents-dispatch-message-ability-smoke.php", "php tests/webhook-safety-smoke.php", "php tests/remote-bridge-smoke.php", "php tests/context-authority-smoke.php", @@ -60,6 +61,7 @@ "php tests/workflow-bindings-smoke.php", "php tests/workflow-spec-validator-smoke.php", "php tests/workflow-runner-smoke.php", + "php tests/workflow-lifecycle-smoke.php", "php tests/agents-workflow-ability-smoke.php", "php tests/routine-smoke.php", "php tests/subagents-smoke.php", diff --git a/src/Channels/class-wp-agent-channel.php b/src/Channels/class-wp-agent-channel.php index 1bf7791..0705762 100644 --- a/src/Channels/class-wp-agent-channel.php +++ b/src/Channels/class-wp-agent-channel.php @@ -383,7 +383,7 @@ public function build_external_message( string $message_text, array $data ): WP_ $this->get_external_id_provider(), $this->get_external_id(), $this->extract_external_message_id( $data ), - null, + $this->extract_sender_id( $data ), false, $this->get_room_kind( $data ), $this->extract_attachments( $data ), @@ -431,6 +431,18 @@ protected function extract_external_message_id( array $data ): ?string { return null; } + /** + * Opaque external sender id. In DMs this may equal the conversation id; + * in group chats it identifies the human sender inside the room. + * + * @param array $data + * @return string|null + */ + protected function extract_sender_id( array $data ): ?string { + unset( $data ); + return null; + } + /** * Conversation kind: `dm`, `group`, `channel`, or null when unknown. * Override per transport — WhatsApp can derive from the JID suffix, diff --git a/src/Channels/class-wp-agent-external-message.php b/src/Channels/class-wp-agent-external-message.php index f5f5ee0..3e46ac9 100644 --- a/src/Channels/class-wp-agent-external-message.php +++ b/src/Channels/class-wp-agent-external-message.php @@ -123,6 +123,7 @@ public function client_context( string $source = 'channel' ): array { 'external_provider' => $this->external_provider, 'external_conversation_id' => $this->external_conversation_id, 'external_message_id' => $this->external_message_id, + 'sender_id' => $this->sender_id, 'room_kind' => $this->room_kind, ); } diff --git a/src/Channels/register-agents-chat-ability.php b/src/Channels/register-agents-chat-ability.php index 13e7651..09c9b96 100644 --- a/src/Channels/register-agents-chat-ability.php +++ b/src/Channels/register-agents-chat-ability.php @@ -242,6 +242,10 @@ function agents_chat_input_schema(): array { 'type' => array( 'string', 'null' ), 'description' => 'Stable transport-side message id, used for reply threading / dedup / audit.', ), + 'sender_id' => array( + 'type' => array( 'string', 'null' ), + 'description' => 'Opaque external sender id. In group rooms this identifies the human sender inside the conversation.', + ), 'room_kind' => array( 'type' => array( 'string', 'null' ), 'enum' => array( 'dm', 'group', 'channel' ), diff --git a/src/Channels/register-agents-dispatch-message-ability.php b/src/Channels/register-agents-dispatch-message-ability.php new file mode 100644 index 0000000..870f074 --- /dev/null +++ b/src/Channels/register-agents-dispatch-message-ability.php @@ -0,0 +1,214 @@ + 'Agents API', + 'description' => 'Cross-cutting abilities provided by the Agents API substrate (channel dispatch, canonical chat contract, and workflow dispatch).', + ) + ); + } +); + +add_action( + 'wp_abilities_api_init', + static function (): void { + if ( wp_has_ability( AGENTS_DISPATCH_MESSAGE_ABILITY ) ) { + return; + } + + wp_register_ability( + AGENTS_DISPATCH_MESSAGE_ABILITY, + array( + 'label' => 'Dispatch Message', + 'description' => 'Canonical entry point for sending one outbound message through a channel transport. Dispatches to whichever runtime is registered via the wp_agent_dispatch_message_handler filter.', + 'category' => 'agents-api', + 'input_schema' => agents_dispatch_message_input_schema(), + 'output_schema' => agents_dispatch_message_output_schema(), + 'execute_callback' => __NAMESPACE__ . '\\agents_dispatch_message_dispatch', + 'permission_callback' => __NAMESPACE__ . '\\agents_dispatch_message_permission', + 'meta' => array( + 'show_in_rest' => true, + 'annotations' => array( + 'destructive' => true, + 'idempotent' => false, + ), + ), + ) + ); + } +); + +/** + * Dispatch an outbound message to the registered runtime. + * + * @since 0.107.0 + * + * @param array $input Canonical dispatch-message input. + * @return array|\WP_Error Canonical output, or WP_Error if no runtime is registered. + */ +function agents_dispatch_message_dispatch( array $input ) { + /** + * Filter the outbound message runtime handler. + * + * The first hook to return a callable wins. Handlers receive the canonical + * input map and must return either the canonical output shape or WP_Error. + * + * @since 0.107.0 + * + * @param callable|null $handler Currently registered handler. + * @param array $input Canonical dispatch-message input. + */ + $handler = apply_filters( 'wp_agent_dispatch_message_handler', null, $input ); + + if ( ! is_callable( $handler ) ) { + do_action( 'agents_dispatch_message_failed', 'no_handler', $input ); + + return new \WP_Error( + 'agents_dispatch_message_no_handler', + 'No agents/dispatch-message handler is registered. Install a channel runtime, or add a callable to the wp_agent_dispatch_message_handler filter.' + ); + } + + $result = call_user_func( $handler, $input ); + + if ( is_wp_error( $result ) ) { + do_action( 'agents_dispatch_message_failed', $result->get_error_code(), $input ); + return $result; + } + + if ( ! is_array( $result ) ) { + do_action( 'agents_dispatch_message_failed', 'invalid_result', $input ); + return new \WP_Error( + 'agents_dispatch_message_invalid_result', + 'agents/dispatch-message handler returned an unexpected result type. Handlers must return an array matching the canonical output shape or a WP_Error.' + ); + } + + return $result; +} + +/** + * Permission gate for `agents/dispatch-message`. + * + * @since 0.107.0 + * + * @param array $input Canonical dispatch-message input. + */ +function agents_dispatch_message_permission( array $input ): bool { + return (bool) apply_filters( + 'agents_dispatch_message_permission', + current_user_can( 'manage_options' ), + $input + ); +} + +/** + * Canonical input schema. + * + * @since 0.107.0 + */ +function agents_dispatch_message_input_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'channel', 'recipient', 'message' ), + 'properties' => array( + 'channel' => array( + 'type' => 'string', + 'description' => 'Outbound channel identifier, e.g. whatsapp, wacli, slack, sms, or a product-defined channel id.', + ), + 'recipient' => array( + 'type' => 'string', + 'description' => 'Transport-specific destination id, e.g. phone number, WhatsApp JID, channel id, or email address.', + ), + 'message' => array( + 'type' => 'string', + 'description' => 'Text body to send.', + ), + 'conversation_id' => array( + 'type' => array( 'string', 'null' ), + 'description' => 'Optional transport conversation/thread id.', + ), + 'attachments' => array( + 'type' => 'array', + 'default' => array(), + 'items' => array( 'type' => 'object' ), + 'description' => 'Optional transport-defined attachments.', + ), + 'client_context' => array( + 'type' => 'object', + 'description' => 'Optional caller/runtime context.', + ), + 'metadata' => array( + 'type' => 'object', + 'description' => 'Opaque caller metadata for transport runtimes and audit logs.', + ), + ), + ); +} + +/** + * Canonical output schema. + * + * @since 0.107.0 + */ +function agents_dispatch_message_output_schema(): array { + return array( + 'type' => 'object', + 'required' => array( 'sent', 'channel', 'recipient' ), + 'properties' => array( + 'sent' => array( 'type' => 'boolean' ), + 'channel' => array( 'type' => 'string' ), + 'recipient' => array( 'type' => 'string' ), + 'message_id' => array( 'type' => array( 'string', 'null' ) ), + 'metadata' => array( 'type' => 'object' ), + ), + ); +} + +/** + * Convenience helper for consumers. + * + * @since 0.107.0 + * + * @param callable $handler Receives canonical input, returns canonical output or WP_Error. + * @param int $priority Filter priority. + */ +function register_dispatch_message_handler( callable $handler, int $priority = 10 ): void { + add_filter( + 'wp_agent_dispatch_message_handler', + static function ( $existing, array $input ) use ( $handler ) { + unset( $input ); + if ( null !== $existing ) { + return $existing; + } + return $handler; + }, + $priority, + 2 + ); +} diff --git a/src/Tools/class-wp-agent-tool-execution-core.php b/src/Tools/class-wp-agent-tool-execution-core.php index e12dec2..46fc619 100644 --- a/src/Tools/class-wp-agent-tool-execution-core.php +++ b/src/Tools/class-wp-agent-tool-execution-core.php @@ -34,7 +34,8 @@ public function prepareWP_Agent_Tool_Call( string $tool_name, array $tool_parame ); } - $validation = WP_Agent_Tool_Parameters::validateRequiredParameters( $tool_parameters, $tool_definition ); + $parameters = WP_Agent_Tool_Parameters::buildParameters( $tool_parameters, $context, $tool_definition ); + $validation = WP_Agent_Tool_Parameters::validateRequiredParameters( $parameters, $tool_definition ); if ( ! $validation['valid'] ) { return array_merge( array( 'ready' => false ), @@ -51,7 +52,7 @@ public function prepareWP_Agent_Tool_Call( string $tool_name, array $tool_parame 'tool_call' => WP_Agent_Tool_Call::normalize( array( 'tool_name' => $tool_name, - 'parameters' => WP_Agent_Tool_Parameters::buildParameters( $tool_parameters, $context, $tool_definition ), + 'parameters' => $parameters, 'metadata' => array( 'source' => $tool_definition['source'] ?? WP_Agent_Tool_Declaration::sourceFromName( $tool_name ), ), diff --git a/src/Tools/class-wp-agent-tool-parameters.php b/src/Tools/class-wp-agent-tool-parameters.php index 6c6eb50..d8ae8d9 100644 --- a/src/Tools/class-wp-agent-tool-parameters.php +++ b/src/Tools/class-wp-agent-tool-parameters.php @@ -15,11 +15,23 @@ class WP_Agent_Tool_Parameters { /** - * Merge caller context with runtime/client-provided tool parameters. + * Merge declared client-context bindings with runtime tool parameters. * - * Runtime parameters win over context keys so tool calls can override optional - * defaults supplied by the host runtime. The tool declaration is accepted for - * future schema-aware normalization without forcing a runtime dependency now. + * Caller-supplied parameters always win. Values from `$context` are only + * pulled in for parameter slots that the tool declaration explicitly opts + * into via `client_context_bindings`. This keeps sensitive parameters + * auditable: a context key can never silently satisfy a required tool + * argument the tool author didn't expect to come from context. + * + * Two declaration shapes are accepted: + * + * - Associative: `[ 'user_phone' => 'sender_id' ]` — pull + * `context['sender_id']` into the `user_phone` parameter slot. + * - Flat list: `[ 'sender_id', 'connector_id' ]` — same-name + * binding, equivalent to `[ 'sender_id' => 'sender_id', ... ]`. + * + * Empty / null context values are ignored so they don't silently + * satisfy a required-parameter check. * * @param array $tool_parameters Runtime tool-call parameters. * @param array $context Host runtime context for this invocation. @@ -27,9 +39,19 @@ class WP_Agent_Tool_Parameters { * @return array Complete parameters for execution. */ public static function buildParameters( array $tool_parameters, array $context = array(), array $tool_definition = array() ): array { - unset( $tool_definition ); + $parameters = array(); + + foreach ( self::clientContextBindings( $tool_definition ) as $parameter_name => $context_key ) { + if ( ! array_key_exists( $context_key, $context ) ) { + continue; + } + $value = $context[ $context_key ]; + if ( '' === $value || null === $value ) { + continue; + } + $parameters[ $parameter_name ] = $value; + } - $parameters = $context; foreach ( $tool_parameters as $key => $value ) { $parameters[ $key ] = $value; } @@ -37,6 +59,34 @@ public static function buildParameters( array $tool_parameters, array $context = return $parameters; } + /** + * Normalize the tool's `client_context_bindings` declaration into a + * `parameter_name => context_key` map. Skips malformed entries silently + * — the declaration is best-effort metadata, not validated input. + * + * @param array $tool_definition Normalized tool declaration. + * @return array + */ + private static function clientContextBindings( array $tool_definition ): array { + $bindings = $tool_definition['client_context_bindings'] ?? array(); + if ( ! is_array( $bindings ) ) { + return array(); + } + + $normalized = array(); + foreach ( $bindings as $parameter_name => $context_key ) { + if ( is_int( $parameter_name ) && is_string( $context_key ) && '' !== $context_key ) { + $normalized[ $context_key ] = $context_key; + continue; + } + if ( is_string( $parameter_name ) && '' !== $parameter_name && is_string( $context_key ) && '' !== $context_key ) { + $normalized[ $parameter_name ] = $context_key; + } + } + + return $normalized; + } + /** * Validate required parameters declared by a tool definition. * diff --git a/src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php b/src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php index e25cb0c..4a002da 100644 --- a/src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php +++ b/src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php @@ -105,6 +105,26 @@ public static function register( WP_Agent_Workflow_Spec $spec ): int { return $count; } + /** + * Re-sync the AS schedule for a durable workflow spec. + * + * Unconditionally unschedules every existing AS action keyed on this + * workflow id, then registers the cron triggers declared on the new + * spec. Distinct from {@see register()} because it tears down stale + * schedules even when the new spec has no cron triggers — the case + * an update that switches from `cron` to `on_demand` would otherwise + * leak. This is the method durable-store lifecycle subscribers want. + * + * @since 0.108.0 + * + * @return int Number of schedules registered (zero if the new spec + * has no cron triggers; the unschedule step still ran). + */ + public static function sync( WP_Agent_Workflow_Spec $spec ): int { + self::unregister( $spec->get_id() ); + return self::register( $spec ); + } + /** * Cancel every scheduled action this bridge owns for the given * workflow id. Useful on workflow deletion or version bump. diff --git a/src/Workflows/class-wp-agent-workflow-bindings.php b/src/Workflows/class-wp-agent-workflow-bindings.php index 25d7115..7218ce2 100644 --- a/src/Workflows/class-wp-agent-workflow-bindings.php +++ b/src/Workflows/class-wp-agent-workflow-bindings.php @@ -4,10 +4,12 @@ * * A workflow step's `args` (or `message`, or any other field a runner asks * the resolver to expand) may contain `${...}` references that pull values - * from the workflow's static inputs or from previous steps' outputs: + * from the workflow's static inputs, previous steps' outputs, or scoped + * runtime variables: * * ${inputs.} // workflow input * ${steps..output.} // earlier step output + * ${vars..} // loop/runtime variable * * Resolution is deliberately conservative: the helper substitutes whole * template expressions atomically (so binding a non-string value into a @@ -16,10 +18,10 @@ * literal `${...}` string. Callers that need stricter behavior can check * for `null` after expansion. * - * The token vocabulary is intentionally minimal — `inputs.*` and - * `steps..output.*`. Anything else (env vars, user-context, secrets) - * needs to be introduced via a dedicated runtime hook so the contract - * stays auditable. + * The token vocabulary is intentionally minimal — `inputs.*`, + * `steps..output.*`, and `vars.*`. Anything else (env vars, + * user-context, secrets) needs to be introduced via a dedicated runtime hook + * so the contract stays auditable. * * @package AgentsAPI * @since 0.103.0 @@ -148,6 +150,11 @@ public static function resolve_path( string $expression, array $context ) { return self::walk( $step['output'] ?? null, $segments ); } + if ( 'vars' === $root ) { + $source = $context['vars'] ?? array(); + return self::walk( $source, $segments ); + } + return null; } diff --git a/src/Workflows/class-wp-agent-workflow-lifecycle.php b/src/Workflows/class-wp-agent-workflow-lifecycle.php new file mode 100644 index 0000000..4d4964a --- /dev/null +++ b/src/Workflows/class-wp-agent-workflow-lifecycle.php @@ -0,0 +1,130 @@ + array( __CLASS__, 'default_ability_handler' ), 'agent' => array( __CLASS__, 'default_agent_handler' ), + 'foreach' => array( __CLASS__, 'default_foreach_handler' ), ); /** @@ -159,6 +160,7 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra 'inputs' => $inputs, // Step outputs accumulate here as the run progresses, keyed by step id. 'steps' => array(), + 'vars' => array(), ); $step_records = array(); @@ -170,7 +172,9 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra $step_id = (string) $step['id']; $type = (string) $step['type']; $start_ts = time(); - $resolved = WP_Agent_Workflow_Bindings::expand( $step, $context ); + $resolved = 'foreach' === $type + ? self::expand_foreach_outer_step( $step, $context ) + : WP_Agent_Workflow_Bindings::expand( $step, $context ); $record = array( 'id' => $step_id, 'type' => $type, @@ -272,6 +276,26 @@ public function run( WP_Agent_Workflow_Spec $spec, array $inputs = array(), arra return $result; } + /** + * Expand a foreach step's outer fields while preserving its nested step + * templates for each iteration's scoped variables. + * + * @since 0.107.0 + * + * @param array $step + * @param array $context + * @return array + */ + private static function expand_foreach_outer_step( array $step, array $context ): array { + $nested = $step['steps'] ?? array(); + unset( $step['steps'] ); + + $expanded = WP_Agent_Workflow_Bindings::expand( $step, $context ); + $expanded['steps'] = $nested; + + return $expanded; + } + /** * Validate inputs against the spec's declared input schemas. * @@ -367,6 +391,124 @@ public static function default_agent_handler( array $step, array $context ) { return is_array( $result ) ? $result : array( 'reply' => (string) $result ); } + /** + * Default `foreach` step handler. Iterates over a resolved array and runs + * an inline list of workflow steps with `${vars..*}` available. + * + * @since 0.107.0 + * + * @param array $step Resolved outer foreach step. + * @param array $context Resolution context. + * @return array|WP_Error + */ + public static function default_foreach_handler( array $step, array $context ) { + $items = $step['items'] ?? array(); + if ( ! is_array( $items ) ) { + return new \WP_Error( + 'workflow_foreach_items_invalid', + 'foreach step `items` must resolve to an array.' + ); + } + + $steps = $step['steps'] ?? array(); + if ( empty( $steps ) || ! is_array( $steps ) ) { + return new \WP_Error( + 'workflow_foreach_steps_invalid', + 'foreach step must include a non-empty nested `steps` list.' + ); + } + + $as = isset( $step['as'] ) && '' !== (string) $step['as'] ? (string) $step['as'] : 'item'; + $index_as = isset( $step['index_as'] ) && '' !== (string) $step['index_as'] ? (string) $step['index_as'] : 'index'; + $continue_on_error = ! empty( $step['continue_on_error'] ); + $handlers = (array) apply_filters( + 'wp_agent_workflow_step_handlers', + array( + 'ability' => array( __CLASS__, 'default_ability_handler' ), + 'agent' => array( __CLASS__, 'default_agent_handler' ), + 'foreach' => array( __CLASS__, 'default_foreach_handler' ), + ) + ); + $iterations = array(); + + foreach ( array_values( $items ) as $index => $item ) { + $iteration_context = $context; + $iteration_context['vars'] = array_merge( + (array) ( $context['vars'] ?? array() ), + array( + $as => $item, + $index_as => $index, + ) + ); + $step_outputs = array(); + $last_output = null; + + foreach ( $steps as $nested_step ) { + if ( ! is_array( $nested_step ) ) { + return new \WP_Error( + 'workflow_foreach_step_invalid', + sprintf( 'foreach nested step at index %d must be an array.', $index ) + ); + } + + $nested_id = (string) ( $nested_step['id'] ?? '' ); + $type = (string) ( $nested_step['type'] ?? '' ); + $handler = $handlers[ $type ] ?? null; + if ( '' === $nested_id || ! is_callable( $handler ) ) { + $error = new \WP_Error( + 'workflow_foreach_step_unhandled', + sprintf( 'foreach nested step `%s` cannot be handled.', '' !== $nested_id ? $nested_id : (string) $index ) + ); + if ( ! $continue_on_error ) { + return $error; + } + $step_outputs[ $nested_id ] = array( + 'error' => array( + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + ), + ); + continue; + } + + $resolved = 'foreach' === $type + ? self::expand_foreach_outer_step( $nested_step, $iteration_context ) + : WP_Agent_Workflow_Bindings::expand( $nested_step, $iteration_context ); + $nested_output = call_user_func( $handler, $resolved, $iteration_context ); + + if ( is_wp_error( $nested_output ) ) { + if ( ! $continue_on_error ) { + return $nested_output; + } + $last_output = array( + 'error' => array( + 'code' => $nested_output->get_error_code(), + 'message' => $nested_output->get_error_message(), + 'data' => $nested_output->get_error_data(), + ), + ); + } else { + $last_output = is_array( $nested_output ) ? $nested_output : array( 'value' => $nested_output ); + } + + $step_outputs[ $nested_id ] = $last_output; + $iteration_context['steps'][ $nested_id ] = array( 'output' => $last_output ); + } + + $iterations[] = array( + 'index' => $index, + 'item' => $item, + 'steps' => $step_outputs, + 'last' => $last_output, + ); + } + + return array( + 'count' => count( $iterations ), + 'iterations' => $iterations, + ); + } + /** * Generate a run id when the caller didn't supply one. Prefers the * WordPress UUID helper when available, falls back to a uniqid-based diff --git a/src/Workflows/class-wp-agent-workflow-spec-validator.php b/src/Workflows/class-wp-agent-workflow-spec-validator.php index 2e66220..2a0284d 100644 --- a/src/Workflows/class-wp-agent-workflow-spec-validator.php +++ b/src/Workflows/class-wp-agent-workflow-spec-validator.php @@ -35,7 +35,7 @@ final class WP_Agent_Workflow_Spec_Validator { /** @since 0.103.0 */ - public const KNOWN_STEP_TYPES = array( 'ability', 'agent' ); + public const KNOWN_STEP_TYPES = array( 'ability', 'agent', 'foreach' ); /** @since 0.103.0 */ public const KNOWN_TRIGGER_TYPES = array( 'on_demand', 'wp_action', 'cron' ); @@ -204,6 +204,29 @@ private static function validate_steps( array $steps ): array { ); } } + + if ( 'foreach' === $step['type'] ) { + if ( ! array_key_exists( 'items', $step ) ) { + $errors[] = array( + 'path' => "{$path}.items", + 'code' => 'missing_required', + 'message' => 'foreach step is missing required `items` field', + ); + } + if ( empty( $step['steps'] ) || ! is_array( $step['steps'] ) || array_values( $step['steps'] ) !== $step['steps'] ) { + $errors[] = array( + 'path' => "{$path}.steps", + 'code' => 'missing_required', + 'message' => 'foreach step must declare a non-empty `steps` list', + ); + } else { + foreach ( self::validate_steps( $step['steps'] ) as $inner_error ) { + $inner_path = (string) preg_replace( '/^steps\./', '', $inner_error['path'] ); + $inner_error['path'] = "{$path}.steps." . $inner_path; + $errors[] = $inner_error; + } + } + } } return $errors; @@ -228,7 +251,7 @@ private static function validate_step_binding_references( array $steps ): array } $step_id = isset( $step['id'] ) ? (string) $step['id'] : ''; - $tokens = self::extract_step_binding_ids( $step ); + $tokens = self::extract_top_level_step_binding_ids( $step ); foreach ( $tokens as $referenced_id ) { if ( ! isset( $seen[ $referenced_id ] ) ) { @@ -253,6 +276,24 @@ private static function validate_step_binding_references( array $steps ): array return $errors; } + /** + * Pull step references from a top-level step without validating nested + * foreach step bodies against the outer step order. + * + * @param array $step + * @return array + */ + private static function extract_top_level_step_binding_ids( array $step ): array { + $ids = array(); + foreach ( $step as $key => $value ) { + if ( 'steps' === $key && 'foreach' === ( $step['type'] ?? '' ) ) { + continue; + } + $ids = array_merge( $ids, self::extract_step_binding_ids( $value ) ); + } + return $ids; + } + /** * Walk a step array and pull out every `${steps..output.*}` token's * id segment. Used by {@see validate_step_binding_references()}. diff --git a/src/Workflows/class-wp-agent-workflow-store.php b/src/Workflows/class-wp-agent-workflow-store.php index 9c02ae1..b1111eb 100644 --- a/src/Workflows/class-wp-agent-workflow-store.php +++ b/src/Workflows/class-wp-agent-workflow-store.php @@ -13,6 +13,32 @@ * that sits in front of any store; consumers compose the two however they * like. * + * ## Lifecycle event contract + * + * Implementations MUST notify the substrate after each durable state + * change by calling the matching method on + * {@see WP_Agent_Workflow_Lifecycle}: + * + * - After a successful `save()` → `WP_Agent_Workflow_Lifecycle::saved( $spec )` + * - After a successful `delete()` → `WP_Agent_Workflow_Lifecycle::deleted( $id, $spec )` + * - After taking a workflow out of active scheduling + * without deleting → `disabled( $spec )` + * - After re-activating a disabled workflow → `enabled( $spec )` + * + * Subscribers (the Action Scheduler bridge, audit logs, cache + * invalidators) rely on these hooks to keep external state in sync. The + * substrate does not fire them from inside this interface because it does + * not own the storage, but the contract is mandatory: a store that skips + * the lifecycle calls will leak scheduled work on delete and miss + * scheduling on save. + * + * The lifecycle hooks intentionally live separately from + * `wp_agent_workflow_registered`, which fires when a code-defined + * workflow is added to the in-memory registry during request boot. That + * hook says "this workflow is known for this request"; the lifecycle + * hooks say "this workflow is durably active and should have scheduled + * work attached". + * * @package AgentsAPI * @since 0.103.0 */ diff --git a/src/Workflows/register-workflow-bridge-sync.php b/src/Workflows/register-workflow-bridge-sync.php new file mode 100644 index 0000000..8311eac --- /dev/null +++ b/src/Workflows/register-workflow-bridge-sync.php @@ -0,0 +1,65 @@ +get_id() ); + } +); + +add_action( + 'wp_agent_workflow_enabled', + static function ( WP_Agent_Workflow_Spec $spec ): void { + WP_Agent_Workflow_Action_Scheduler_Bridge::sync( $spec ); + } +); diff --git a/tests/agents-chat-ability-smoke.php b/tests/agents-chat-ability-smoke.php index 616dfd5..776c396 100644 --- a/tests/agents-chat-ability-smoke.php +++ b/tests/agents-chat-ability-smoke.php @@ -167,6 +167,7 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & smoke_assert( array( 'agent', 'message' ), $in['required'] ?? array(), 'input_schema_required_fields', $failures, $passes ); smoke_assert( true, isset( $in['properties']['client_context'] ), 'input_schema_has_client_context', $failures, $passes ); smoke_assert( true, isset( $in['properties']['attachments'] ), 'input_schema_has_attachments', $failures, $passes ); +smoke_assert( true, isset( $in['properties']['client_context']['properties']['sender_id'] ), 'client_context_schema_has_sender_id', $failures, $passes ); $out_schema = agents_chat_output_schema(); smoke_assert( array( 'session_id', 'reply' ), $out_schema['required'] ?? array(), 'output_schema_required_fields', $failures, $passes ); diff --git a/tests/agents-dispatch-message-ability-smoke.php b/tests/agents-dispatch-message-ability-smoke.php new file mode 100644 index 0000000..2eac219 --- /dev/null +++ b/tests/agents-dispatch-message-ability-smoke.php @@ -0,0 +1,159 @@ +code; } + public function get_error_message(): string { return $this->message; } +} + +function is_wp_error( $value ): bool { + return $value instanceof WP_Error; +} + +function current_user_can( string $cap ): bool { + unset( $cap ); + return $GLOBALS['__smoke_can'] ?? false; +} + +$GLOBALS['__smoke_filters'] = array(); + +function add_filter( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void { + unset( $accepted_args ); + $GLOBALS['__smoke_filters'][ $hook ][ $priority ][] = $cb; +} + +function apply_filters( string $hook, $value, ...$args ) { + $callbacks = $GLOBALS['__smoke_filters'][ $hook ] ?? array(); + ksort( $callbacks ); + foreach ( $callbacks as $priority_callbacks ) { + foreach ( $priority_callbacks as $cb ) { + $value = call_user_func_array( $cb, array_merge( array( $value ), $args ) ); + } + } + return $value; +} + +function add_action( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void { + add_filter( $hook, $cb, $priority, $accepted_args ); +} + +function do_action( string $hook, ...$args ): void { + $callbacks = $GLOBALS['__smoke_filters'][ $hook ] ?? array(); + ksort( $callbacks ); + foreach ( $callbacks as $priority_callbacks ) { + foreach ( $priority_callbacks as $cb ) { + call_user_func_array( $cb, $args ); + } + } +} + +function smoke_assert( $expected, $actual, string $name, array &$failures, int &$passes ): void { + if ( $expected === $actual ) { + ++$passes; + echo " PASS {$name}\n"; + return; + } + $failures[] = $name; + echo " FAIL {$name}\n"; + echo ' expected: ' . var_export( $expected, true ) . "\n"; + echo ' actual: ' . var_export( $actual, true ) . "\n"; +} + +require_once __DIR__ . '/../src/Channels/register-agents-dispatch-message-ability.php'; + +use function AgentsAPI\AI\Channels\agents_dispatch_message_dispatch; +use function AgentsAPI\AI\Channels\agents_dispatch_message_input_schema; +use function AgentsAPI\AI\Channels\agents_dispatch_message_output_schema; +use function AgentsAPI\AI\Channels\agents_dispatch_message_permission; +use function AgentsAPI\AI\Channels\register_dispatch_message_handler; +use const AgentsAPI\AI\Channels\AGENTS_DISPATCH_MESSAGE_ABILITY; + +smoke_assert( 'agents/dispatch-message', AGENTS_DISPATCH_MESSAGE_ABILITY, 'slug_is_agents_dispatch_message', $failures, $passes ); + +$dispatch_failures = array(); +add_filter( + 'agents_dispatch_message_failed', + static function ( $reason, $input ) use ( &$dispatch_failures ) { + $dispatch_failures[] = array( 'reason' => $reason, 'channel' => $input['channel'] ?? null ); + }, + 10, + 2 +); + +$result = agents_dispatch_message_dispatch( + array( + 'channel' => 'whatsapp', + 'recipient' => '+59899123456', + 'message' => 'hola', + ) +); +smoke_assert( true, $result instanceof WP_Error, 'no_handler_returns_wp_error', $failures, $passes ); +smoke_assert( 'agents_dispatch_message_no_handler', $result->get_error_code(), 'no_handler_error_code', $failures, $passes ); +smoke_assert( 'no_handler', $dispatch_failures[0]['reason'] ?? 'missing', 'no_handler_fires_observability', $failures, $passes ); +smoke_assert( 'whatsapp', $dispatch_failures[0]['channel'] ?? 'missing', 'observability_includes_channel', $failures, $passes ); + +$captured = array(); +$GLOBALS['__smoke_filters'] = array(); +register_dispatch_message_handler( + static function ( array $input ) use ( &$captured ): array { + $captured = $input; + return array( + 'sent' => true, + 'channel' => $input['channel'], + 'recipient' => $input['recipient'], + 'message_id' => 'm-1', + 'metadata' => array( 'provider' => 'stub' ), + ); + } +); + +$ok = agents_dispatch_message_dispatch( + array( + 'channel' => 'whatsapp', + 'recipient' => '+59899123456', + 'message' => 'hola', + 'conversation_id' => 'group-1', + ) +); +smoke_assert( 'hola', $captured['message'] ?? null, 'handler_receives_message', $failures, $passes ); +smoke_assert( true, $ok['sent'] ?? false, 'handler_result_returned', $failures, $passes ); +smoke_assert( 'm-1', $ok['message_id'] ?? null, 'message_id_returned', $failures, $passes ); + +$GLOBALS['__smoke_filters'] = array(); +register_dispatch_message_handler( static fn( array $input ) => 'bad' ); +$bad = agents_dispatch_message_dispatch( array( 'channel' => 'x', 'recipient' => 'y', 'message' => 'z' ) ); +smoke_assert( true, $bad instanceof WP_Error, 'invalid_result_returns_wp_error', $failures, $passes ); +smoke_assert( 'agents_dispatch_message_invalid_result', $bad->get_error_code(), 'invalid_result_error_code', $failures, $passes ); + +$GLOBALS['__smoke_can'] = false; +smoke_assert( false, agents_dispatch_message_permission( array() ), 'default_permission_blocks_non_admin', $failures, $passes ); +$GLOBALS['__smoke_can'] = true; +smoke_assert( true, agents_dispatch_message_permission( array() ), 'default_permission_allows_admin', $failures, $passes ); + +$GLOBALS['__smoke_can'] = false; +add_filter( 'agents_dispatch_message_permission', static fn() => true ); +smoke_assert( true, agents_dispatch_message_permission( array() ), 'permission_filter_widens_gate', $failures, $passes ); + +$in = agents_dispatch_message_input_schema(); +smoke_assert( array( 'channel', 'recipient', 'message' ), $in['required'] ?? array(), 'input_schema_required_fields', $failures, $passes ); +smoke_assert( true, isset( $in['properties']['attachments'] ), 'input_schema_has_attachments', $failures, $passes ); + +$out = agents_dispatch_message_output_schema(); +smoke_assert( array( 'sent', 'channel', 'recipient' ), $out['required'] ?? array(), 'output_schema_required_fields', $failures, $passes ); + +echo "Passed: {$passes}, Failed: " . count( $failures ) . "\n"; +exit( count( $failures ) > 0 ? 1 : 0 ); diff --git a/tests/channels-smoke.php b/tests/channels-smoke.php index c81a059..1f1ba53 100644 --- a/tests/channels-smoke.php +++ b/tests/channels-smoke.php @@ -177,6 +177,7 @@ protected function on_complete(): void { $this->lifecycle[] = 'complete'; } 'external_provider' => 'test-channel', 'external_conversation_id' => 'chat-A', 'external_message_id' => null, + 'sender_id' => null, 'room_kind' => null, ), ), diff --git a/tests/tool-runtime-smoke.php b/tests/tool-runtime-smoke.php index ec28dd8..a748621 100644 --- a/tests/tool-runtime-smoke.php +++ b/tests/tool-runtime-smoke.php @@ -45,14 +45,18 @@ static function () { return array( 'local/summarize' => array( - 'description' => 'Summarize text.', - 'parameters' => array( + 'description' => 'Summarize text.', + 'parameters' => array( 'type' => 'object', 'required' => array( 'text' ), 'properties' => array( 'text' => array( 'type' => 'string' ), ), ), + // Opt this tool into pulling `text` from the runtime context. Without + // this declaration, a `text` key in context never satisfies the + // required parameter — keeps required-arg sourcing auditable. + 'client_context_bindings' => array( 'text' ), ), ); } @@ -85,8 +89,37 @@ static function () { ), $tools['local/summarize'] ); -agents_api_smoke_assert_equals( 'hello', $parameters['text'], 'runtime parameters override context defaults', $failures, $passes ); -agents_api_smoke_assert_equals( 'req-123', $parameters['request_id'], 'parameter builder preserves runtime context', $failures, $passes ); +agents_api_smoke_assert_equals( 'hello', $parameters['text'], 'runtime parameters override declared context bindings', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'request_id', $parameters ), 'undeclared context keys do not leak into parameters', $failures, $passes ); + +$rename_definition = array( + 'parameters' => array( + 'type' => 'object', + 'required' => array( 'user_phone' ), + 'properties' => array( 'user_phone' => array( 'type' => 'string' ) ), + ), + 'client_context_bindings' => array( 'user_phone' => 'sender_id' ), +); +$renamed = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array(), + array( 'sender_id' => 'whatsapp:+1', 'request_id' => 'req-123' ), + $rename_definition +); +agents_api_smoke_assert_equals( 'whatsapp:+1', $renamed['user_phone'] ?? null, 'binding can rename context_key → parameter_name', $failures, $passes ); +agents_api_smoke_assert_equals( false, array_key_exists( 'sender_id', $renamed ), 'rename binding does not also expose the source key', $failures, $passes ); + +$undeclared = AgentsAPI\AI\Tools\WP_Agent_Tool_Parameters::buildParameters( + array(), + array( 'text' => 'context text' ), + array( + 'parameters' => array( + 'type' => 'object', + 'required' => array( 'text' ), + ), + // No client_context_bindings declared — context must NOT auto-satisfy. + ) +); +agents_api_smoke_assert_equals( array(), $undeclared, 'no bindings ⇒ no context auto-population', $failures, $passes ); $tool_call = AgentsAPI\AI\Tools\WP_Agent_Tool_Call::normalize( array( @@ -101,12 +134,15 @@ static function () { public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definition, array $context = array() ): array { unset( $tool_definition ); + // `text` is auditable runtime input (declared parameter); `request_id` and + // `agent_id` are ambient context that the adapter is free to consult + // without the tool declaration having to bind them as parameters. return array( 'success' => true, 'result' => array( 'summary' => strtoupper( (string) $tool_call['parameters']['text'] ), - 'request_id' => $tool_call['parameters']['request_id'], - 'agent_id' => $context['agent_id'], + 'request_id' => $context['request_id'] ?? null, + 'agent_id' => $context['agent_id'] ?? null, ), ); } @@ -117,6 +153,38 @@ public function executeWP_Agent_Tool_Call( array $tool_call, array $tool_definit agents_api_smoke_assert_equals( false, $missing['success'], 'execution returns normalized error for missing parameters', $failures, $passes ); agents_api_smoke_assert_equals( array( 'text' ), $missing['metadata']['missing_parameters'], 'execution error includes missing parameter metadata', $failures, $passes ); +$from_context = $executor->executeTool( + 'local/summarize', + array(), + $tools, + $adapter, + array( + 'agent_id' => 'writer', + 'request_id' => 'req-123', + 'text' => 'context text', + ) +); +agents_api_smoke_assert_equals( true, $from_context['success'], 'declared context binding satisfies required parameter', $failures, $passes ); +agents_api_smoke_assert_equals( 'CONTEXT TEXT', $from_context['result']['summary'], 'bound context value reaches adapter through parameters', $failures, $passes ); + +// Sanity: an undeclared context key (request_id) must not silently satisfy a +// required parameter even when its name matches. +$tools_with_request_required = $tools; +$tools_with_request_required['local/summarize']['parameters']['required'] = array( 'text', 'request_id' ); +$tools_with_request_required['local/summarize']['parameters']['properties'] = array( + 'text' => array( 'type' => 'string' ), + 'request_id' => array( 'type' => 'string' ), +); +$undeclared_required = $executor->executeTool( + 'local/summarize', + array( 'text' => 'hello' ), + $tools_with_request_required, + $adapter, + array( 'request_id' => 'req-undeclared' ) +); +agents_api_smoke_assert_equals( false, $undeclared_required['success'], 'undeclared context key cannot satisfy a required parameter', $failures, $passes ); +agents_api_smoke_assert_equals( array( 'request_id' ), $undeclared_required['metadata']['missing_parameters'], 'missing-parameter error names the undeclared slot', $failures, $passes ); + $result = $executor->executeTool( 'local/summarize', array( 'text' => 'hello' ), diff --git a/tests/workflow-bindings-smoke.php b/tests/workflow-bindings-smoke.php index cdfb8e1..ed2cdf2 100644 --- a/tests/workflow-bindings-smoke.php +++ b/tests/workflow-bindings-smoke.php @@ -45,6 +45,12 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & ), ), ), + 'vars' => array( + 'item' => array( + 'id' => 7, + 'title' => 'Uruguay', + ), + ), ); // Whole-string atomic substitution preserves type. @@ -172,5 +178,21 @@ function smoke_assert( $expected, $actual, string $name, array &$failures, int & $passes ); +smoke_assert( + 7, + WP_Agent_Workflow_Bindings::expand( '${vars.item.id}', $context ), + 'vars root resolves scoped runtime values', + $failures, + $passes +); + +smoke_assert( + 'team Uruguay', + WP_Agent_Workflow_Bindings::expand( 'team ${vars.item.title}', $context ), + 'mixed vars template stringifies inline values', + $failures, + $passes +); + echo "Passed: {$passes}, Failed: " . count( $failures ) . "\n"; exit( count( $failures ) > 0 ? 1 : 0 ); diff --git a/tests/workflow-lifecycle-smoke.php b/tests/workflow-lifecycle-smoke.php new file mode 100644 index 0000000..082b433 --- /dev/null +++ b/tests/workflow-lifecycle-smoke.php @@ -0,0 +1,200 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data() { return $this->data; } +} + +function is_wp_error( $value ): bool { + return $value instanceof WP_Error; +} + +$GLOBALS['__hooks'] = array(); + +function add_action( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void { + $GLOBALS['__hooks'][ $hook ][ $priority ][] = array( 'cb' => $cb, 'args' => $accepted_args ); +} + +function do_action( string $hook, ...$args ): void { + $buckets = $GLOBALS['__hooks'][ $hook ] ?? array(); + ksort( $buckets ); + foreach ( $buckets as $bucket ) { + foreach ( $bucket as $entry ) { + call_user_func_array( $entry['cb'], array_slice( $args, 0, (int) $entry['args'] ) ); + } + } +} + +function add_filter( string $hook, callable $cb, int $priority = 10, int $accepted_args = 1 ): void { + add_action( $hook, $cb, $priority, $accepted_args ); +} + +function apply_filters( string $hook, $value, ...$args ) { + $buckets = $GLOBALS['__hooks'][ $hook ] ?? array(); + ksort( $buckets ); + foreach ( $buckets as $bucket ) { + foreach ( $bucket as $entry ) { + $value = call_user_func_array( $entry['cb'], array_slice( array_merge( array( $value ), $args ), 0, (int) $entry['args'] ) ); + } + } + return $value; +} + +function smoke_assert( $expected, $actual, string $name, array &$failures, int &$passes ): void { + if ( $expected === $actual ) { + echo " PASS {$name}\n"; + ++$passes; + return; + } + $failures[] = $name; + echo " FAIL {$name}\n"; + echo " expected: " . var_export( $expected, true ) . "\n"; + echo " actual: " . var_export( $actual, true ) . "\n"; +} + +// Stub out Action Scheduler so the bridge thinks it's available and records +// every call we care about. +$GLOBALS['__as_calls'] = array(); +function as_schedule_recurring_action( int $start, int $interval, string $hook, array $args = array(), string $group = '' ): int { + $GLOBALS['__as_calls'][] = array( 'op' => 'schedule_recurring', 'interval' => $interval, 'hook' => $hook, 'args' => $args, 'group' => $group ); + return 1; +} +function as_schedule_cron_action( int $start, string $schedule, string $hook, array $args = array(), string $group = '' ): int { + $GLOBALS['__as_calls'][] = array( 'op' => 'schedule_cron', 'expression' => $schedule, 'hook' => $hook, 'args' => $args, 'group' => $group ); + return 1; +} +function as_unschedule_all_actions( string $hook, array $args = array(), string $group = '' ): int { + $GLOBALS['__as_calls'][] = array( 'op' => 'unschedule', 'hook' => $hook, 'args' => $args, 'group' => $group ); + return 0; +} + +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec-validator.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-spec.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-action-scheduler-bridge.php'; +require_once __DIR__ . '/../src/Workflows/class-wp-agent-workflow-lifecycle.php'; +require_once __DIR__ . '/../src/Workflows/register-workflow-bridge-sync.php'; + +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Action_Scheduler_Bridge; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Lifecycle; +use AgentsAPI\AI\Workflows\WP_Agent_Workflow_Spec; + +function make_spec( string $id, array $triggers = array() ): WP_Agent_Workflow_Spec { + $raw = array( + 'id' => $id, + 'version' => '1.0.0', + 'steps' => array( + array( 'id' => 'noop', 'type' => 'ability', 'ability' => 'demo/noop' ), + ), + 'triggers' => $triggers, + ); + return WP_Agent_Workflow_Spec::from_array( $raw ); +} + +// ─── 1. Lifecycle methods fire canonical hooks ────────────────────────── + +$captured = array(); +add_action( 'wp_agent_workflow_saved', static function ( WP_Agent_Workflow_Spec $spec ) use ( &$captured ) { + $captured['saved'] = $spec->get_id(); +} ); +add_action( 'wp_agent_workflow_deleted', static function ( string $id, $spec ) use ( &$captured ) { + $captured['deleted'] = array( 'id' => $id, 'spec_id' => $spec ? $spec->get_id() : null ); +}, 10, 2 ); +add_action( 'wp_agent_workflow_disabled', static function ( WP_Agent_Workflow_Spec $spec ) use ( &$captured ) { + $captured['disabled'] = $spec->get_id(); +} ); +add_action( 'wp_agent_workflow_enabled', static function ( WP_Agent_Workflow_Spec $spec ) use ( &$captured ) { + $captured['enabled'] = $spec->get_id(); +} ); + +$spec = make_spec( 'demo/lifecycle' ); + +WP_Agent_Workflow_Lifecycle::saved( $spec ); +smoke_assert( 'demo/lifecycle', $captured['saved'] ?? null, 'saved hook fires with spec', $failures, $passes ); + +WP_Agent_Workflow_Lifecycle::deleted( 'demo/lifecycle', $spec ); +smoke_assert( 'demo/lifecycle', $captured['deleted']['id'] ?? null, 'deleted hook fires with id', $failures, $passes ); +smoke_assert( 'demo/lifecycle', $captured['deleted']['spec_id'] ?? null, 'deleted hook forwards spec', $failures, $passes ); + +WP_Agent_Workflow_Lifecycle::deleted( 'demo/lifecycle-no-spec' ); +smoke_assert( 'demo/lifecycle-no-spec', $captured['deleted']['id'] ?? null, 'deleted hook works when spec is null', $failures, $passes ); +smoke_assert( true, array_key_exists( 'spec_id', $captured['deleted'] ) && null === $captured['deleted']['spec_id'], 'deleted hook passes null spec through', $failures, $passes ); + +WP_Agent_Workflow_Lifecycle::disabled( $spec ); +smoke_assert( 'demo/lifecycle', $captured['disabled'] ?? null, 'disabled hook fires with spec', $failures, $passes ); + +WP_Agent_Workflow_Lifecycle::enabled( $spec ); +smoke_assert( 'demo/lifecycle', $captured['enabled'] ?? null, 'enabled hook fires with spec', $failures, $passes ); + +// ─── 2. Bridge sync subscriber dispatches to the AS bridge ────────────── + +$cron_spec = make_spec( 'demo/cron', array( array( 'type' => 'cron', 'interval' => 300 ) ) ); + +$GLOBALS['__as_calls'] = array(); +WP_Agent_Workflow_Lifecycle::saved( $cron_spec ); + +$ops = array_column( $GLOBALS['__as_calls'], 'op' ); +smoke_assert( array( 'unschedule', 'unschedule', 'schedule_recurring' ), $ops, 'saved → sync unschedules then registers', $failures, $passes ); +smoke_assert( 300, $GLOBALS['__as_calls'][2]['interval'] ?? null, 'sync registers the new interval', $failures, $passes ); + +$GLOBALS['__as_calls'] = array(); +WP_Agent_Workflow_Lifecycle::deleted( 'demo/cron', $cron_spec ); +$ops = array_column( $GLOBALS['__as_calls'], 'op' ); +smoke_assert( array( 'unschedule' ), $ops, 'deleted → unregister tears down schedule', $failures, $passes ); +smoke_assert( array( 'workflow_id' => 'demo/cron' ), $GLOBALS['__as_calls'][0]['args'], 'deleted unschedule keys on workflow_id', $failures, $passes ); + +$GLOBALS['__as_calls'] = array(); +WP_Agent_Workflow_Lifecycle::disabled( $cron_spec ); +smoke_assert( array( 'unschedule' ), array_column( $GLOBALS['__as_calls'], 'op' ), 'disabled → unregister tears down schedule', $failures, $passes ); + +$GLOBALS['__as_calls'] = array(); +WP_Agent_Workflow_Lifecycle::enabled( $cron_spec ); +$ops = array_column( $GLOBALS['__as_calls'], 'op' ); +smoke_assert( array( 'unschedule', 'unschedule', 'schedule_recurring' ), $ops, 'enabled → sync re-registers', $failures, $passes ); + +// ─── 3. sync() unschedules even when new spec has no cron triggers ────── + +$no_cron_spec = make_spec( 'demo/cron', array( array( 'type' => 'on_demand' ) ) ); +$GLOBALS['__as_calls'] = array(); +$count = WP_Agent_Workflow_Action_Scheduler_Bridge::sync( $no_cron_spec ); +smoke_assert( 0, $count, 'sync returns zero registrations when no cron triggers', $failures, $passes ); +smoke_assert( array( 'unschedule' ), array_column( $GLOBALS['__as_calls'], 'op' ), 'sync still unschedules stale actions', $failures, $passes ); + +// ─── 4. wp_agent_workflow_registered (in-memory) keeps register() semantics ─ + +$GLOBALS['__as_calls'] = array(); +do_action( 'wp_agent_workflow_registered', $cron_spec ); +$ops = array_column( $GLOBALS['__as_calls'], 'op' ); +smoke_assert( array( 'unschedule', 'schedule_recurring' ), $ops, 'registered hook still routes through register()', $failures, $passes ); + +// ─── Summary ──────────────────────────────────────────────────────────── + +if ( ! empty( $failures ) ) { + echo "\nFAILED " . count( $failures ) . " of " . ( count( $failures ) + $passes ) . "\n"; + exit( 1 ); +} + +echo "OK {$passes} passed\n"; diff --git a/tests/workflow-runner-smoke.php b/tests/workflow-runner-smoke.php index fc69d68..ee178de 100644 --- a/tests/workflow-runner-smoke.php +++ b/tests/workflow-runner-smoke.php @@ -119,6 +119,14 @@ static function ( array $input ): array { return array( 'wrapped' => '<<' . (string) ( $input['inner'] ?? '' ) . '>>' ); } ); +$GLOBALS['__abilities']['demo/score-item'] = new Stub_Ability( + static function ( array $input ): array { + return array( + 'id' => (int) ( $input['id'] ?? 0 ), + 'points' => (int) ( $input['points'] ?? 0 ), + ); + } +); $spec = WP_Agent_Workflow_Spec::from_array( array( @@ -268,5 +276,50 @@ public function recent( array $args = array() ): array { return array(); } smoke_assert( 'update', $tracker->events[1]['op'] ?? '', 'recorder sees update second', $failures, $passes ); smoke_assert( WP_Agent_Workflow_Run_Result::STATUS_FAILED, $tracker->events[1]['status'] ?? '', 'update flips status to FAILED', $failures, $passes ); +// ─── foreach step iterates over bound arrays with scoped vars ──────── + +$foreach_spec = WP_Agent_Workflow_Spec::from_array( + array( + 'id' => 'demo/foreach', + 'inputs' => array( + 'items' => array( 'type' => 'array', 'required' => true ), + ), + 'steps' => array( + array( + 'id' => 'score_each', + 'type' => 'foreach', + 'items' => '${inputs.items}', + 'as' => 'prediction', + 'steps' => array( + array( + 'id' => 'score', + 'type' => 'ability', + 'ability' => 'demo/score-item', + 'args' => array( + 'id' => '${vars.prediction.id}', + 'points' => '${vars.prediction.points}', + ), + ), + ), + ), + ), + ) +); + +$result8 = ( new WP_Agent_Workflow_Runner( null ) )->run( + $foreach_spec, + array( + 'items' => array( + array( 'id' => 10, 'points' => 5 ), + array( 'id' => 11, 'points' => 1 ), + ), + ) +); + +smoke_assert( WP_Agent_Workflow_Run_Result::STATUS_SUCCEEDED, $result8->get_status(), 'foreach run succeeds', $failures, $passes ); +smoke_assert( 2, $result8->get_output()['last']['count'] ?? 0, 'foreach reports iteration count', $failures, $passes ); +smoke_assert( 10, $result8->get_output()['last']['iterations'][0]['last']['id'] ?? 0, 'foreach first iteration receives scoped item', $failures, $passes ); +smoke_assert( 1, $result8->get_output()['last']['iterations'][1]['last']['points'] ?? 0, 'foreach second iteration receives scoped item', $failures, $passes ); + echo "Passed: {$passes}, Failed: " . count( $failures ) . "\n"; exit( count( $failures ) > 0 ? 1 : 0 );