diff --git a/composer.json b/composer.json index 85d2640..91a4771 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,12 @@ { "name": "lezama/openclawp", - "description": "Generic chat-with-an-agent WordPress plugin built on Automattic/agents-api.", + "description": "Generic chat-with-an-agent WordPress plugin built on wordpress/agents-api.", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": { "php": ">=8.1", - "automattic/agents-api": "dev-main", - "woocommerce/action-scheduler": "^3.9" + "woocommerce/action-scheduler": "^3.9", + "wordpress/agents-api": "dev-main" }, "require-dev": { "phpunit/phpunit": "^9.6", @@ -37,9 +37,9 @@ "sort-packages": true }, "scripts": { - "test": "phpunit", + "test": "php -d auto_prepend_file=tests/phpunit-prepend.php vendor/phpunit/phpunit/phpunit", "test:smoke": "php tests/smoke.php", - "test:assembly": "phpunit --testsuite assembly", + "test:assembly": "php -d auto_prepend_file=tests/phpunit-prepend.php vendor/phpunit/phpunit/phpunit --testsuite assembly", "lint:php": "phpcs", "lint:php:fix": "phpcbf", "analyse": "phpstan analyse" diff --git a/composer.lock b/composer.lock index 6af3f4d..a4f8bee 100644 --- a/composer.lock +++ b/composer.lock @@ -4,70 +4,148 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c266c01738424e314a3dbcc52f54998b", + "content-hash": "2d0eae45ca434fb83de85de5703af42d", "packages": [ { - "name": "automattic/agents-api", + "name": "woocommerce/action-scheduler", + "version": "3.9.3", + "source": { + "type": "git", + "url": "https://github.com/woocommerce/action-scheduler.git", + "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c", + "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.5", + "woocommerce/woocommerce-sniffs": "0.1.0", + "wp-cli/wp-cli": "~2.5.0", + "yoast/phpunit-polyfills": "^2.0" + }, + "type": "wordpress-plugin", + "extra": { + "scripts-description": { + "test": "Run unit tests", + "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer", + "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "description": "Action Scheduler for WordPress and WooCommerce", + "homepage": "https://actionscheduler.org/", + "support": { + "issues": "https://github.com/woocommerce/action-scheduler/issues", + "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3" + }, + "time": "2025-07-15T09:32:30+00:00" + }, + { + "name": "wordpress/agents-api", "version": "dev-main", "source": { "type": "git", "url": "https://github.com/Automattic/agents-api.git", - "reference": "cec9932e50a2d842b37e0ab1b5b1ec33a1d88562" + "reference": "600475b7ad2113b264d43fa67c1eda7af593284c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Automattic/agents-api/zipball/cec9932e50a2d842b37e0ab1b5b1ec33a1d88562", - "reference": "cec9932e50a2d842b37e0ab1b5b1ec33a1d88562", + "url": "https://api.github.com/repos/Automattic/agents-api/zipball/600475b7ad2113b264d43fa67c1eda7af593284c", + "reference": "600475b7ad2113b264d43fa67c1eda7af593284c", "shasum": "" }, "require": { "php": ">=8.1" }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "szepeviktor/phpstan-wordpress": "^2.0" + }, "suggest": { "woocommerce/action-scheduler": "Required to actually run cron-triggered workflows. The substrate detects Action Scheduler at runtime and no-ops cleanly when absent; install this package (or any plugin that ships it, like WooCommerce) to enable scheduled workflow runs." }, "default-branch": true, "type": "wordpress-plugin", + "autoload": { + "files": [ + "agents-api.php" + ] + }, "scripts": { - "test": [ + "phpstan": [ + "phpstan analyse --no-progress --memory-limit=2G" + ], + "smoke": [ "php tests/bootstrap-smoke.php", "php tests/message-envelope-smoke.php", + "php tests/message-coalesce-smoke.php", "php tests/registry-smoke.php", + "php tests/installed-agent-materialization-smoke.php", + "php tests/registered-agent-materialization-adapter-smoke.php", + "php tests/runtime-agent-bundle-importer-smoke.php", + "php tests/package-lifecycle-smoke.php", + "php tests/package-capability-contract-smoke.php", + "php tests/package-adoption-orchestration-smoke.php", "php tests/execution-principal-smoke.php", "php tests/effective-agent-resolver-smoke.php", "php tests/caller-context-smoke.php", "php tests/authorization-smoke.php", "php tests/agents-access-ability-smoke.php", "php tests/agents-conversation-session-abilities-smoke.php", + "php tests/conversation-session-store-contract-smoke.php", "php tests/action-policy-values-smoke.php", "php tests/consent-policy-smoke.php", "php tests/tool-policy-contracts-smoke.php", + "php tests/tool-source-registry-smoke.php", + "php tests/runtime-tool-policy-smoke.php", + "php tests/tool-tier-resolver-smoke.php", "php tests/action-policy-resolver-smoke.php", + "php tests/ability-meta-abilities-smoke.php", "php tests/tool-runtime-smoke.php", "php tests/pending-action-store-contract-smoke.php", "php tests/approval-resolver-contract-smoke.php", "php tests/pending-action-abilities-smoke.php", "php tests/identity-smoke.php", "php tests/memory-metadata-contract-smoke.php", + "php tests/memory-store-resolver-smoke.php", + "php tests/ability-lifecycle-bridge-smoke.php", + "php tests/pre-execute-approval-smoke.php", "php tests/approval-action-value-shape-smoke.php", "php tests/workspace-scope-smoke.php", "php tests/compaction-item-smoke.php", "php tests/compaction-conservation-smoke.php", "php tests/conversation-runner-contracts-smoke.php", "php tests/conversation-transcript-lock-smoke.php", + "php tests/cpt-conversation-store-smoke.php", "php tests/conversation-compaction-smoke.php", "php tests/tool-pair-validator-smoke.php", "php tests/markdown-section-compaction-smoke.php", + "php tests/spin-detector-smoke.php", + "php tests/identical-failure-tracker-smoke.php", + "php tests/tool-result-truncator-smoke.php", "php tests/context-registry-smoke.php", "php tests/conversation-loop-smoke.php", + "php tests/provider-turn-adapter-smoke.php", "php tests/conversation-loop-tool-execution-smoke.php", "php tests/conversation-loop-completion-policy-smoke.php", "php tests/conversation-loop-transcript-persister-smoke.php", "php tests/conversation-loop-events-smoke.php", + "php tests/conversation-loop-interrupt-source-smoke.php", "php tests/iteration-budget-smoke.php", "php tests/conversation-loop-budgets-smoke.php", "php tests/channels-smoke.php", + "php tests/chat-run-control-smoke.php", "php tests/frontend-chat-rest-smoke.php", + "php tests/agents-chat-jsonrpc-route-smoke.php", "php tests/agents-dispatch-message-ability-smoke.php", "php tests/webhook-safety-smoke.php", "php tests/remote-bridge-smoke.php", @@ -79,8 +157,12 @@ "php tests/workflow-lifecycle-smoke.php", "php tests/agents-workflow-ability-smoke.php", "php tests/routine-smoke.php", + "php tests/event-trigger-smoke.php", "php tests/subagents-smoke.php", "php tests/no-product-imports-smoke.php" + ], + "test": [ + "@smoke" ] }, "license": [ @@ -92,50 +174,7 @@ "issues": "https://github.com/Automattic/agents-api/issues", "source": "https://github.com/Automattic/agents-api" }, - "time": "2026-05-16T18:33:06+00:00" - }, - { - "name": "woocommerce/action-scheduler", - "version": "3.9.3", - "source": { - "type": "git", - "url": "https://github.com/woocommerce/action-scheduler.git", - "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/woocommerce/action-scheduler/zipball/c58cdbab17651303d406cd3b22cf9d75c71c986c", - "reference": "c58cdbab17651303d406cd3b22cf9d75c71c986c", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "require-dev": { - "phpunit/phpunit": "^8.5", - "woocommerce/woocommerce-sniffs": "0.1.0", - "wp-cli/wp-cli": "~2.5.0", - "yoast/phpunit-polyfills": "^2.0" - }, - "type": "wordpress-plugin", - "extra": { - "scripts-description": { - "test": "Run unit tests", - "phpcs": "Analyze code against the WordPress coding standards with PHP_CodeSniffer", - "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-3.0-or-later" - ], - "description": "Action Scheduler for WordPress and WooCommerce", - "homepage": "https://actionscheduler.org/", - "support": { - "issues": "https://github.com/woocommerce/action-scheduler/issues", - "source": "https://github.com/woocommerce/action-scheduler/tree/3.9.3" - }, - "time": "2025-07-15T09:32:30+00:00" + "time": "2026-06-03T20:33:36+00:00" } ], "packages-dev": [ @@ -2817,7 +2856,7 @@ "aliases": [], "minimum-stability": "dev", "stability-flags": { - "automattic/agents-api": 20 + "wordpress/agents-api": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/docs/release.md b/docs/release.md index 947dffc..058b5a5 100644 --- a/docs/release.md +++ b/docs/release.md @@ -11,7 +11,7 @@ Use this checklist before tagging or publishing a release package. main plugin header. 5. Do not add `Requires Plugins: agents-api` unless `agents-api` is published under that exact WordPress.org slug; core can load the Composer dependency - itself from `vendor/automattic/agents-api/agents-api.php`. + itself from `vendor/wordpress/agents-api/agents-api.php`. 6. Run `composer update --lock` when dependency constraints change and commit the resulting `composer.lock` so `dev-main` dependencies stay pinned. 7. While WordPress 7.0 is still pre-release, keep the README setup commands @@ -51,7 +51,7 @@ For the default local model path, also verify a tool-using chat prompt in Composer manifests, npm manifests, or release-only tooling files. 6. Install the generated ZIP on a clean WordPress 7.0+ site with the companion `agents-api` plugin inactive, then confirm `AGENTS_API_PLUGIN_FILE` points to - `openclawp/vendor/automattic/agents-api/agents-api.php`. + `openclawp/vendor/wordpress/agents-api/agents-api.php`. 7. Confirm `readme.txt` includes dependency, external-services, changelog, and upgrade-notice sections. diff --git a/includes/class-openclawp-bootstrap.php b/includes/class-openclawp-bootstrap.php index 7e67b22..21444b6 100644 --- a/includes/class-openclawp-bootstrap.php +++ b/includes/class-openclawp-bootstrap.php @@ -206,7 +206,7 @@ public static function render_missing_dependencies_notice(): void { $missing = array(); if ( ! defined( 'AGENTS_API_LOADED' ) ) { - $missing[] = 'automattic/agents-api'; + $missing[] = 'wordpress/agents-api'; } if ( ! function_exists( 'wp_ai_client_prompt' ) ) { $missing[] = 'WordPress 7.0+ (provides wp_ai_client_prompt())'; diff --git a/includes/class-openclawp-message-adapter.php b/includes/class-openclawp-message-adapter.php index 7643d1c..02086c5 100644 --- a/includes/class-openclawp-message-adapter.php +++ b/includes/class-openclawp-message-adapter.php @@ -21,8 +21,12 @@ final class OpenclaWP_Message_Adapter { * Convert a transcript array into a list of WP AI Client `Message` DTOs. * * Roles map: `user` → `MessageRoleEnum::user()`, `assistant`/`model` → - * `MessageRoleEnum::model()`. System and tool messages are dropped — the - * loop's tool-mediation path isn't wired through this adapter yet. + * `MessageRoleEnum::model()`. Tool-mediation turns round-trip as native + * function-call parts so the model sees its own calls and their results + * (without this, a tool-using turn re-issues the same call every turn + * because the result never reaches the model): + * - `tool_call` → model message carrying a `FunctionCall` part. + * - `tool_result` → user message carrying a `FunctionResponse` part. * Returns the original transcript unchanged if WP AI Client is missing. * * @param array> $messages Transcript messages. @@ -33,32 +37,92 @@ public static function to_ai_client_messages( array $messages ): array { return $messages; } + $user_role = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(); + $model_role = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model(); + $out = array(); foreach ( $messages as $message ) { + $type = (string) ( $message['type'] ?? 'text' ); + + if ( 'tool_call' === $type ) { + $part = self::tool_call_part( $message ); + if ( null !== $part ) { + $out[] = new \WordPress\AiClient\Messages\DTO\Message( $model_role, array( $part ) ); + } + continue; + } + + if ( 'tool_result' === $type ) { + $part = self::tool_result_part( $message ); + if ( null !== $part ) { + $out[] = new \WordPress\AiClient\Messages\DTO\Message( $user_role, array( $part ) ); + } + continue; + } + $role = (string) ( $message['role'] ?? '' ); $text = self::extract_text( $message['content'] ?? '' ); - if ( '' === $text ) { continue; } if ( 'user' === $role ) { - $role_enum = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(); + $out[] = new \WordPress\AiClient\Messages\DTO\Message( $user_role, array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) ); } elseif ( 'assistant' === $role || 'model' === $role ) { - $role_enum = \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model(); - } else { - continue; + $out[] = new \WordPress\AiClient\Messages\DTO\Message( $model_role, array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) ); } - - $out[] = new \WordPress\AiClient\Messages\DTO\Message( - $role_enum, - array( new \WordPress\AiClient\Messages\DTO\MessagePart( $text ) ) - ); } return $out; } + /** + * Build a `MessagePart` wrapping the `FunctionCall` for a stored `tool_call` + * transcript message. The function name is mapped back to the provider-safe + * form the model originally used (the stored name carries the `client/` + * loop prefix). Returns null when the DTO is unavailable or the name is empty. + * + * @param array $message Stored tool_call message. + * @return object|null + */ + private static function tool_call_part( array $message ) { + if ( ! class_exists( '\\WordPress\\AiClient\\Tools\\DTO\\FunctionCall' ) ) { + return null; + } + $payload = is_array( $message['payload'] ?? null ) ? $message['payload'] : array(); + $meta = is_array( $message['metadata'] ?? null ) ? $message['metadata'] : array(); + $name = OpenclaWP_Tools_Resolver::provider_name( (string) ( $payload['tool_name'] ?? '' ) ); + if ( '' === $name ) { + return null; + } + $id = (string) ( $meta['tool_call_id'] ?? '' ); + $args = is_array( $payload['parameters'] ?? null ) ? $payload['parameters'] : array(); + + $call = new \WordPress\AiClient\Tools\DTO\FunctionCall( '' !== $id ? $id : null, $name, $args ); + return new \WordPress\AiClient\Messages\DTO\MessagePart( $call ); + } + + /** + * Build a `MessagePart` wrapping the `FunctionResponse` for a stored + * `tool_result` transcript message, matched to its call by `tool_call_id`. + * + * @param array $message Stored tool_result message. + * @return object|null + */ + private static function tool_result_part( array $message ) { + if ( ! class_exists( '\\WordPress\\AiClient\\Tools\\DTO\\FunctionResponse' ) ) { + return null; + } + $payload = is_array( $message['payload'] ?? null ) ? $message['payload'] : array(); + $meta = is_array( $message['metadata'] ?? null ) ? $message['metadata'] : array(); + $name = OpenclaWP_Tools_Resolver::provider_name( (string) ( $payload['tool_name'] ?? '' ) ); + $id = (string) ( $meta['tool_call_id'] ?? '' ); + $response = array_key_exists( 'result', $payload ) ? $payload['result'] : ( $message['content'] ?? '' ); + + $resp = new \WordPress\AiClient\Tools\DTO\FunctionResponse( '' !== $id ? $id : null, '' !== $name ? $name : null, $response ); + return new \WordPress\AiClient\Messages\DTO\MessagePart( $resp ); + } + /** * Walk the transcript backwards and return the most recent assistant text. * diff --git a/includes/class-openclawp-runner.php b/includes/class-openclawp-runner.php index 32c6efb..da08001 100644 --- a/includes/class-openclawp-runner.php +++ b/includes/class-openclawp-runner.php @@ -577,8 +577,12 @@ private static function extract_tool_calls( $result ): array { continue; } + // The model returns the provider-safe function name (no slash). Map it + // to the loop-facing `client/` form so the conversation loop's + // mediation matches it against the tool declarations and the executor's + // name map (both keyed on the loop name). {@see OpenclaWP_Tools_Resolver::loop_name()}. $out[] = array( - 'name' => $name, + 'name' => OpenclaWP_Tools_Resolver::loop_name( $name ), 'parameters' => is_array( $args ) ? $args : array(), ); } diff --git a/includes/class-openclawp-tool-discovery.php b/includes/class-openclawp-tool-discovery.php index 509c65b..b780ac8 100644 --- a/includes/class-openclawp-tool-discovery.php +++ b/includes/class-openclawp-tool-discovery.php @@ -190,22 +190,24 @@ public static function execute_tool( array $args ) { public static function meta_tool_resolver_payload( array $catalog_tools = array() ): array { $list_name = OpenclaWP_Tools_Resolver::sanitize_name( self::LIST_ABILITY ); $execute_name = OpenclaWP_Tools_Resolver::sanitize_name( self::EXECUTE_ABILITY ); + $list_loop = OpenclaWP_Tools_Resolver::loop_name( $list_name ); + $execute_loop = OpenclaWP_Tools_Resolver::loop_name( $execute_name ); $list_params = self::list_tools_input_schema(); $execute_params = self::execute_tool_input_schema(); $declarations = array( - $list_name => array( - 'name' => $list_name, - 'source' => 'openclawp', + $list_loop => array( + 'name' => $list_loop, + 'source' => OpenclaWP_Tools_Resolver::TOOL_SOURCE, 'description' => self::list_tools_description(), 'parameters' => $list_params, 'executor' => 'client', 'scope' => 'run', ), - $execute_name => array( - 'name' => $execute_name, - 'source' => 'openclawp', + $execute_loop => array( + 'name' => $execute_loop, + 'source' => OpenclaWP_Tools_Resolver::TOOL_SOURCE, 'description' => self::execute_tool_description(), 'parameters' => $execute_params, 'executor' => 'client', @@ -231,8 +233,8 @@ public static function meta_tool_resolver_payload( array $catalog_tools = array( 'declarations' => $declarations, 'declarations_for_provider' => $provider_decls, 'name_to_ability' => array( - $list_name => self::LIST_ABILITY, - $execute_name => self::EXECUTE_ABILITY, + $list_loop => self::LIST_ABILITY, + $execute_loop => self::EXECUTE_ABILITY, ), 'catalog_tools' => array_values( array_filter( array_map( 'strval', $catalog_tools ) ) ), ); diff --git a/includes/class-openclawp-tools-resolver.php b/includes/class-openclawp-tools-resolver.php index 3d9af82..1e5c57a 100644 --- a/includes/class-openclawp-tools-resolver.php +++ b/includes/class-openclawp-tools-resolver.php @@ -30,6 +30,44 @@ final class OpenclaWP_Tools_Resolver { + /** + * Source segment for client tool declarations. The agents-api conversation + * loop validates client tool names against `^client/[a-z][a-z0-9_-]*$` and + * derives the declaration's source from the segment before the slash, which + * MUST equal `client` (see WP_Agent_Tool_Declaration::validate()). Provider + * APIs reject `/` in function names, so the name the model sees stays the + * sanitized `__` form; the loop-facing declaration + executor key it as + * `client/`. {@see self::loop_name()}. + */ + public const TOOL_SOURCE = 'client'; + + /** + * Map a provider-safe declaration name (what the model sees, e.g. + * `openclawp__get-recent-posts`) to the loop-facing name the agents-api + * conversation loop validates and matches tool calls against + * (`client/openclawp__get-recent-posts`). Idempotent. + */ + public static function loop_name( string $declared_name ): string { + $prefix = self::TOOL_SOURCE . '/'; + if ( 0 === strpos( $declared_name, $prefix ) ) { + return $declared_name; + } + return $prefix . $declared_name; + } + + /** + * Inverse of {@see self::loop_name()}: strip the `client/` prefix off a + * loop-facing tool name to recover the provider-safe name the model used + * (and expects back in FunctionCall / FunctionResponse parts). Idempotent. + */ + public static function provider_name( string $loop_name ): string { + $prefix = self::TOOL_SOURCE . '/'; + if ( 0 === strpos( $loop_name, $prefix ) ) { + return substr( $loop_name, strlen( $prefix ) ); + } + return $loop_name; + } + /** * @return array{ * declarations: array, @@ -82,13 +120,14 @@ public static function for_agent( WP_Agent $agent ): array { } $declared_name = self::sanitize_name( $ability_name ); + $loop_name = self::loop_name( $declared_name ); $description = method_exists( $ability, 'get_description' ) ? (string) $ability->get_description() : ''; $input_schema = method_exists( $ability, 'get_input_schema' ) ? $ability->get_input_schema() : null; $parameters = is_array( $input_schema ) ? $input_schema : array( 'type' => 'object', 'properties' => array() ); - $declarations[ $declared_name ] = array( - 'name' => $declared_name, - 'source' => 'openclawp', + $declarations[ $loop_name ] = array( + 'name' => $loop_name, + 'source' => self::TOOL_SOURCE, 'description' => $description, 'parameters' => $parameters, 'executor' => 'client', @@ -101,7 +140,7 @@ public static function for_agent( WP_Agent $agent ): array { $parameters ); - $name_to_abil[ $declared_name ] = $ability_name; + $name_to_abil[ $loop_name ] = $ability_name; } } @@ -130,6 +169,7 @@ public static function for_agent( WP_Agent $agent ): array { } $declared_name = self::sanitize_name( 'delegate-to-' . $subagent_slug ); + $loop_name = self::loop_name( $declared_name ); $label = method_exists( $subagent, 'get_label' ) ? (string) $subagent->get_label() : $subagent_slug; $bio = method_exists( $subagent, 'get_description' ) ? (string) $subagent->get_description() : ''; $description = sprintf( @@ -138,9 +178,9 @@ public static function for_agent( WP_Agent $agent ): array { '' === $bio ? '(no description provided)' : $bio ); - $declarations[ $declared_name ] = array( - 'name' => $declared_name, - 'source' => 'openclawp', + $declarations[ $loop_name ] = array( + 'name' => $loop_name, + 'source' => self::TOOL_SOURCE, 'description' => $description, 'parameters' => $delegate_parameters, 'executor' => 'client', @@ -153,7 +193,7 @@ public static function for_agent( WP_Agent $agent ): array { $delegate_parameters ); - $delegate_targets[ $declared_name ] = $subagent_slug; + $delegate_targets[ $loop_name ] = $subagent_slug; } } diff --git a/openclawp.php b/openclawp.php index 12d850c..5b006d3 100644 --- a/openclawp.php +++ b/openclawp.php @@ -32,7 +32,7 @@ } if ( ! defined( 'AGENTS_API_LOADED' ) ) { - $openclawp_agents_api_bootstrap = __DIR__ . '/vendor/automattic/agents-api/agents-api.php'; + $openclawp_agents_api_bootstrap = __DIR__ . '/vendor/wordpress/agents-api/agents-api.php'; if ( file_exists( $openclawp_agents_api_bootstrap ) ) { require_once $openclawp_agents_api_bootstrap; } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6623276..1d4ab1c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -189,7 +189,7 @@ function esc_url_raw( string $url ): string { require_once __DIR__ . '/../vendor/autoload.php'; // Stub the agents-api channel base class. The real one lives in -// `automattic/agents-api`, which is a dev-main composer dep that isn't +// `wordpress/agents-api`, which is a dev-main composer dep that isn't // always installed during PHPUnit runs (CI environments, fresh checkouts). // Tests in tests/unit/ exercise pure-PHP helpers on the subclasses; the // actual loop logic is covered by tests/smoke.php inside a real WP. diff --git a/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json b/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json index 3d3c7b3..06a2572 100644 --- a/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json +++ b/tests/integration/prompt-assembly/__snapshots__/coordinator--chat.json @@ -2,8 +2,8 @@ "agent_slug": "openclawp-coordinator", "channel": "chat", "delegate_targets": { - "delegate-to-openclawp-loop-demo": "openclawp-loop-demo", - "delegate-to-openclawp-site-introspection": "openclawp-site-introspection" + "client/delegate-to-openclawp-loop-demo": "openclawp-loop-demo", + "client/delegate-to-openclawp-site-introspection": "openclawp-site-introspection" }, "max_turns": 6, "messages": [ @@ -26,7 +26,7 @@ "tool_catalog": [ { "description": "Delegate to subagent openclaWP Site Introspection. Use when the request is in this subagent's scope. Subagent description: You are a helpful assistant that answers questions about this WordPress site. You have read-only access to four tools: openclawp__get-recent-posts (recent published posts), openclawp__count-comments (comment moderation totals), openclawp__get-active-plugins (currently active plugins), and openclawp__get-current-user (the human you are talking to). Always call the relevant tool before answering a factual question — never guess. Quote tool output values directly. Be concise.", - "name": "delegate-to-openclawp-site-introspection", + "name": "client/delegate-to-openclawp-site-introspection", "parameters": { "properties": { "prompt": { @@ -39,11 +39,11 @@ ], "type": "object" }, - "source": "openclawp" + "source": "client" }, { "description": "Delegate to subagent openclaWP Loop Demo. Use when the request is in this subagent's scope. Subagent description: You are a precise assistant. You have access to one tool: openclawp__get-time, which returns the current time. When the user asks for the time, the current date, or anything time-related, you MUST call openclawp__get-time first and use its result in your reply. Never guess the time.", - "name": "delegate-to-openclawp-loop-demo", + "name": "client/delegate-to-openclawp-loop-demo", "parameters": { "properties": { "prompt": { @@ -56,7 +56,7 @@ ], "type": "object" }, - "source": "openclawp" + "source": "client" } ] } diff --git a/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json b/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json index 5e6386e..ccd690a 100644 --- a/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json +++ b/tests/integration/prompt-assembly/__snapshots__/loop-demo--chat.json @@ -23,11 +23,11 @@ "tool_catalog": [ { "description": "Returns the current server time in ISO 8601 (UTC). Call this whenever the user asks for the time, the current date, or how long ago something happened.", - "name": "openclawp__get-time", + "name": "client/openclawp__get-time", "parameters": { "type": "object" }, - "source": "openclawp" + "source": "client" } ] } diff --git a/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json b/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json index 238598b..e6ae0cc 100644 --- a/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json +++ b/tests/integration/prompt-assembly/__snapshots__/site-introspection--whatsapp.json @@ -28,7 +28,7 @@ "tool_catalog": [ { "description": "Returns up to 5 most recent published posts (title + permalink + excerpt).", - "name": "openclawp__get-recent-posts", + "name": "client/openclawp__get-recent-posts", "parameters": { "properties": { "limit": { @@ -39,31 +39,31 @@ }, "type": "object" }, - "source": "openclawp" + "source": "client" }, { "description": "Returns counts of comments by status (approved, pending, spam, trash).", - "name": "openclawp__count-comments", + "name": "client/openclawp__count-comments", "parameters": { "type": "object" }, - "source": "openclawp" + "source": "client" }, { "description": "Lists currently active plugins on this site (name + version).", - "name": "openclawp__get-active-plugins", + "name": "client/openclawp__get-active-plugins", "parameters": { "type": "object" }, - "source": "openclawp" + "source": "client" }, { "description": "Returns the logged-in WordPress user (id, login, display name, roles).", - "name": "openclawp__get-current-user", + "name": "client/openclawp__get-current-user", "parameters": { "type": "object" }, - "source": "openclawp" + "source": "client" } ] } diff --git a/tests/integration/prompt-assembly/bootstrap.php b/tests/integration/prompt-assembly/bootstrap.php index 9c80132..318c712 100644 --- a/tests/integration/prompt-assembly/bootstrap.php +++ b/tests/integration/prompt-assembly/bootstrap.php @@ -23,7 +23,8 @@ // directly (rather than via the substrate bootstrap) because the substrate // also tries to register WordPress actions, REST routes, and hooks that // aren't available in this minimal PHPUnit environment. -require_once dirname( __DIR__, 3 ) . '/vendor/automattic/agents-api/src/Registry/class-wp-agent.php'; +require_once dirname( __DIR__, 3 ) . '/vendor/wordpress/agents-api/src/Registry/class-wp-agent-runtime-overrides.php'; +require_once dirname( __DIR__, 3 ) . '/vendor/wordpress/agents-api/src/Registry/class-wp-agent.php'; // Stub the WP AI Client FunctionDeclaration DTO. The real class ships with // `wordpress/php-ai-client` (or WP 7.0 core) and is only used at runtime to diff --git a/tests/phpunit-prepend.php b/tests/phpunit-prepend.php new file mode 100644 index 0000000..f115e1a --- /dev/null +++ b/tests/phpunit-prepend.php @@ -0,0 +1,20 @@ +assertSame( $input, $output ); } + public function test_to_ai_client_messages_round_trips_tool_mediation_parts(): void { + self::install_ai_client_stubs(); + + $output = OpenclaWP_Message_Adapter::to_ai_client_messages( + array( + array( 'role' => 'user', 'content' => 'Run the echo tool.' ), + array( + 'type' => 'tool_call', + 'payload' => array( + 'tool_name' => 'client/openclawp__echo', + 'parameters' => array( 'text' => 'hello' ), + ), + 'metadata' => array( 'tool_call_id' => 'call-1' ), + ), + array( + 'type' => 'tool_result', + 'payload' => array( + 'tool_name' => 'client/openclawp__echo', + 'result' => array( 'ok' => true ), + ), + 'metadata' => array( 'tool_call_id' => 'call-1' ), + ), + ) + ); + + $this->assertCount( 3, $output ); + + $call = $output[1]->getParts()[0]->getContent(); + $this->assertInstanceOf( \WordPress\AiClient\Tools\DTO\FunctionCall::class, $call ); + $this->assertSame( 'call-1', $call->getId() ); + $this->assertSame( 'openclawp__echo', $call->getName() ); + $this->assertSame( array( 'text' => 'hello' ), $call->getArgs() ); + + $response = $output[2]->getParts()[0]->getContent(); + $this->assertInstanceOf( \WordPress\AiClient\Tools\DTO\FunctionResponse::class, $response ); + $this->assertSame( 'call-1', $response->getId() ); + $this->assertSame( 'openclawp__echo', $response->getName() ); + $this->assertSame( array( 'ok' => true ), $response->getResponse() ); + } + public function test_last_assistant_text_returns_most_recent_assistant_message(): void { $messages = array( array( 'role' => 'user', 'content' => 'q1' ), @@ -79,4 +119,129 @@ public function test_last_assistant_text_handles_string_content(): void { $this->assertSame( 'plain string reply', OpenclaWP_Message_Adapter::last_assistant_text( $messages ) ); } + + private static function install_ai_client_stubs(): void { + $aliases = array( + AiClientStubs\Message::class => 'WordPress\\AiClient\\Messages\\DTO\\Message', + AiClientStubs\MessagePart::class => 'WordPress\\AiClient\\Messages\\DTO\\MessagePart', + AiClientStubs\MessageRoleEnum::class => 'WordPress\\AiClient\\Messages\\Enums\\MessageRoleEnum', + AiClientStubs\FunctionCall::class => 'WordPress\\AiClient\\Tools\\DTO\\FunctionCall', + AiClientStubs\FunctionResponse::class => 'WordPress\\AiClient\\Tools\\DTO\\FunctionResponse', + ); + + foreach ( $aliases as $source => $alias ) { + if ( ! class_exists( '\\' . $alias, false ) ) { + class_alias( $source, $alias ); + } + } + } +} + +namespace OpenclaWP\Tests\Unit\AiClientStubs; + +final class MessageRoleEnum { + private string $value; + + private function __construct( string $value ) { + $this->value = $value; + } + + public static function user(): self { + return new self( 'user' ); + } + + public static function model(): self { + return new self( 'model' ); + } + + public function value(): string { + return $this->value; + } +} + +final class Message { + private MessageRoleEnum $role; + /** @var array */ + private array $parts; + + /** + * @param array $parts + */ + public function __construct( MessageRoleEnum $role, array $parts ) { + $this->role = $role; + $this->parts = $parts; + } + + public function getRole(): MessageRoleEnum { + return $this->role; + } + + /** + * @return array + */ + public function getParts(): array { + return $this->parts; + } +} + +final class MessagePart { + /** @var mixed */ + private $content; + + public function __construct( $content ) { + $this->content = $content; + } + + public function getContent() { + return $this->content; + } +} + +final class FunctionCall { + private ?string $id; + private string $name; + private array $args; + + public function __construct( ?string $id, string $name, array $args ) { + $this->id = $id; + $this->name = $name; + $this->args = $args; + } + + public function getId(): ?string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function getArgs(): array { + return $this->args; + } +} + +final class FunctionResponse { + private ?string $id; + private ?string $name; + /** @var mixed */ + private $response; + + public function __construct( ?string $id, ?string $name, $response ) { + $this->id = $id; + $this->name = $name; + $this->response = $response; + } + + public function getId(): ?string { + return $this->id; + } + + public function getName(): ?string { + return $this->name; + } + + public function getResponse() { + return $this->response; + } }