From db9c611b4a7df0b863bc8b5f6dfc4d2167865d75 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 29 May 2026 11:48:50 -0300 Subject: [PATCH 1/2] Add opt-in default CPT conversation store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships a WordPress-native default conversation store inside canonical, dormant behind a single filter, resolving the zero-store cold-start gap from #235 without imposing a session post type on installs that don't ask for it. Enable with: add_filter( 'agents_api_enable_default_conversation_store', '__return_true' ); When enabled, WP_Agent_Cpt_Conversation_Store registers the agents_api_session CPT and provides itself as a fallback on wp_agent_conversation_store at low priority (5) — any host store at the default priority still wins. When not enabled, nothing registers and a vanilla install is unchanged, preserving the contracts-only default and the WP-core landing path: an off-by-default dormant class is not a mandatory post type. The store is wp_posts + wp_postmeta backed (no custom tables), generalized from the openclawp reference. It implements WP_Agent_Principal_Conversation_Store (and therefore WP_Agent_Conversation_Store) plus WP_Agent_Conversation_Lock; ownership is keyed by (owner_type, owner_key) so audience/token principals are first-class, with the int-user methods delegating to the _for_owner variants. Also updates the no-store error to point at the opt-in filter and records the decision in docs/default-stores-companion.md (superseding the earlier separate companion-package direction per maintainer consensus: easier to consume, no second package to maintain). Adds tests/cpt-conversation-store-smoke.php: 38 assertions against the canonical interfaces via an in-memory WP shim — full CRUD round-trip, user/audience owner isolation, pending-session dedup, and lock acquire/release/CAS reclaim. Closes #235. Co-Authored-By: Claude Opus 4.8 (1M context) --- agents-api.php | 2 + composer.json | 1 + docs/default-stores-companion.md | 67 ++- .../class-wp-agent-cpt-conversation-store.php | 551 ++++++++++++++++++ ...-agents-conversation-session-abilities.php | 2 +- .../register-default-conversation-store.php | 62 ++ tests/cpt-conversation-store-smoke.php | 404 +++++++++++++ 7 files changed, 1062 insertions(+), 27 deletions(-) create mode 100644 src/Transcripts/class-wp-agent-cpt-conversation-store.php create mode 100644 src/Transcripts/register-default-conversation-store.php create mode 100644 tests/cpt-conversation-store-smoke.php diff --git a/agents-api.php b/agents-api.php index f1604a8..27be8ba 100644 --- a/agents-api.php +++ b/agents-api.php @@ -77,6 +77,8 @@ require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-sessions.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-conversation-lock.php'; require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-null-conversation-lock.php'; +require_once AGENTS_API_PATH . 'src/Transcripts/class-wp-agent-cpt-conversation-store.php'; +require_once AGENTS_API_PATH . 'src/Transcripts/register-default-conversation-store.php'; require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-pending-action-store.php'; require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-pending-action-status.php'; require_once AGENTS_API_PATH . 'src/Approvals/class-wp-agent-pending-action.php'; diff --git a/composer.json b/composer.json index ef5cc10..1c0b5a1 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "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", diff --git a/docs/default-stores-companion.md b/docs/default-stores-companion.md index 6f7c263..c717a70 100644 --- a/docs/default-stores-companion.md +++ b/docs/default-stores-companion.md @@ -4,34 +4,49 @@ Issues: https://github.com/Automattic/agents-api/issues/78, https://github.com/A `agents-api` remains the canonical substrate for contracts, value objects, and provider-neutral runtime seams. Concrete persistence policy belongs outside this repository unless the policy itself becomes a generic contract. -## Decision (resolves #235) +## Decision (resolves #235): opt-in default conversation store in canonical #235 reopened the question of whether canonical should ship a default conversation -session store so that zero-store installs work out of the box. The decision is to -keep that store in **this companion package, not canonical**: - -- Canonical is on the WordPress core landing path (#77). A default CPT session store - in canonical would become a session post type that core ships to every site — a - much larger imposition than a Composer-required companion, and likely a core-review - blocker. The companion never carries that liability. -- The posts-table-bloat concern (Data Machine generating large post volumes) only - applies to consumers that do not override `wp_agent_conversation_store`. High-volume - adopters override and never touch the default; the remaining audience is low-volume - experiments for which a CPT is appropriate. -- The reference implementation already exists in - [`lezama/openclawp`](https://github.com/lezama/openclawp/blob/main/includes/class-openclawp-conversation-store.php) - (CPT + postmeta, `WP_Agent_Conversation_Store` + `WP_Agent_Conversation_Lock`). The - companion's CPT transcript store is an extraction + generalization of it, not a - from-scratch design. - -Canonical's contribution to the cold-start experience is limited to a more actionable -`agents_conversation_session_no_store` error that points at this companion. The error -code is unchanged. - -Note: openclawp's store is **user-keyed only**. The generalized companion store must -additionally implement `WP_Agent_Principal_Conversation_Store` so non-user principals -(audience/token) do not require a custom backend on day one; the legacy int-user -methods delegate to the `_for_owner` variants. +session store so that zero-store installs work out of the box. After weighing a +separate companion package against an in-canonical option, the maintainers +(@chubes4, @lezama) chose to ship the default conversation store **inside canonical, +dormant behind an opt-in filter** rather than as a separate package. + +Rationale: + +- **One package to consume.** A consumer enables the store with a single filter — + `add_filter( 'agents_api_enable_default_conversation_store', '__return_true' )` — + instead of installing and version-tracking a second tiny plugin. +- **No CPT shipped to every site.** The store registers nothing unless opted in. A + vanilla install still has no `agents_api_session` post type and still returns the + no-store error, so the contracts-only default — and the WordPress core landing path + (#77) — is preserved at runtime. An off-by-default dormant class is a far smaller + core-review concern than a mandatory session post type. +- **Fallback only.** When enabled, the store registers on `wp_agent_conversation_store` + at low priority (5), so any host store registered at the default priority wins. + Data Machine and WordPress.com keep their own custom backends and never enable this. + +Implementation: `WP_Agent_Cpt_Conversation_Store` +(`src/Transcripts/class-wp-agent-cpt-conversation-store.php`), wired by +`src/Transcripts/register-default-conversation-store.php`. It is a `wp_posts` + +`wp_postmeta` store (no custom tables) generalized from the +[`lezama/openclawp`](https://github.com/lezama/openclawp/blob/main/includes/class-openclawp-conversation-store.php) +reference. openclawp's store is user-keyed only; the canonical store additionally +implements `WP_Agent_Principal_Conversation_Store` so non-user principals +(audience/token) work without a custom backend — the int-user methods delegate to the +`_for_owner` variants. + +Retention/cleanup is intentionally not shipped: it stays a consumer responsibility so +the substrate does not become a product runtime. + +### Superseded direction + +An earlier resolution favored a separate `wordpress/agents-api-default-stores` +companion package (and the conversation-store error briefly pointed at it). That was +superseded by the opt-in-in-canonical decision above for the consumption + maintenance +reasons listed. The memory-store sections below remain a valid future companion +candidate **if** their dependency surface ever justifies a separate package; the +conversation store does not. ## Boundary diff --git a/src/Transcripts/class-wp-agent-cpt-conversation-store.php b/src/Transcripts/class-wp-agent-cpt-conversation-store.php new file mode 100644 index 0000000..844d6b4 --- /dev/null +++ b/src/Transcripts/class-wp-agent-cpt-conversation-store.php @@ -0,0 +1,551 @@ +update()` slow path for expired-lock reclamation. + */ +final class WP_Agent_Cpt_Conversation_Store implements WP_Agent_Principal_Conversation_Store, WP_Agent_Conversation_Lock { + + public const POST_TYPE = 'agents_api_session'; + + public const OWNER_TYPE_USER = 'user'; + + private const META_SESSION_ID = '_agents_api_session_id'; + private const META_WORKSPACE_TYPE = '_agents_api_workspace_type'; + private const META_WORKSPACE_ID = '_agents_api_workspace_id'; + private const META_OWNER_TYPE = '_agents_api_owner_type'; + private const META_OWNER_KEY = '_agents_api_owner_key'; + private const META_AGENT_SLUG = '_agents_api_agent_slug'; + private const META_METADATA = '_agents_api_metadata'; + private const META_PROVIDER = '_agents_api_provider'; + private const META_MODEL = '_agents_api_model'; + private const META_PROVIDER_RESPONSE_ID = '_agents_api_provider_response_id'; + private const META_CONTEXT = '_agents_api_context'; + private const META_LAST_READ_AT = '_agents_api_last_read_at'; + private const META_EXPIRES_AT = '_agents_api_expires_at'; + private const META_LOCK = '_agents_api_lock'; + private const META_TOKEN_ID = '_agents_api_token_id'; + + /** + * Register the session post type. + * + * Sessions are not surfaced in wp-admin (`show_ui = false`) and have no + * front-end permalinks (`public = false`) because they are per-owner data, + * not site content — mirroring how `wp_block` is registered. + */ + public static function register_post_type(): void { + register_post_type( + self::POST_TYPE, + array( + 'labels' => array( + 'name' => __( 'Agent Sessions', 'agents-api' ), + 'singular_name' => __( 'Agent Session', 'agents-api' ), + ), + 'public' => false, + 'show_ui' => false, + 'show_in_rest' => false, + 'exclude_from_search' => true, + 'rewrite' => false, + 'has_archive' => false, + 'supports' => array( 'title', 'editor', 'author', 'custom-fields' ), + 'capability_type' => 'post', + 'map_meta_cap' => true, + ) + ); + } + + /** + * Canonical owner shape for a WordPress user ID. + * + * @param int $user_id WordPress user ID. + * @return array{type:string,key:string} + */ + public static function user_owner( int $user_id ): array { + return array( + 'type' => self::OWNER_TYPE_USER, + 'key' => (string) $user_id, + ); + } + + /* --------------------- Conversation store contract -------------------- */ + + public function create_session( WP_Agent_Workspace_Scope $workspace, int $user_id, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + return $this->create_session_for_owner( $workspace, self::user_owner( $user_id ), $agent_slug, $metadata, $context ); + } + + public function list_sessions( WP_Agent_Workspace_Scope $workspace, int $user_id, array $args = array() ): array { + return $this->list_sessions_for_owner( $workspace, self::user_owner( $user_id ), $args ); + } + + public function get_recent_pending_session( WP_Agent_Workspace_Scope $workspace, int $user_id, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array { + return $this->get_recent_pending_session_for_owner( $workspace, self::user_owner( $user_id ), $seconds, $context, $token_id ); + } + + public function get_session( string $session_id ): ?array { + $post = $this->find_post_by_session_id( $session_id ); + return null === $post ? null : $this->session_array( $post ); + } + + public function update_session( string $session_id, array $messages, array $metadata = array(), string $provider = '', string $model = '', ?string $provider_response_id = null ): bool { + $post = $this->find_post_by_session_id( $session_id ); + if ( null === $post ) { + return false; + } + + $updated = wp_update_post( + array( + 'ID' => $post->ID, + // wp_update_post unslashes post_content; wp_slash preserves the + // JSON's escaped quotes (notably tool_result payloads that nest + // JSON in their content field). + 'post_content' => wp_slash( wp_json_encode( array_values( $messages ) ) ), + ), + true + ); + + if ( is_wp_error( $updated ) || ! $updated ) { + return false; + } + + update_post_meta( $post->ID, self::META_METADATA, wp_json_encode( $metadata ) ); + if ( '' !== $provider ) { + update_post_meta( $post->ID, self::META_PROVIDER, $provider ); + } + if ( '' !== $model ) { + update_post_meta( $post->ID, self::META_MODEL, $model ); + } + if ( null !== $provider_response_id ) { + update_post_meta( $post->ID, self::META_PROVIDER_RESPONSE_ID, $provider_response_id ); + } + + return true; + } + + public function delete_session( string $session_id ): bool { + $post = $this->find_post_by_session_id( $session_id ); + if ( null === $post ) { + return true; + } + $result = wp_delete_post( $post->ID, true ); + return false !== $result && null !== $result; + } + + public function update_title( string $session_id, string $title ): bool { + $post = $this->find_post_by_session_id( $session_id ); + if ( null === $post ) { + return false; + } + + $updated = wp_update_post( + array( + 'ID' => $post->ID, + 'post_title' => wp_slash( $title ), + ), + true + ); + + return ! is_wp_error( $updated ) && (bool) $updated; + } + + /* ---------------- Principal conversation store contract --------------- */ + + public function create_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, string $agent_slug = '', array $metadata = array(), string $context = 'chat' ): string { + $owner = $this->normalize_owner( $owner ); + $session_id = self::uuid4(); + + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_author' => self::OWNER_TYPE_USER === $owner['type'] ? (int) $owner['key'] : 0, + 'post_title' => '', + // wp_insert_post unslashes post_content; wp_slash compensates so + // JSON-escaped characters survive the round-trip. + 'post_content' => wp_slash( wp_json_encode( array() ) ), + ), + true + ); + + if ( is_wp_error( $post_id ) || ! $post_id ) { + return ''; + } + + update_post_meta( $post_id, self::META_SESSION_ID, $session_id ); + update_post_meta( $post_id, self::META_WORKSPACE_TYPE, $workspace->workspace_type ); + update_post_meta( $post_id, self::META_WORKSPACE_ID, $workspace->workspace_id ); + update_post_meta( $post_id, self::META_OWNER_TYPE, $owner['type'] ); + update_post_meta( $post_id, self::META_OWNER_KEY, $owner['key'] ); + update_post_meta( $post_id, self::META_AGENT_SLUG, $agent_slug ); + update_post_meta( $post_id, self::META_METADATA, wp_json_encode( $metadata ) ); + update_post_meta( $post_id, self::META_CONTEXT, $context ); + + if ( isset( $metadata['token_id'] ) ) { + update_post_meta( $post_id, self::META_TOKEN_ID, (int) $metadata['token_id'] ); + } + + return $session_id; + } + + public function list_sessions_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, array $args = array() ): array { + $owner = $this->normalize_owner( $owner ); + $limit = isset( $args['limit'] ) ? max( 1, (int) $args['limit'] ) : 50; + $offset = isset( $args['offset'] ) ? max( 0, (int) $args['offset'] ) : 0; + $include_messages = ! empty( $args['include_messages'] ); + + $meta_query = $this->owner_meta_query( $workspace, $owner ); + + if ( isset( $args['agent_slug'] ) && '' !== $args['agent_slug'] ) { + $meta_query[] = array( + 'key' => self::META_AGENT_SLUG, + 'value' => (string) $args['agent_slug'], + ); + } + + if ( isset( $args['context'] ) && '' !== $args['context'] ) { + $meta_query[] = array( + 'key' => self::META_CONTEXT, + 'value' => (string) $args['context'], + ); + } + + $query = new \WP_Query( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => $limit, + 'offset' => $offset, + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'suppress_filters' => true, + ) + ); + + $sessions = array(); + foreach ( $query->posts as $post ) { + $session = $this->session_array( $post ); + if ( ! $include_messages ) { + unset( $session['messages'] ); + } + $sessions[] = $session; + } + + return $sessions; + } + + public function get_recent_pending_session_for_owner( WP_Agent_Workspace_Scope $workspace, array $owner, int $seconds = 600, string $context = 'chat', ?int $token_id = null ): ?array { + $owner = $this->normalize_owner( $owner ); + $cutoff = gmdate( 'Y-m-d H:i:s', time() - max( 1, $seconds ) ); + + $meta_query = $this->owner_meta_query( $workspace, $owner ); + $meta_query[] = array( + 'key' => self::META_CONTEXT, + 'value' => $context, + ); + + if ( null !== $token_id ) { + $meta_query[] = array( + 'key' => self::META_TOKEN_ID, + 'value' => $token_id, + 'type' => 'NUMERIC', + ); + } + + $query = new \WP_Query( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + 'date_query' => array( + array( + 'after' => $cutoff, + 'inclusive' => true, + 'column' => 'post_date_gmt', + ), + ), + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'suppress_filters' => true, + 'fields' => 'all', + ) + ); + + if ( empty( $query->posts ) ) { + return null; + } + + $post = $query->posts[0]; + $messages = $this->decode_messages( $post->post_content ); + + // "Pending" = empty transcript or actively-locked session. + if ( empty( $messages ) || $this->lock_active( $post->ID ) ) { + return $this->session_array( $post ); + } + + return null; + } + + /* ---------------------------- Lock contract --------------------------- */ + + public function acquire_session_lock( string $session_id, int $ttl_seconds = 300 ): ?string { + global $wpdb; + + $post = $this->find_post_by_session_id( $session_id ); + if ( null === $post ) { + return null; + } + + $token = self::uuid4(); + $expires = time() + max( 1, $ttl_seconds ); + $value = wp_json_encode( + array( + 'token' => $token, + 'expires' => $expires, + ) + ); + + // Fast path: no lock present. add_post_meta with $unique=true is atomic. + $added = add_post_meta( $post->ID, self::META_LOCK, $value, true ); + if ( $added ) { + return $token; + } + + // Slow path: a lock row exists. Read it; if not yet expired, lose. + $existing_raw = get_post_meta( $post->ID, self::META_LOCK, true ); + if ( ! is_string( $existing_raw ) || '' === $existing_raw ) { + // Race: meta disappeared between calls. Retry once. + $retry = add_post_meta( $post->ID, self::META_LOCK, $value, true ); + return $retry ? $token : null; + } + + $existing = json_decode( $existing_raw, true ); + if ( ! is_array( $existing ) || (int) ( $existing['expires'] ?? 0 ) > time() ) { + return null; + } + + // Atomic compare-and-swap on the expired lock. The WHERE meta_value = + // $existing_raw clause is the test; the SET is the swap. Concurrent + // callers that read the same expired lock race here, and only one sees + // rows_affected = 1. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->update( + $wpdb->postmeta, + array( 'meta_value' => $value ), + array( + 'post_id' => $post->ID, + 'meta_key' => self::META_LOCK, + 'meta_value' => $existing_raw, + ), + array( '%s' ), + array( '%d', '%s', '%s' ) + ); + + if ( false === $rows ) { + return null; + } + + // Bust the post-meta cache so subsequent reads see the new lock value. + wp_cache_delete( $post->ID, 'post_meta' ); + + return 1 === (int) $rows ? $token : null; + } + + public function release_session_lock( string $session_id, string $lock_token ): bool { + $post = $this->find_post_by_session_id( $session_id ); + if ( null === $post ) { + return false; + } + + $existing = $this->read_lock( $post->ID ); + if ( null === $existing ) { + return false; + } + + if ( ( $existing['token'] ?? '' ) !== $lock_token ) { + return false; + } + + delete_post_meta( $post->ID, self::META_LOCK ); + return true; + } + + /* ----------------------------- Internals ------------------------------ */ + + /** + * Build the workspace + owner meta_query prefix shared by list/dedup. + * + * @param WP_Agent_Workspace_Scope $workspace Workspace scope. + * @param array{type:string,key:string} $owner Normalized owner. + * @return array + */ + private function owner_meta_query( WP_Agent_Workspace_Scope $workspace, array $owner ): array { + return array( + 'relation' => 'AND', + array( + 'key' => self::META_WORKSPACE_TYPE, + 'value' => $workspace->workspace_type, + ), + array( + 'key' => self::META_WORKSPACE_ID, + 'value' => $workspace->workspace_id, + ), + array( + 'key' => self::META_OWNER_TYPE, + 'value' => $owner['type'], + ), + array( + 'key' => self::META_OWNER_KEY, + 'value' => $owner['key'], + ), + ); + } + + /** + * Coerce a caller-supplied owner into the canonical shape. + * + * @param array{type?:string,key?:string} $owner Raw owner. + * @return array{type:string,key:string} + */ + private function normalize_owner( array $owner ): array { + $type = isset( $owner['type'] ) && is_string( $owner['type'] ) && '' !== $owner['type'] ? $owner['type'] : self::OWNER_TYPE_USER; + $key = isset( $owner['key'] ) ? (string) $owner['key'] : '0'; + return array( + 'type' => $type, + 'key' => $key, + ); + } + + private function find_post_by_session_id( string $session_id ): ?\WP_Post { + if ( '' === trim( $session_id ) ) { + return null; + } + + $query = new \WP_Query( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'update_post_term_cache' => false, + 'suppress_filters' => true, + 'meta_key' => self::META_SESSION_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $session_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + ) + ); + + return ! empty( $query->posts ) ? $query->posts[0] : null; + } + + private function session_array( \WP_Post $post ): array { + $metadata_raw = get_post_meta( $post->ID, self::META_METADATA, true ); + $metadata = is_string( $metadata_raw ) && '' !== $metadata_raw ? json_decode( $metadata_raw, true ) : array(); + if ( ! is_array( $metadata ) ) { + $metadata = array(); + } + + $owner_type = (string) ( get_post_meta( $post->ID, self::META_OWNER_TYPE, true ) ?: self::OWNER_TYPE_USER ); + $owner_key = (string) get_post_meta( $post->ID, self::META_OWNER_KEY, true ); + + return array( + 'session_id' => (string) get_post_meta( $post->ID, self::META_SESSION_ID, true ), + 'workspace_type' => (string) get_post_meta( $post->ID, self::META_WORKSPACE_TYPE, true ), + 'workspace_id' => (string) get_post_meta( $post->ID, self::META_WORKSPACE_ID, true ), + 'owner_type' => $owner_type, + 'owner_key' => $owner_key, + 'user_id' => self::OWNER_TYPE_USER === $owner_type ? (int) $owner_key : 0, + 'agent_slug' => (string) get_post_meta( $post->ID, self::META_AGENT_SLUG, true ), + 'title' => (string) $post->post_title, + 'messages' => $this->decode_messages( $post->post_content ), + 'metadata' => $metadata, + 'provider' => (string) get_post_meta( $post->ID, self::META_PROVIDER, true ), + 'model' => (string) get_post_meta( $post->ID, self::META_MODEL, true ), + 'provider_response_id' => $this->nullable_meta_string( $post->ID, self::META_PROVIDER_RESPONSE_ID ), + 'context' => (string) ( get_post_meta( $post->ID, self::META_CONTEXT, true ) ?: 'chat' ), + 'mode' => (string) ( get_post_meta( $post->ID, self::META_CONTEXT, true ) ?: 'chat' ), + 'created_at' => (string) $post->post_date_gmt, + 'updated_at' => (string) $post->post_modified_gmt, + 'last_read_at' => $this->nullable_meta_string( $post->ID, self::META_LAST_READ_AT ), + 'expires_at' => $this->nullable_meta_string( $post->ID, self::META_EXPIRES_AT ), + ); + } + + private function decode_messages( string $raw ): array { + if ( '' === $raw ) { + return array(); + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? array_values( $decoded ) : array(); + } + + private function nullable_meta_string( int $post_id, string $key ): ?string { + $value = get_post_meta( $post_id, $key, true ); + return ( '' === $value || null === $value ) ? null : (string) $value; + } + + private function read_lock( int $post_id ): ?array { + $raw = get_post_meta( $post_id, self::META_LOCK, true ); + if ( ! is_string( $raw ) || '' === $raw ) { + return null; + } + $decoded = json_decode( $raw, true ); + return is_array( $decoded ) ? $decoded : null; + } + + private function lock_active( int $post_id ): bool { + $lock = $this->read_lock( $post_id ); + if ( null === $lock ) { + return false; + } + return (int) ( $lock['expires'] ?? 0 ) > time(); + } + + private static function uuid4(): string { + if ( function_exists( 'wp_generate_uuid4' ) ) { + return wp_generate_uuid4(); + } + // Fallback for environments without WP loaded. + $data = random_bytes( 16 ); + $data[6] = chr( ( ord( $data[6] ) & 0x0f ) | 0x40 ); + $data[8] = chr( ( ord( $data[8] ) & 0x3f ) | 0x80 ); + return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) ); + } +} diff --git a/src/Transcripts/register-agents-conversation-session-abilities.php b/src/Transcripts/register-agents-conversation-session-abilities.php index f127b51..71cfe3e 100644 --- a/src/Transcripts/register-agents-conversation-session-abilities.php +++ b/src/Transcripts/register-agents-conversation-session-abilities.php @@ -281,7 +281,7 @@ function agents_conversation_sessions_context( array $input ) { $store = WP_Agent_Conversation_Sessions::get_store( array( 'principal' => $principal ) + $input ); if ( ! $store instanceof WP_Agent_Conversation_Store ) { - return new \WP_Error( 'agents_conversation_session_no_store', 'No conversation store is registered. Install the wordpress/agents-api-default-stores companion for a zero-config WordPress-native store, or register your own with the wp_agent_conversation_store filter.' ); + return new \WP_Error( 'agents_conversation_session_no_store', "No conversation store is registered. Enable the built-in WordPress-native store with add_filter( 'agents_api_enable_default_conversation_store', '__return_true' ), or register your own with the wp_agent_conversation_store filter." ); } if ( ! $store instanceof WP_Agent_Principal_Conversation_Store && WP_Agent_Execution_Principal::OWNER_TYPE_USER !== $owner['type'] ) { diff --git a/src/Transcripts/register-default-conversation-store.php b/src/Transcripts/register-default-conversation-store.php new file mode 100644 index 0000000..9c75906 --- /dev/null +++ b/src/Transcripts/register-default-conversation-store.php @@ -0,0 +1,62 @@ + get -> + * update -> delete -> list -> lock round-trip runs without a database. The + * shim implements exactly the WordPress surface this store touches; it is not + * a general WP emulator. + * + * Run with: php tests/cpt-conversation-store-smoke.php + * + * @package AgentsAPI\Tests + */ + +if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); +} + +$failures = array(); +$passes = 0; + +echo "agents-api-cpt-conversation-store-smoke\n"; + +/* ----------------------------- in-memory WP shim ---------------------------- */ + +$GLOBALS['__posts'] = array(); +$GLOBALS['__meta'] = array(); +$GLOBALS['__next_id'] = 1; + +if ( ! class_exists( 'WP_Post' ) ) { + class WP_Post { + public int $ID = 0; + public int $post_author = 0; + public string $post_title = ''; + public string $post_content = ''; + public string $post_type = ''; + public string $post_status = 'publish'; + public string $post_date_gmt = ''; + public string $post_modified_gmt = ''; + } +} + +if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + public function __construct( private string $code = '', private string $message = '' ) {} + public function get_error_code(): string { + return $this->code; } + public function get_error_message(): string { + return $this->message; } + } +} + +function is_wp_error( $thing ): bool { + return $thing instanceof WP_Error; +} + +function __( string $text, string $domain = 'default' ): string { + unset( $domain ); + return $text; +} + +// No-op hook stubs: loading agents-api.php registers init/abilities callbacks +// at file scope. The smoke calls the store directly, so these need not fire. +function add_action( string $hook, $cb, int $priority = 10, int $args = 1 ): bool { + unset( $hook, $cb, $priority, $args ); + return true; +} +function add_filter( string $hook, $cb, int $priority = 10, int $args = 1 ): bool { + unset( $hook, $cb, $priority, $args ); + return true; +} +function apply_filters( string $hook, $value, ...$args ) { + unset( $hook, $args ); + return $value; +} +function do_action( string $hook, ...$args ): void { + unset( $hook, $args ); +} + +function wp_json_encode( $data, int $options = 0, int $depth = 512 ) { + return json_encode( $data, $options, max( 1, $depth ) ); +} + +function wp_slash( $value ) { + return $value; +} + +function wp_cache_delete( $key, $group = '' ): bool { + unset( $key, $group ); + return true; +} + +$GLOBALS['__uuid_seq'] = 0; +function wp_generate_uuid4(): string { + ++$GLOBALS['__uuid_seq']; + return sprintf( '00000000-0000-4000-8000-%012d', $GLOBALS['__uuid_seq'] ); +} + +function wp_insert_post( array $postarr, bool $wp_error = false ) { + unset( $wp_error ); + $id = $GLOBALS['__next_id']++; + $now = gmdate( 'Y-m-d H:i:s' ); + $post = new WP_Post(); + $post->ID = $id; + $post->post_author = (int) ( $postarr['post_author'] ?? 0 ); + $post->post_title = (string) ( $postarr['post_title'] ?? '' ); + $post->post_content = (string) ( $postarr['post_content'] ?? '' ); + $post->post_type = (string) ( $postarr['post_type'] ?? 'post' ); + $post->post_status = (string) ( $postarr['post_status'] ?? 'publish' ); + $post->post_date_gmt = $now; + $post->post_modified_gmt = $now; + + $GLOBALS['__posts'][ $id ] = $post; + $GLOBALS['__meta'][ $id ] = array(); + return $id; +} + +function wp_update_post( array $postarr, bool $wp_error = false ) { + unset( $wp_error ); + $id = (int) ( $postarr['ID'] ?? 0 ); + if ( ! isset( $GLOBALS['__posts'][ $id ] ) ) { + return 0; + } + $post = $GLOBALS['__posts'][ $id ]; + if ( array_key_exists( 'post_content', $postarr ) ) { + $post->post_content = (string) $postarr['post_content']; + } + if ( array_key_exists( 'post_title', $postarr ) ) { + $post->post_title = (string) $postarr['post_title']; + } + $post->post_modified_gmt = gmdate( 'Y-m-d H:i:s', time() + 1 ); + return $id; +} + +function wp_delete_post( int $post_id, bool $force = false ) { + unset( $force ); + if ( ! isset( $GLOBALS['__posts'][ $post_id ] ) ) { + return false; + } + $post = $GLOBALS['__posts'][ $post_id ]; + unset( $GLOBALS['__posts'][ $post_id ], $GLOBALS['__meta'][ $post_id ] ); + return $post; +} + +function get_post_meta( int $post_id, string $key = '', bool $single = false ) { + $all = $GLOBALS['__meta'][ $post_id ] ?? array(); + if ( '' === $key ) { + return $all; + } + $values = $all[ $key ] ?? array(); + if ( $single ) { + return empty( $values ) ? '' : $values[0]; + } + return $values; +} + +function update_post_meta( int $post_id, string $key, $value ): bool { + $GLOBALS['__meta'][ $post_id ][ $key ] = array( $value ); + return true; +} + +function add_post_meta( int $post_id, string $key, $value, bool $unique = false ) { + if ( $unique && ! empty( $GLOBALS['__meta'][ $post_id ][ $key ] ) ) { + return false; + } + $GLOBALS['__meta'][ $post_id ][ $key ][] = $value; + return $post_id * 1000 + 1; +} + +function delete_post_meta( int $post_id, string $key ): bool { + unset( $GLOBALS['__meta'][ $post_id ][ $key ] ); + return true; +} + +/** + * Minimal WP_Query supporting only the shapes this store issues: + * post_type filter, optional meta_key/meta_value lookup, meta_query (AND + * equality + NUMERIC), date_query (after/before on a *_gmt column), order by + * date, posts_per_page + offset. + */ +if ( ! class_exists( 'WP_Query' ) ) { + class WP_Query { + /** @var WP_Post[] */ + public array $posts = array(); + + public function __construct( array $args ) { + $post_type = (string) ( $args['post_type'] ?? 'post' ); + $matches = array(); + + foreach ( $GLOBALS['__posts'] as $post ) { + if ( $post->post_type !== $post_type ) { + continue; + } + if ( isset( $args['meta_key'] ) ) { + $single = get_post_meta( $post->ID, (string) $args['meta_key'], true ); + if ( (string) $single !== (string) ( $args['meta_value'] ?? '' ) ) { + continue; + } + } + if ( isset( $args['meta_query'] ) && is_array( $args['meta_query'] ) && ! $this->meta_query_matches( $post->ID, $args['meta_query'] ) ) { + continue; + } + if ( isset( $args['date_query'] ) && is_array( $args['date_query'] ) && ! $this->date_query_matches( $post, $args['date_query'] ) ) { + continue; + } + $matches[] = $post; + } + + usort( + $matches, + static function ( WP_Post $a, WP_Post $b ): int { + return strcmp( $b->post_date_gmt, $a->post_date_gmt ) ?: ( $b->ID <=> $a->ID ); + } + ); + + $offset = isset( $args['offset'] ) ? max( 0, (int) $args['offset'] ) : 0; + $limit = isset( $args['posts_per_page'] ) ? (int) $args['posts_per_page'] : -1; + $matches = array_slice( $matches, $offset, $limit < 0 ? null : $limit ); + + $this->posts = $matches; + } + + private function meta_query_matches( int $post_id, array $meta_query ): bool { + foreach ( $meta_query as $key => $clause ) { + if ( 'relation' === $key || ! is_array( $clause ) ) { + continue; + } + $meta_key = (string) ( $clause['key'] ?? '' ); + $expected = $clause['value'] ?? ''; + $actual = get_post_meta( $post_id, $meta_key, true ); + if ( 'NUMERIC' === ( $clause['type'] ?? '' ) ) { + if ( (int) $actual !== (int) $expected ) { + return false; + } + continue; + } + if ( (string) $actual !== (string) $expected ) { + return false; + } + } + return true; + } + + private function date_query_matches( WP_Post $post, array $date_query ): bool { + foreach ( $date_query as $clause ) { + if ( ! is_array( $clause ) ) { + continue; + } + $column = (string) ( $clause['column'] ?? 'post_date_gmt' ); + $value = $post->{$column} ?? ''; + if ( isset( $clause['after'] ) && strcmp( $value, (string) $clause['after'] ) < 0 ) { + return false; + } + if ( isset( $clause['before'] ) && strcmp( $value, (string) $clause['before'] ) > 0 ) { + return false; + } + } + return true; + } + } +} + +/** Minimal $wpdb supporting the lock compare-and-swap. */ +if ( ! class_exists( 'WPDB_Cpt_Store_Shim' ) ) { + class WPDB_Cpt_Store_Shim { + public string $postmeta = 'wp_postmeta'; + + public function update( string $table, array $data, array $where, array $data_format = array(), array $where_format = array() ) { + unset( $table, $data_format, $where_format ); + $post_id = (int) ( $where['post_id'] ?? 0 ); + $key = (string) ( $where['meta_key'] ?? '' ); + $old = (string) ( $where['meta_value'] ?? '' ); + $new = (string) ( $data['meta_value'] ?? '' ); + + $values = $GLOBALS['__meta'][ $post_id ][ $key ] ?? array(); + if ( empty( $values ) || (string) $values[0] !== $old ) { + return 0; + } + $GLOBALS['__meta'][ $post_id ][ $key ][0] = $new; + return 1; + } + } +} +$GLOBALS['wpdb'] = new WPDB_Cpt_Store_Shim(); + +/* ------------------------------ load + assert ------------------------------ */ + +require_once __DIR__ . '/../agents-api.php'; + +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Store; +use AgentsAPI\Core\Database\Chat\WP_Agent_Conversation_Lock; +use AgentsAPI\Core\Database\Chat\WP_Agent_Principal_Conversation_Store; +use AgentsAPI\Core\Database\Chat\WP_Agent_Cpt_Conversation_Store; +use AgentsAPI\Core\Workspace\WP_Agent_Workspace_Scope; + +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"; +} + +$store = new WP_Agent_Cpt_Conversation_Store(); +$workspace = WP_Agent_Workspace_Scope::from_parts( 'site', '42' ); + +echo "\n[1] Implements the canonical contracts:\n"; +smoke_assert( true, $store instanceof WP_Agent_Conversation_Store, 'implements WP_Agent_Conversation_Store', $failures, $passes ); +smoke_assert( true, $store instanceof WP_Agent_Principal_Conversation_Store, 'implements WP_Agent_Principal_Conversation_Store', $failures, $passes ); +smoke_assert( true, $store instanceof WP_Agent_Conversation_Lock, 'implements WP_Agent_Conversation_Lock', $failures, $passes ); + +echo "\n[2] User-keyed create -> get round-trips with the contract shape:\n"; +$session_id = $store->create_session( $workspace, 7, 'demo-agent', array( 'k' => 'v' ), 'chat' ); +smoke_assert( true, '' !== $session_id, 'create_session returns a non-empty id', $failures, $passes ); +$session = $store->get_session( $session_id ); +smoke_assert( true, is_array( $session ), 'get_session returns an array', $failures, $passes ); +smoke_assert( $session_id, $session['session_id'] ?? null, 'session_id round-trips', $failures, $passes ); +smoke_assert( 'site', $session['workspace_type'] ?? null, 'workspace_type preserved', $failures, $passes ); +smoke_assert( '42', $session['workspace_id'] ?? null, 'workspace_id preserved', $failures, $passes ); +smoke_assert( 7, $session['user_id'] ?? null, 'user_id derived from user owner', $failures, $passes ); +smoke_assert( 'user', $session['owner_type'] ?? null, 'owner_type recorded as user', $failures, $passes ); +smoke_assert( '7', $session['owner_key'] ?? null, 'owner_key is the user id string', $failures, $passes ); +smoke_assert( 'demo-agent', $session['agent_slug'] ?? null, 'agent_slug preserved', $failures, $passes ); +smoke_assert( 'chat', $session['context'] ?? null, 'context preserved', $failures, $passes ); +smoke_assert( array(), $session['messages'] ?? null, 'messages start empty', $failures, $passes ); +smoke_assert( true, array_key_exists( 'provider_response_id', $session ) && null === $session['provider_response_id'], 'provider_response_id present and null until set', $failures, $passes ); + +echo "\n[3] update_session persists messages + provider continuity:\n"; +$ok = $store->update_session( $session_id, array( array( 'role' => 'user', 'content' => 'hi' ) ), array( 'k' => 'v2' ), 'anthropic', 'claude', 'resp_123' ); +smoke_assert( true, $ok, 'update_session returns true', $failures, $passes ); +$session = $store->get_session( $session_id ); +smoke_assert( 1, count( $session['messages'] ), 'messages persisted', $failures, $passes ); +smoke_assert( 'anthropic', $session['provider'], 'provider persisted', $failures, $passes ); +smoke_assert( 'claude', $session['model'], 'model persisted', $failures, $passes ); +smoke_assert( 'resp_123', $session['provider_response_id'], 'provider_response_id persisted', $failures, $passes ); + +echo "\n[4] update_title + delete:\n"; +smoke_assert( true, $store->update_title( $session_id, 'My session' ), 'update_title returns true', $failures, $passes ); +smoke_assert( 'My session', $store->get_session( $session_id )['title'], 'title persisted', $failures, $passes ); +smoke_assert( true, $store->delete_session( $session_id ), 'delete_session returns true', $failures, $passes ); +smoke_assert( null, $store->get_session( $session_id ), 'session gone after delete', $failures, $passes ); +smoke_assert( true, $store->delete_session( $session_id ), 'delete is idempotent on missing session', $failures, $passes ); + +echo "\n[5] Principal (audience) owner is first-class, distinct from user owner:\n"; +$aud_owner = array( 'type' => 'audience', 'key' => 'browser:one' ); +$aud_id = $store->create_session_for_owner( $workspace, $aud_owner, 'demo-agent', array(), 'chat' ); +$aud = $store->get_session( $aud_id ); +smoke_assert( 'audience', $aud['owner_type'], 'audience owner_type recorded', $failures, $passes ); +smoke_assert( 'browser:one', $aud['owner_key'], 'audience owner_key recorded', $failures, $passes ); +smoke_assert( 0, $aud['user_id'], 'audience session has user_id 0', $failures, $passes ); + +echo "\n[6] list scopes by owner — user and audience do not bleed:\n"; +$u1 = $store->create_session( $workspace, 7, 'demo-agent', array(), 'chat' ); +$u2 = $store->create_session( $workspace, 7, 'demo-agent', array(), 'chat' ); +$store->create_session( $workspace, 8, 'demo-agent', array(), 'chat' ); +$user7 = $store->list_sessions( $workspace, 7, array() ); +smoke_assert( 2, count( $user7 ), 'user 7 sees only its two sessions', $failures, $passes ); +$aud_list = $store->list_sessions_for_owner( $workspace, $aud_owner, array() ); +smoke_assert( 1, count( $aud_list ), 'audience owner sees only its one session', $failures, $passes ); + +echo "\n[7] recent pending session dedup respects owner + empty transcript:\n"; +$pending = $store->get_recent_pending_session( $workspace, 7, 600, 'chat' ); +smoke_assert( true, is_array( $pending ), 'an empty recent session is treated as pending', $failures, $passes ); +$store->update_session( $user7[0]['session_id'], array( array( 'role' => 'user', 'content' => 'x' ) ) ); +$store->update_session( $user7[1]['session_id'], array( array( 'role' => 'user', 'content' => 'y' ) ) ); +$store->update_session( $u1, array( array( 'role' => 'user', 'content' => 'z' ) ) ); +$store->update_session( $u2, array( array( 'role' => 'user', 'content' => 'w' ) ) ); +$pending_after = $store->get_recent_pending_session( $workspace, 7, 600, 'chat' ); +smoke_assert( null, $pending_after, 'no pending session once all are non-empty and unlocked', $failures, $passes ); + +echo "\n[8] Lock acquire / release / contention / CAS reclaim:\n"; +$lock_session = $store->create_session( $workspace, 7, 'demo-agent', array(), 'chat' ); +$token = $store->acquire_session_lock( $lock_session, 300 ); +smoke_assert( true, is_string( $token ) && '' !== $token, 'acquire returns a token on a free session', $failures, $passes ); +smoke_assert( null, $store->acquire_session_lock( $lock_session, 300 ), 'second acquire loses while lock is active', $failures, $passes ); +smoke_assert( false, $store->release_session_lock( $lock_session, 'wrong-token' ), 'release with wrong token fails', $failures, $passes ); +smoke_assert( true, $store->release_session_lock( $lock_session, $token ), 'release with correct token succeeds', $failures, $passes ); +$token2 = $store->acquire_session_lock( $lock_session, 300 ); +smoke_assert( true, is_string( $token2 ) && '' !== $token2, 'reacquire after release succeeds', $failures, $passes ); + +// Force an expired lock and confirm CAS reclaim. +$post_id = null; +foreach ( $GLOBALS['__posts'] as $pid => $p ) { + if ( (string) get_post_meta( $pid, '_agents_api_session_id', true ) === $lock_session ) { + $post_id = $pid; + break; + } +} +update_post_meta( $post_id, '_agents_api_lock', wp_json_encode( array( 'token' => 'stale', 'expires' => time() - 10 ) ) ); +$token3 = $store->acquire_session_lock( $lock_session, 300 ); +smoke_assert( true, is_string( $token3 ) && '' !== $token3, 'expired lock is reclaimed via compare-and-swap', $failures, $passes ); + +if ( $failures ) { + echo "\nFAILED: " . count( $failures ) . " CPT conversation store assertions failed.\n"; + exit( 1 ); +} + +echo "\nAll {$passes} CPT conversation store assertions passed.\n"; From 33678352b8c18aa0933c7d780241c5778aa10be0 Mon Sep 17 00:00:00 2001 From: Miguel Lezama Date: Fri, 29 May 2026 11:51:35 -0300 Subject: [PATCH 2/2] Replace short ternaries with explicit checks (phpcs) --- .../class-wp-agent-cpt-conversation-store.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Transcripts/class-wp-agent-cpt-conversation-store.php b/src/Transcripts/class-wp-agent-cpt-conversation-store.php index 844d6b4..e15ecd2 100644 --- a/src/Transcripts/class-wp-agent-cpt-conversation-store.php +++ b/src/Transcripts/class-wp-agent-cpt-conversation-store.php @@ -482,8 +482,16 @@ private function session_array( \WP_Post $post ): array { $metadata = array(); } - $owner_type = (string) ( get_post_meta( $post->ID, self::META_OWNER_TYPE, true ) ?: self::OWNER_TYPE_USER ); - $owner_key = (string) get_post_meta( $post->ID, self::META_OWNER_KEY, true ); + $owner_type = (string) get_post_meta( $post->ID, self::META_OWNER_TYPE, true ); + if ( '' === $owner_type ) { + $owner_type = self::OWNER_TYPE_USER; + } + $owner_key = (string) get_post_meta( $post->ID, self::META_OWNER_KEY, true ); + + $context = (string) get_post_meta( $post->ID, self::META_CONTEXT, true ); + if ( '' === $context ) { + $context = 'chat'; + } return array( 'session_id' => (string) get_post_meta( $post->ID, self::META_SESSION_ID, true ), @@ -499,8 +507,8 @@ private function session_array( \WP_Post $post ): array { 'provider' => (string) get_post_meta( $post->ID, self::META_PROVIDER, true ), 'model' => (string) get_post_meta( $post->ID, self::META_MODEL, true ), 'provider_response_id' => $this->nullable_meta_string( $post->ID, self::META_PROVIDER_RESPONSE_ID ), - 'context' => (string) ( get_post_meta( $post->ID, self::META_CONTEXT, true ) ?: 'chat' ), - 'mode' => (string) ( get_post_meta( $post->ID, self::META_CONTEXT, true ) ?: 'chat' ), + 'context' => $context, + 'mode' => $context, 'created_at' => (string) $post->post_date_gmt, 'updated_at' => (string) $post->post_modified_gmt, 'last_read_at' => $this->nullable_meta_string( $post->ID, self::META_LAST_READ_AT ),